mirror of
https://github.com/certbot/certbot.git
synced 2026-07-30 16:14:44 +02:00
Merge branch 'prettify' into all-together-now
This commit is contained in:
+103
-42
@@ -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, "")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user