Skip to content

Commit

Permalink
Merge pull request certbot#3466 from certbot/all-together-now
Browse files Browse the repository at this point in the history
DNS challenge support in the manual plugin and general purpose --preferred-challenges flag
  • Loading branch information
pde authored Sep 22, 2016
2 parents 4660c0d + 74ac006 commit 1584ee8
Show file tree
Hide file tree
Showing 15 changed files with 292 additions and 92 deletions.
4 changes: 2 additions & 2 deletions acme/acme/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ def simple_verify(self, chall, domain, account_public_key):
try:
from acme import dns_resolver
except ImportError: # pragma: no cover
raise errors.Error("Local validation for 'dns-01' challenges "
"requires 'dnspython'")
raise errors.DependencyError("Local validation for 'dns-01' "
"challenges requires 'dnspython'")
txt_records = dns_resolver.txt_records_for_name(validation_domain_name)
exists = validation in txt_records
if not exists:
Expand Down
4 changes: 4 additions & 0 deletions acme/acme/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class Error(Exception):
"""Generic ACME error."""


class DependencyError(Error):
"""Dependency error"""


class SchemaValidationError(jose_errors.DeserializationError):
"""JSON schema ACME object validation error."""

Expand Down
19 changes: 16 additions & 3 deletions certbot/auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ class AuthHandler(object):
and values are :class:`acme.messages.AuthorizationResource`
:ivar list achalls: DV challenges in the form of
:class:`certbot.achallenges.AnnotatedChallenge`
:ivar list pref_challs: sorted user specified preferred challenges
in the form of subclasses of :class:`acme.challenges.Challenge`
with the most preferred challenge listed first
"""
def __init__(self, auth, acme, account):
def __init__(self, auth, acme, account, pref_challs):
self.auth = auth
self.acme = acme

self.account = account
self.authzr = dict()
self.pref_challs = pref_challs

# List must be used to keep responses straight.
self.achalls = []
Expand Down Expand Up @@ -244,9 +248,18 @@ def _get_chall_pref(self, domain):
:param str domain: domain for which you are requesting preferences
"""
# Make sure to make a copy...
chall_prefs = []
chall_prefs.extend(self.auth.get_chall_pref(domain))
# Make sure to make a copy...
plugin_pref = self.auth.get_chall_pref(domain)
if self.pref_challs:
chall_prefs.extend(pref for pref in self.pref_challs
if pref in plugin_pref)
if chall_prefs:
return chall_prefs
raise errors.AuthorizationError(
"None of the preferred challenges "
"are supported by the selected plugin")
chall_prefs.extend(plugin_pref)
return chall_prefs

def _cleanup_challenges(self, achall_list=None):
Expand Down
34 changes: 32 additions & 2 deletions certbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import configargparse
import six

from acme import challenges

import certbot

from certbot import constants
Expand Down Expand Up @@ -788,11 +790,12 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
help=config_help("no_verify_ssl"),
default=flag_default("no_verify_ssl"))
helpful.add(
"testing", "--tls-sni-01-port", type=int,
["certonly", "renew", "run"], "--tls-sni-01-port", type=int,
default=flag_default("tls_sni_01_port"),
help=config_help("tls_sni_01_port"))
helpful.add(
"testing", "--http-01-port", type=int, dest="http01_port",
["certonly", "renew", "run", "manual"], "--http-01-port", type=int,
dest="http01_port",
default=flag_default("http01_port"), help=config_help("http01_port"))
helpful.add(
"testing", "--break-my-certs", action="store_true",
Expand Down Expand Up @@ -844,6 +847,18 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
"security", "--strict-permissions", action="store_true",
help="Require that all configuration files are owned by the current "
"user; only needed if your config is somewhere unsafe like /tmp/")
helpful.add(
["manual", "standalone", "certonly", "renew", "run"],
"--preferred-challenges", dest="pref_challs",
action=_PrefChallAction, default=[],
help='A sorted, comma delimited list of the preferred challenge to '
'use during authorization with the most preferred challenge '
'listed first (Eg, "dns" or "tls-sni-01,http,dns"). '
'Not all plugins support all challenges. See '
'https://certbot.eff.org/docs/using.html#plugins for details. '
'ACME Challenges are versioned, but if you pick "http" rather '
'than "http-01", Certbot will select the latest version '
'automatically.')
helpful.add(
"renew", "--pre-hook",
help="Command to be run in a shell before obtaining any certificates."
Expand Down Expand Up @@ -1032,3 +1047,18 @@ def add_domains(args_or_config, domains):
args_or_config.domains.append(domain)

return validated_domains

class _PrefChallAction(argparse.Action):
"""Action class for parsing preferred challenges."""

def __call__(self, parser, namespace, pref_challs, option_string=None):
aliases = {"dns": "dns-01", "http": "http-01", "tls-sni": "tls-sni-01"}
challs = [c.strip() for c in pref_challs.split(",")]
challs = [aliases[c] if c in aliases else c for c in challs]
unrecognized = ", ".join(name for name in challs
if name not in challenges.Challenge.TYPES)
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(unrecognized))
namespace.pref_challs.extend(challenges.Challenge.TYPES[name]
for name in challs)
2 changes: 1 addition & 1 deletion certbot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def __init__(self, config, account_, auth, installer, acme=None):

if auth is not None:
self.auth_handler = auth_handler.AuthHandler(
auth, self.acme, self.account)
auth, self.acme, self.account, self.config.pref_challs)
else:
self.auth_handler = None

Expand Down
9 changes: 6 additions & 3 deletions certbot/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,14 @@ class IConfig(zope.interface.Interface):
no_verify_ssl = zope.interface.Attribute(
"Disable verification of the ACME server's certificate.")
tls_sni_01_port = zope.interface.Attribute(
"Port number to perform tls-sni-01 challenge. "
"Boulder in testing mode defaults to 5001.")
"Port used during tls-sni-01 challenge. "
"This only affects the port Certbot listens on. "
"A conforming ACME server will still attempt to connect on port 443.")

http01_port = zope.interface.Attribute(
"Port used in the SimpleHttp challenge.")
"Port used in the http-01 challenge."
"This only affects the port Certbot listens on. "
"A conforming ACME server will still attempt to connect on port 80.")


class IInstaller(IPlugin):
Expand Down
114 changes: 86 additions & 28 deletions certbot/plugins/manual.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import zope.interface

from acme import challenges
from acme import errors as acme_errors

from certbot import errors
from certbot import interfaces
Expand All @@ -41,7 +42,16 @@ class Authenticator(common.Plugin):

description = "Manually configure an HTTP server"

MESSAGE_TEMPLATE = """\
MESSAGE_TEMPLATE = {
"dns-01": """\
Please deploy a DNS TXT record under the name
{domain} with the following value:
{validation}
Once this is deployed,
""",
"http-01": """\
Make sure your web server displays the following content at
{uri} before continuing:
Expand All @@ -51,7 +61,7 @@ class Authenticator(common.Plugin):
command on the target server (as root):
{command}
"""
"""}

# a disclaimer about your current IP being transmitted to Let's Encrypt's servers.
IP_DISCLAIMER = """\
Expand Down Expand Up @@ -97,21 +107,29 @@ def prepare(self): # pylint: disable=missing-docstring,no-self-use

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 "
"does not need to be run as a privileged process. "
"Alternatively shows instructions on how to use Python's "
"built-in HTTP server.")
"up challenges to prove control of a domain and does not need "
"to be run as a privileged process. When solving "
"http-01 challenges, the user is responsible for setting up "
"an HTTP server. Alternatively, instructions are shown on how "
"to use Python's built-in HTTP server. The user is "
"responsible for configuration of a domain's DNS when solving "
"dns-01 challenges. The type of challenges used can be "
"controlled through the --preferred-challenges flag.")

def get_chall_pref(self, domain):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return [challenges.HTTP01]
return [challenges.HTTP01, challenges.DNS01]

def perform(self, achalls): # pylint: disable=missing-docstring
def perform(self, achalls):
# pylint: disable=missing-docstring
self._get_ip_logging_permission()
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
Expand All @@ -128,7 +146,13 @@ def _test_mode_busy_wait(cls, port):
finally:
sock.close()

def _perform_single(self, achall):
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_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
Expand Down Expand Up @@ -162,19 +186,16 @@ def _perform_single(self, achall):
# 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)

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")

self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
validation=validation, response=response,
uri=achall.chall.uri(achall.domain),
command=command))
self._notify_and_wait(
self._get_message(achall).format(
validation=validation,
response=response,
uri=achall.chall.uri(achall.domain),
command=command))

if not response.simple_verify(
achall.chall, achall.domain,
Expand All @@ -183,15 +204,30 @@ def _perform_single(self, achall):

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"):
self._notify_and_wait(
self._get_message(achall).format(
validation=validation,
domain=achall.validation_domain_name(achall.domain),
response=response))

try:
verification_status = response.simple_verify(
achall.chall, achall.domain,
achall.account_key.public_key())
except acme_errors.DependencyError:
logger.warning("Self verification requires optional "
"dependency `dnspython` to be installed.")
else:
if not verification_status:
logger.warning("Self-verify of challenge failed.")

def cleanup(self, achalls):
# pylint: disable=missing-docstring,no-self-use,unused-argument
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()")
Expand All @@ -202,3 +238,25 @@ def cleanup(self, achalls):
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_ip_logging_permission(self):
# pylint: disable=missing-docstring
if not (self.conf("test-mode") or 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

def _get_message(self, achall):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return self.MESSAGE_TEMPLATE.get(achall.chall.typ, "")
Loading

0 comments on commit 1584ee8

Please sign in to comment.