Merge branch 'prettify' into all-together-now

This commit is contained in:
Brad Warren
2016-08-29 15:20:00 -07:00
10 changed files with 248 additions and 66 deletions
+103 -42
View File
@@ -4,26 +4,31 @@ 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 acme import errors as acme_errors
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 +46,15 @@ class Authenticator(common.Plugin):
description = "Manually configure an HTTP server"
MESSAGE_TEMPLATE = """\
MESSAGE_TEMPLATE = {
"dns-01": """\
To prove control of the domain {domain}, please deploy a DNS TXT record with the following value:
{validation}
Once this is deployed,
""",
"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,20 +184,19 @@ 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:
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")
message = self._get_message(achall)
uri = achall.chall.uri(achall.domain)
formated_message = message.format(validation=validation,
response=response,
uri=uri,
command=command)
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
validation=validation, response=response,
uri=achall.chall.uri(achall.domain),
command=command))
self._ip_logging_permission(formated_message)
if not response.simple_verify(
achall.chall, achall.domain,
@@ -183,15 +205,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"):
message = self._get_message(achall)
formated_message = message.format(validation=validation,
domain=achall.domain,
response=response)
self._ip_logging_permission(formated_message)
def cleanup(self, achalls):
# pylint: disable=missing-docstring,no-self-use,unused-argument
try:
verification_status = response.simple_verify(
achall.chall, achall.domain,
achall.account_key.public_key())
except acme_errors.DependencyError:
verification_status = False
logger.warning("Dns challenge requires `dnspython`")
if not verification_status:
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 +239,27 @@ 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 _ip_logging_permission(self, formated_message):
# pylint: disable=missing-docstring
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")
else:
self.config.namespace.manual_public_ip_logging_ok = True
self._notify_and_wait(formated_message)
def _get_message(self, achall):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return self.MESSAGE_TEMPLATE.get(achall.chall.typ, "")
+34 -12
View File
@@ -5,6 +5,7 @@ import unittest
import mock
from acme import challenges
from acme import errors as acme_errors
from acme import jose
from certbot import achallenges
@@ -24,10 +25,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 +63,34 @@ 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("acme.challenges.DNS01Response.simple_verify")
@mock.patch("six.moves.input")
def test_perform_missing_dependency(self, mock_raw_input, mock_verify, mock_interaction):
mock_interaction().yesno.return_value = True
mock_verify.side_effect = acme_errors.DependencyError()
with mock.patch("certbot.plugins.manual.logger") as mock_logger:
self.auth.perform([self.dns01])
self.assertEqual(2, mock_logger.warning.call_count)
mock_raw_input.assert_called_once_with("Press ENTER to continue")
@mock.patch("certbot.plugins.manual.zope.component.getUtility")
@mock.patch("certbot.plugins.manual.Authenticator._notify_and_wait")
@@ -82,10 +104,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):
+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
@@ -211,5 +212,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