Add dns-01 challenge support to the Manual plugin

This commit is contained in:
Mathieu Leduc-Hamel
2016-08-13 10:52:03 -04:00
parent 5d8744bf7c
commit f7a09e5e44
10 changed files with 221 additions and 118 deletions
+90 -36
View File
@@ -4,26 +4,30 @@ import logging
import pipes
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import six
import zope.component
import zope.interface
from functools import partial
from acme import challenges
from certbot import errors
from certbot import interfaces
from certbot.plugins import common
from certbot.plugins import common, util
from certbot.util import busy_wait
logger = logging.getLogger(__name__)
SUPPORTED_CHALLENGES = [challenges.HTTP01, challenges.DNS01]
@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(common.Plugin):
@@ -41,7 +45,16 @@ class Authenticator(common.Plugin):
description = "Manually configure an HTTP server"
MESSAGE_TEMPLATE = """\
MESSAGE_TEMPLATE = {
"dns-01": """\
Make sure your dns configuration content the following key before continuing:
{validation}
if you didn't, make sure to add the validation as TXT record into your domain
configuration.
""",
"http-01": """\
Make sure your web server displays the following content at
{uri} before continuing:
@@ -51,7 +64,7 @@ If you don't have HTTP server configured, you can run the following
command on the target server (as root):
{command}
"""
"""}
# a disclaimer about your current IP being transmitted to Let's Encrypt's servers.
IP_DISCLAIMER = """\
@@ -86,10 +99,23 @@ s.serve_forever()" """
@classmethod
def add_parser_arguments(cls, add):
validator = partial(util.supported_challenges_validator,
supported=SUPPORTED_CHALLENGES)
add("test-mode", action="store_true",
help="Test mode. Executes the manual command in subprocess.")
add("public-ip-logging-ok", action="store_true",
help="Automatically allows public IP logging.")
add("supported-challenges",
help="Supported challenges. Preferred in the order they are listed.",
type=validator,
default="http-01")
@property
def supported_challenges(self):
"""Challenges supported by this plugin."""
return [challenges.Challenge.TYPES[name] for name in
self.conf("supported-challenges").split(",")]
def prepare(self): # pylint: disable=missing-docstring,no-self-use
if self.config.noninteractive_mode and not self.conf("test-mode"):
@@ -97,38 +123,35 @@ s.serve_forever()" """
def more_info(self): # pylint: disable=missing-docstring,no-self-use
return ("This plugin requires user's manual intervention in setting "
"up an HTTP server for solving http-01 challenges and thus "
"up an HTTP server when solving http-01 challenges and thus "
"does not need to be run as a privileged process. "
"Alternatively shows instructions on how to use Python's "
"built-in HTTP server.")
"built-in HTTP server."
"When solving dns-01 challenges, it simply needs to wait for "
"the proper configuration of the domain's dns")
def get_chall_pref(self, domain):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return [challenges.HTTP01]
return self.supported_challenges
def perform(self, achalls): # pylint: disable=missing-docstring
def perform(self, achalls):
# pylint: disable=missing-docstring
mapping = {"http-01": self._perform_http01_challenge,
"dns-01": self._perform_dns01_challenge}
responses = []
# TODO: group achalls by the same socket.gethostbyname(_ex)
# and prompt only once per server (one "echo -n" per domain)
for achall in achalls:
responses.append(self._perform_single(achall))
responses.append(mapping[achall.typ](achall))
return responses
@classmethod
def _test_mode_busy_wait(cls, port):
while True:
time.sleep(1)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect(("localhost", port))
except socket.error: # pragma: no cover
pass
else:
break
finally:
sock.close()
def cleanup(self, achalls):
# pylint: disable=missing-docstring
for achall in achalls:
if isinstance(achall.chall, challenges.HTTP01):
self._cleanup_http01_challenge(achall)
def _perform_single(self, achall):
def _perform_http01_challenge(self, achall):
# same path for each challenge response would be easier for
# users, but will not work if multiple domains point at the
# same server: default command doesn't support virtual hosts
@@ -161,7 +184,8 @@ s.serve_forever()" """
logger.debug("Manual command running as PID %s.", self._httpd.pid)
# give it some time to bootstrap, before we try to verify
# (cert generation in case of simpleHttpS might take time)
self._test_mode_busy_wait(port)
busy_wait(port)
if self._httpd.poll() is not None:
raise errors.Error("Couldn't execute manual command")
else:
@@ -171,10 +195,14 @@ s.serve_forever()" """
cli_flag="--manual-public-ip-logging-ok"):
raise errors.PluginError("Must agree to IP logging to proceed")
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
validation=validation, response=response,
message = self._get_message(achall)
self._notify_and_wait(message.format(
validation=validation,
response=response,
uri=achall.chall.uri(achall.domain),
command=command))
command=command
))
if not response.simple_verify(
achall.chall, achall.domain,
@@ -183,15 +211,30 @@ s.serve_forever()" """
return response
def _notify_and_wait(self, message): # pylint: disable=no-self-use
# TODO: IDisplay wraps messages, breaking the command
#answer = zope.component.getUtility(interfaces.IDisplay).notification(
# message=message, height=25, pause=True)
sys.stdout.write(message)
six.moves.input("Press ENTER to continue")
def _perform_dns01_challenge(self, achall):
response, validation = achall.response_and_validation()
if not self.conf("test-mode"):
if not self.conf("public-ip-logging-ok"):
if not zope.component.getUtility(interfaces.IDisplay).yesno(
self.IP_DISCLAIMER, "Yes", "No",
cli_flag="--manual-public-ip-logging-ok"):
raise errors.PluginError("Must agree to IP logging to proceed")
def cleanup(self, achalls):
# pylint: disable=missing-docstring,no-self-use,unused-argument
message = self._get_message(achall)
formated_message = message.format(validation=validation,
response=response)
self._notify_and_wait(formated_message)
if not response.simple_verify(
achall.chall, achall.domain,
achall.account_key.public_key()):
logger.warning("Self-verify of challenge failed.")
return response
def _cleanup_http01_challenge(self, achall):
# pylint: disable=missing-docstring,unused-argument
if self.conf("test-mode"):
assert self._httpd is not None, (
"cleanup() must be called after perform()")
@@ -202,3 +245,14 @@ s.serve_forever()" """
logger.debug("Manual command process already terminated "
"with %s code", self._httpd.returncode)
shutil.rmtree(self._root)
def _notify_and_wait(self, message): # pylint: disable=no-self-use
# TODO: IDisplay wraps messages, breaking the command
#answer = zope.component.getUtility(interfaces.IDisplay).notification(
# message=message, height=25, pause=True)
sys.stdout.write(message)
six.moves.input("Press ENTER to continue")
def _get_message(self, achall):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return self.MESSAGE_TEMPLATE.get(achall.chall.typ, "")
+20 -12
View File
@@ -24,10 +24,16 @@ class AuthenticatorTest(unittest.TestCase):
from certbot.plugins.manual import Authenticator
self.config = mock.MagicMock(
http01_port=8080, manual_test_mode=False,
manual_public_ip_logging_ok=False, noninteractive_mode=True)
manual_public_ip_logging_ok=False, noninteractive_mode=True,
standalone_supported_challenges="dns-01,http-01")
self.auth = Authenticator(config=self.config, name="manual")
self.achalls = [achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)]
self.http01 = achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)
self.dns01 = achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.DNS01_P, domain="foo.com", account_key=KEY)
self.achalls = [self.http01, self.dns01]
config_test_mode = mock.MagicMock(
http01_port=8080, manual_test_mode=True, noninteractive_mode=True)
@@ -56,19 +62,21 @@ class AuthenticatorTest(unittest.TestCase):
mock_verify.return_value = True
mock_interaction().yesno.return_value = True
resp = self.achalls[0].response(KEY)
self.assertEqual([resp], self.auth.perform(self.achalls))
self.assertEqual(1, mock_raw_input.call_count)
resp_http = self.http01.response(KEY)
resp_dns = self.dns01.response(KEY)
self.assertEqual([resp_http, resp_dns], self.auth.perform(self.achalls))
self.assertEqual(2, mock_raw_input.call_count)
mock_verify.assert_called_with(
self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 8080)
self.http01.challb.chall, "foo.com", KEY.public_key(), 8080)
message = mock_stdout.write.mock_calls[0][1][0]
self.assertTrue(self.achalls[0].chall.encode("token") in message)
self.assertTrue(self.http01.chall.encode("token") in message)
mock_verify.return_value = False
with mock.patch("certbot.plugins.manual.logger") as mock_logger:
self.auth.perform(self.achalls)
mock_logger.warning.assert_called_once_with(mock.ANY)
self.assertEqual(2, mock_logger.warning.call_count)
@mock.patch("certbot.plugins.manual.zope.component.getUtility")
@mock.patch("certbot.plugins.manual.Authenticator._notify_and_wait")
@@ -82,10 +90,10 @@ class AuthenticatorTest(unittest.TestCase):
@mock.patch("certbot.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_command_oserror(self, mock_popen):
mock_popen.side_effect = OSError
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
self.assertEqual([False], self.auth_test_mode.perform([self.http01]))
@mock.patch("certbot.plugins.manual.socket.socket")
@mock.patch("certbot.plugins.manual.time.sleep", autospec=True)
@mock.patch("certbot.util.socket.socket")
@mock.patch("certbot.util.time.sleep", autospec=True)
@mock.patch("certbot.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_command_run_failure(
self, mock_popen, unused_mock_sleep, unused_mock_socket):
+6 -32
View File
@@ -1,5 +1,4 @@
"""Standalone Authenticator."""
import argparse
import collections
import logging
import socket
@@ -9,6 +8,8 @@ import OpenSSL
import six
import zope.interface
from functools import partial
from acme import challenges
from acme import standalone as acme_standalone
@@ -113,36 +114,6 @@ class ServerManager(object):
SUPPORTED_CHALLENGES = [challenges.TLSSNI01, challenges.HTTP01]
def supported_challenges_validator(data):
"""Supported challenges validator for the `argparse`.
It should be passed as `type` argument to `add_argument`.
"""
challs = data.split(",")
# tls-sni-01 was dvsni during private beta
if "dvsni" in challs:
logger.info("Updating legacy standalone_supported_challenges value")
challs = [challenges.TLSSNI01.typ if chall == "dvsni" else chall
for chall in challs]
data = ",".join(challs)
unrecognized = [name for name in challs
if name not in challenges.Challenge.TYPES]
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(", ".join(unrecognized)))
choices = set(chall.typ for chall in SUPPORTED_CHALLENGES)
if not set(challs).issubset(choices):
raise argparse.ArgumentTypeError(
"Plugin does not support the following (valid) "
"challenges: {0}".format(", ".join(set(challs) - choices)))
return data
@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(common.Plugin):
@@ -176,9 +147,12 @@ class Authenticator(common.Plugin):
@classmethod
def add_parser_arguments(cls, add):
validator = partial(util.supported_challenges_validator,
supported=SUPPORTED_CHALLENGES)
add("supported-challenges",
help="Supported challenges. Preferred in the order they are listed.",
type=supported_challenges_validator,
type=validator,
default=",".join(chall.typ for chall in SUPPORTED_CHALLENGES))
@property
-28
View File
@@ -1,5 +1,4 @@
"""Tests for certbot.plugins.standalone."""
import argparse
import socket
import unittest
@@ -64,33 +63,6 @@ class ServerManagerTest(unittest.TestCase):
self.assertEqual(self.mgr.running(), {})
class SupportedChallengesValidatorTest(unittest.TestCase):
"""Tests for plugins.standalone.supported_challenges_validator."""
def _call(self, data):
from certbot.plugins.standalone import (
supported_challenges_validator)
return supported_challenges_validator(data)
def test_correct(self):
self.assertEqual("tls-sni-01", self._call("tls-sni-01"))
self.assertEqual("http-01", self._call("http-01"))
self.assertEqual("tls-sni-01,http-01", self._call("tls-sni-01,http-01"))
self.assertEqual("http-01,tls-sni-01", self._call("http-01,tls-sni-01"))
def test_unrecognized(self):
assert "foo" not in challenges.Challenge.TYPES
self.assertRaises(argparse.ArgumentTypeError, self._call, "foo")
def test_not_subset(self):
self.assertRaises(argparse.ArgumentTypeError, self._call, "dns")
def test_dvsni(self):
self.assertEqual("tls-sni-01", self._call("dvsni"))
self.assertEqual("http-01,tls-sni-01", self._call("http-01,dvsni"))
self.assertEqual("tls-sni-01,http-01", self._call("dvsni,http-01"))
class AuthenticatorTest(unittest.TestCase):
"""Tests for certbot.plugins.standalone.Authenticator."""
+36
View File
@@ -1,10 +1,13 @@
"""Plugin utilities."""
import argparse
import logging
import os
import socket
import zope.component
from acme import challenges
from certbot import interfaces
from certbot import util
@@ -154,3 +157,36 @@ def already_listening_psutil(port, renewer=False):
# name (AccessDenied).
pass
return False
def supported_challenges_validator(data, supported=None):
"""Supported challenges validator for the `argparse`.
It should be passed as `type` argument to `add_argument`.
:param str data: input value representing the supported_challenges
:returns: original value if valid
"""
challs = data.split(",")
supported = supported or []
# tls-sni-01 was dvsni during private beta
if "dvsni" in challs:
logger.info("Updating legacy standalone_supported_challenges value")
challs = [challenges.TLSSNI01.typ if chall == "dvsni" else chall
for chall in challs]
data = ",".join(challs)
unrecognized = [name for name in challs
if name not in challenges.Challenge.TYPES]
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(", ".join(unrecognized)))
choices = set(chall.typ for chall in supported)
if not set(challs).issubset(choices):
raise argparse.ArgumentTypeError(
"Plugin does not support the following (valid) "
"challenges: {0}".format(", ".join(set(challs) - choices)))
return data
+38
View File
@@ -1,4 +1,5 @@
"""Tests for certbot.plugins.util."""
import argparse
import os
import unittest
import sys
@@ -181,5 +182,42 @@ class AlreadyListeningTestPsutil(unittest.TestCase):
mock_net.side_effect = psutil.AccessDenied("")
self.assertFalse(self._call(12345))
class SupportedChallengesValidatorTest(unittest.TestCase):
"""Tests for plugins.standalone.supported_challenges_validator."""
def _call(self, data):
from certbot.plugins.util import supported_challenges_validator
from acme import challenges
supported = [challenges.HTTP01, challenges.DNS01, challenges.TLSSNI01]
return supported_challenges_validator(data, supported=supported)
def test_correct(self):
self.assertEqual("tls-sni-01", self._call("tls-sni-01"))
self.assertEqual("http-01", self._call("http-01"))
self.assertEqual("tls-sni-01,http-01", self._call("tls-sni-01,http-01"))
self.assertEqual("http-01,tls-sni-01", self._call("http-01,tls-sni-01"))
def test_unrecognized(self):
from acme import challenges
assert "foo" not in challenges.Challenge.TYPES
self.assertRaises(argparse.ArgumentTypeError, self._call, "foo")
def test_not_subset(self):
self.assertRaises(argparse.ArgumentTypeError, self._call, "dns")
def test_dvsni(self):
self.assertEqual("tls-sni-01", self._call("dvsni"))
self.assertEqual("http-01,tls-sni-01", self._call("http-01,dvsni"))
self.assertEqual("tls-sni-01,http-01", self._call("dvsni,http-01"))
def test_dns01(self):
self.assertEqual("dns-01", self._call("dns-01"))
self.assertEqual("http-01,dns-01", self._call("http-01,dns-01"))
self.assertEqual("dns-01,http-01", self._call("dns-01,http-01"))
if __name__ == "__main__":
unittest.main() # pragma: no cover
+4 -4
View File
@@ -17,9 +17,9 @@ HTTP01 = challenges.HTTP01(
token=b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA")
TLSSNI01 = challenges.TLSSNI01(
token=jose.b64decode(b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJyPCt92wrDoA"))
DNS = challenges.DNS(token=b"17817c66b60ce2e4012dfad92657527a")
DNS01 = challenges.DNS01(token=b"17817c66b60ce2e4012dfad92657527a")
CHALLENGES = [HTTP01, TLSSNI01, DNS]
CHALLENGES = [HTTP01, TLSSNI01, DNS01]
def gen_combos(challbs):
@@ -45,9 +45,9 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name
# Pending ChallengeBody objects
TLSSNI01_P = chall_to_challb(TLSSNI01, messages.STATUS_PENDING)
HTTP01_P = chall_to_challb(HTTP01, messages.STATUS_PENDING)
DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING)
DNS01_P = chall_to_challb(DNS01, messages.STATUS_PENDING)
CHALLENGES_P = [HTTP01_P, TLSSNI01_P, DNS_P]
CHALLENGES_P = [HTTP01_P, TLSSNI01_P, DNS01_P]
def gen_authzr(authz_status, domain, challs, statuses, combos=True):
+4 -4
View File
@@ -111,7 +111,7 @@ class GetAuthorizationsTest(unittest.TestCase):
mock_poll.side_effect = self._validate_all
self.mock_auth.get_chall_pref.return_value.append(challenges.HTTP01)
self.mock_auth.get_chall_pref.return_value.append(challenges.DNS)
self.mock_auth.get_chall_pref.return_value.append(challenges.DNS01)
authzr = self.handler.get_authorizations(["0"])
@@ -125,7 +125,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
# Test if list first element is TLSSNI01, use typ because it is an achall
for achall in self.mock_auth.cleanup.call_args[0][0]:
self.assertTrue(achall.typ in ["tls-sni-01", "http-01", "dns"])
self.assertTrue(achall.typ in ["tls-sni-01", "http-01", "dns-01"])
# Length of authorizations list
self.assertEqual(len(authzr), 1)
@@ -240,7 +240,7 @@ class PollChallengesTest(unittest.TestCase):
from certbot.auth_handler import challb_to_achall
self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid
self.chall_update[self.doms[0]].append(
challb_to_achall(acme_util.DNS_P, "key", self.doms[0]))
challb_to_achall(acme_util.DNS01_P, "key", self.doms[0]))
self.assertRaises(
errors.AuthorizationError, self.handler._poll_challenges,
self.chall_update, False)
@@ -342,7 +342,7 @@ class GenChallengePathTest(unittest.TestCase):
self.assertTrue(self._call(challbs[::-1], prefs, None))
def test_not_supported(self):
challbs = (acme_util.DNS_P, acme_util.TLSSNI01_P)
challbs = (acme_util.DNS01_P, acme_util.TLSSNI01_P)
prefs = [challenges.TLSSNI01]
combos = ((0, 1),)
+2 -2
View File
@@ -16,12 +16,12 @@ class FaiiledChallengesTest(unittest.TestCase):
from certbot.errors import FailedChallenges
self.error = FailedChallenges(set([achallenges.DNS(
domain="example.com", challb=messages.ChallengeBody(
chall=acme_util.DNS, uri=None,
chall=acme_util.DNS01, uri=None,
error=messages.Error(typ="tls", detail="detail")))]))
def test_str(self):
self.assertTrue(str(self.error).startswith(
"Failed authorization procedure. example.com (dns): tls"))
"Failed authorization procedure. example.com (dns-01): tls"))
class StandaloneBindErrorTest(unittest.TestCase):
+21
View File
@@ -14,6 +14,7 @@ import socket
import stat
import subprocess
import sys
import time
import configargparse
@@ -474,3 +475,23 @@ def get_strict_version(normalized):
# strict version ending with "a" and a number designates a pre-release
# pylint: disable=no-member
return distutils.version.StrictVersion(normalized.replace(".dev", "a"))
def busy_wait(port, host="localhost"):
"""Artificialy wait a fixed amount of time on a specific host and port
:param str port: port of the connection
:param str host: hostname of the connection, "localhost" if None
"""
while True:
time.sleep(1)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((host, port))
except socket.error: # pragma: no cover
pass
else:
break
finally:
sock.close()