From f7a09e5e44b25d5631898f771022ac94df4334ae Mon Sep 17 00:00:00 2001 From: Mathieu Leduc-Hamel Date: Sun, 7 Aug 2016 10:51:25 -0400 Subject: [PATCH] Add dns-01 challenge support to the Manual plugin --- certbot/plugins/manual.py | 126 ++++++++++++++++++++--------- certbot/plugins/manual_test.py | 32 +++++--- certbot/plugins/standalone.py | 38 ++------- certbot/plugins/standalone_test.py | 28 ------- certbot/plugins/util.py | 36 +++++++++ certbot/plugins/util_test.py | 38 +++++++++ certbot/tests/acme_util.py | 8 +- certbot/tests/auth_handler_test.py | 8 +- certbot/tests/errors_test.py | 4 +- certbot/util.py | 21 +++++ 10 files changed, 221 insertions(+), 118 deletions(-) diff --git a/certbot/plugins/manual.py b/certbot/plugins/manual.py index 6c7b822ab..608484031 100644 --- a/certbot/plugins/manual.py +++ b/certbot/plugins/manual.py @@ -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, "") diff --git a/certbot/plugins/manual_test.py b/certbot/plugins/manual_test.py index dd0905049..0686a24d7 100644 --- a/certbot/plugins/manual_test.py +++ b/certbot/plugins/manual_test.py @@ -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): diff --git a/certbot/plugins/standalone.py b/certbot/plugins/standalone.py index 97aca351a..bf38ced27 100644 --- a/certbot/plugins/standalone.py +++ b/certbot/plugins/standalone.py @@ -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 diff --git a/certbot/plugins/standalone_test.py b/certbot/plugins/standalone_test.py index eb6631732..914332307 100644 --- a/certbot/plugins/standalone_test.py +++ b/certbot/plugins/standalone_test.py @@ -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.""" diff --git a/certbot/plugins/util.py b/certbot/plugins/util.py index b97ca1afd..5a8789c79 100644 --- a/certbot/plugins/util.py +++ b/certbot/plugins/util.py @@ -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 diff --git a/certbot/plugins/util_test.py b/certbot/plugins/util_test.py index e1a064fb3..798d7780e 100644 --- a/certbot/plugins/util_test.py +++ b/certbot/plugins/util_test.py @@ -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 diff --git a/certbot/tests/acme_util.py b/certbot/tests/acme_util.py index 4f6e86cc7..de64dfef9 100644 --- a/certbot/tests/acme_util.py +++ b/certbot/tests/acme_util.py @@ -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): diff --git a/certbot/tests/auth_handler_test.py b/certbot/tests/auth_handler_test.py index fce130f7c..fcc22f41c 100644 --- a/certbot/tests/auth_handler_test.py +++ b/certbot/tests/auth_handler_test.py @@ -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),) diff --git a/certbot/tests/errors_test.py b/certbot/tests/errors_test.py index 67611ed45..f35a5ea08 100644 --- a/certbot/tests/errors_test.py +++ b/certbot/tests/errors_test.py @@ -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): diff --git a/certbot/util.py b/certbot/util.py index e78ae664c..e5d671ce1 100644 --- a/certbot/util.py +++ b/certbot/util.py @@ -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()