Preserve preferred-challenges on renewal (#4112)

* use challenge type strings, not objectS

* Factor out parse_preferred_challenges

* restore pref_challs

* save pref_challs

* Make CheckCertCount more flexible

* improve integration tests

* Make pref_challs more flexible
This commit is contained in:
Brad Warren
2017-01-25 18:40:22 -08:00
committed by GitHub
parent 2f50dfd7be
commit 4d860b37b0
7 changed files with 110 additions and 32 deletions
+5 -4
View File
@@ -34,8 +34,7 @@ class AuthHandler(object):
:ivar list achalls: DV challenges in the form of :ivar list achalls: DV challenges in the form of
:class:`certbot.achallenges.AnnotatedChallenge` :class:`certbot.achallenges.AnnotatedChallenge`
:ivar list pref_challs: sorted user specified preferred challenges :ivar list pref_challs: sorted user specified preferred challenges
in the form of subclasses of :class:`acme.challenges.Challenge` type strings with the most preferred challenge listed first
with the most preferred challenge listed first
""" """
def __init__(self, auth, acme, account, pref_challs): def __init__(self, auth, acme, account, pref_challs):
@@ -252,8 +251,10 @@ class AuthHandler(object):
# Make sure to make a copy... # Make sure to make a copy...
plugin_pref = self.auth.get_chall_pref(domain) plugin_pref = self.auth.get_chall_pref(domain)
if self.pref_challs: if self.pref_challs:
chall_prefs.extend(pref for pref in self.pref_challs plugin_pref_types = set(chall.typ for chall in plugin_pref)
if pref in plugin_pref) for typ in self.pref_challs:
if typ in plugin_pref_types:
chall_prefs.append(challenges.Challenge.TYPES[typ])
if chall_prefs: if chall_prefs:
return chall_prefs return chall_prefs
raise errors.AuthorizationError( raise errors.AuthorizationError(
+29 -10
View File
@@ -1,4 +1,5 @@
"""Certbot command line argument & config processing.""" """Certbot command line argument & config processing."""
# pylint: disable=too-many-lines
from __future__ import print_function from __future__ import print_function
import argparse import argparse
import copy import copy
@@ -1236,13 +1237,31 @@ class _PrefChallAction(argparse.Action):
"""Action class for parsing preferred challenges.""" """Action class for parsing preferred challenges."""
def __call__(self, parser, namespace, pref_challs, option_string=None): def __call__(self, parser, namespace, pref_challs, option_string=None):
aliases = {"dns": "dns-01", "http": "http-01", "tls-sni": "tls-sni-01"} try:
challs = [c.strip() for c in pref_challs.split(",")] challs = parse_preferred_challenges(pref_challs.split(","))
challs = [aliases[c] if c in aliases else c for c in challs] except errors.Error as error:
unrecognized = ", ".join(name for name in challs raise argparse.ArgumentTypeError(str(error))
if name not in challenges.Challenge.TYPES) namespace.pref_challs.extend(challs)
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(unrecognized)) def parse_preferred_challenges(pref_challs):
namespace.pref_challs.extend(challenges.Challenge.TYPES[name] """Translate and validate preferred challenges.
for name in challs)
:param pref_challs: list of preferred challenge types
:type pref_challs: `list` of `str`
:returns: validated list of preferred challenge types
:rtype: `list` of `str`
:raises errors.Error: if pref_challs is invalid
"""
aliases = {"dns": "dns-01", "http": "http-01", "tls-sni": "tls-sni-01"}
challs = [c.strip() for c in pref_challs]
challs = [aliases.get(c, c) for c in challs]
unrecognized = ", ".join(name for name in challs
if name not in challenges.Challenge.TYPES)
if unrecognized:
raise errors.Error(
"Unrecognized challenges: {0}".format(unrecognized))
return challs
+24 -1
View File
@@ -35,7 +35,7 @@ INT_CONFIG_ITEMS = ["rsa_key_size", "tls_sni_01_port", "http01_port"]
BOOL_CONFIG_ITEMS = ["must_staple", "allow_subset_of_names"] BOOL_CONFIG_ITEMS = ["must_staple", "allow_subset_of_names"]
CONFIG_ITEMS = set(itertools.chain( CONFIG_ITEMS = set(itertools.chain(
BOOL_CONFIG_ITEMS, INT_CONFIG_ITEMS, STR_CONFIG_ITEMS)) BOOL_CONFIG_ITEMS, INT_CONFIG_ITEMS, STR_CONFIG_ITEMS, ('pref_challs',)))
def _reconstitute(config, full_path): def _reconstitute(config, full_path):
@@ -165,6 +165,7 @@ def restore_required_config_elements(config, renewalparams):
""" """
required_items = itertools.chain( required_items = itertools.chain(
(("pref_challs", _restore_pref_challs),),
six.moves.zip(BOOL_CONFIG_ITEMS, itertools.repeat(_restore_bool)), six.moves.zip(BOOL_CONFIG_ITEMS, itertools.repeat(_restore_bool)),
six.moves.zip(INT_CONFIG_ITEMS, itertools.repeat(_restore_int)), six.moves.zip(INT_CONFIG_ITEMS, itertools.repeat(_restore_int)),
six.moves.zip(STR_CONFIG_ITEMS, itertools.repeat(_restore_str))) six.moves.zip(STR_CONFIG_ITEMS, itertools.repeat(_restore_str)))
@@ -174,6 +175,28 @@ def restore_required_config_elements(config, renewalparams):
setattr(config.namespace, item_name, value) setattr(config.namespace, item_name, value)
def _restore_pref_challs(unused_name, value):
"""Restores preferred challenges from a renewal config file.
If value is a `str`, it should be a single challenge type.
:param str unused_name: option name
:param value: option value
:type value: `list` of `str` or `str`
:returns: converted option value to be stored in the runtime config
:rtype: `list` of `str`
:raises errors.Error: if value can't be converted to an bool
"""
# If pref_challs has only one element, configobj saves the value
# with a trailing comma so it's parsed as a list. If this comma is
# removed by the user, the value is parsed as a str.
value = [value] if isinstance(value, str) else value
return cli.parse_preferred_challenges(value)
def _restore_bool(name, value): def _restore_bool(name, value):
"""Restores an boolean key-value pair from a renewal config file. """Restores an boolean key-value pair from a renewal config file.
+3 -2
View File
@@ -176,7 +176,8 @@ class GetAuthorizationsTest(unittest.TestCase):
mock_poll.side_effect = self._validate_all 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.HTTP01)
self.handler.pref_challs.extend((challenges.HTTP01, challenges.DNS01,)) self.handler.pref_challs.extend((challenges.HTTP01.typ,
challenges.DNS01.typ,))
self.handler.get_authorizations(["0"]) self.handler.get_authorizations(["0"])
@@ -187,7 +188,7 @@ class GetAuthorizationsTest(unittest.TestCase):
def test_preferred_challenges_not_supported(self): def test_preferred_challenges_not_supported(self):
self.mock_net.request_domain_challenges.side_effect = functools.partial( self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES) gen_dom_authzr, challs=acme_util.CHALLENGES)
self.handler.pref_challs.append(challenges.HTTP01) self.handler.pref_challs.append(challenges.HTTP01.typ)
self.assertRaises( self.assertRaises(
errors.AuthorizationError, self.handler.get_authorizations, ["0"]) errors.AuthorizationError, self.handler.get_authorizations, ["0"])
+5 -3
View File
@@ -8,6 +8,8 @@ import mock
import six import six
from six.moves import reload_module # pylint: disable=import-error from six.moves import reload_module # pylint: disable=import-error
from acme import challenges
from certbot import cli from certbot import cli
from certbot import constants from certbot import constants
from certbot import errors from certbot import errors
@@ -178,12 +180,12 @@ class ParseTest(unittest.TestCase):
self.assertEqual(namespace.domains, ['example.com', 'another.net']) self.assertEqual(namespace.domains, ['example.com', 'another.net'])
def test_preferred_challenges(self): def test_preferred_challenges(self):
from acme.challenges import HTTP01, TLSSNI01, DNS01
short_args = ['--preferred-challenges', 'http, tls-sni-01, dns'] short_args = ['--preferred-challenges', 'http, tls-sni-01, dns']
namespace = self.parse(short_args) namespace = self.parse(short_args)
self.assertEqual(namespace.pref_challs, [HTTP01, TLSSNI01, DNS01]) expected = [challenges.HTTP01.typ,
challenges.TLSSNI01.typ, challenges.DNS01.typ]
self.assertEqual(namespace.pref_challs, expected)
short_args = ['--preferred-challenges', 'jumping-over-the-moon'] short_args = ['--preferred-challenges', 'jumping-over-the-moon']
self.assertRaises(argparse.ArgumentTypeError, self.parse, short_args) self.assertRaises(argparse.ArgumentTypeError, self.parse, short_args)
+25
View File
@@ -5,6 +5,8 @@ import unittest
import shutil import shutil
import tempfile import tempfile
from acme import challenges
from certbot import configuration from certbot import configuration
from certbot import errors from certbot import errors
from certbot import storage from certbot import storage
@@ -59,6 +61,29 @@ class RestoreRequiredConfigElementsTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
errors.Error, self._call, self.config, renewalparams) errors.Error, self._call, self.config, renewalparams)
@mock.patch('certbot.renewal.cli.set_by_cli')
def test_pref_challs_list(self, mock_set_by_cli):
mock_set_by_cli.return_value = False
renewalparams = {'pref_challs': 'tls-sni, http-01, dns'.split(',')}
self._call(self.config, renewalparams)
expected = [challenges.TLSSNI01.typ,
challenges.HTTP01.typ, challenges.DNS01.typ]
self.assertEqual(self.config.namespace.pref_challs, expected)
@mock.patch('certbot.renewal.cli.set_by_cli')
def test_pref_challs_str(self, mock_set_by_cli):
mock_set_by_cli.return_value = False
renewalparams = {'pref_challs': 'dns'}
self._call(self.config, renewalparams)
expected = [challenges.DNS01.typ]
self.assertEqual(self.config.namespace.pref_challs, expected)
@mock.patch('certbot.renewal.cli.set_by_cli')
def test_pref_challs_failure(self, mock_set_by_cli):
mock_set_by_cli.return_value = False
renewalparams = {'pref_challs': 'finding-a-shrubbery'}
self.assertRaises(errors.Error, self._call, self.config, renewalparams)
@mock.patch('certbot.renewal.cli.set_by_cli') @mock.patch('certbot.renewal.cli.set_by_cli')
def test_must_staple_success(self, mock_set_by_cli): def test_must_staple_success(self, mock_set_by_cli):
mock_set_by_cli.return_value = False mock_set_by_cli.return_value = False
+19 -12
View File
@@ -96,7 +96,7 @@ common certonly -a manual -d le.wtf --rsa-key-size 4096 \
--pre-hook 'echo wtf2.pre >> "$HOOK_TEST"' \ --pre-hook 'echo wtf2.pre >> "$HOOK_TEST"' \
--post-hook 'echo wtf2.post >> "$HOOK_TEST"' --post-hook 'echo wtf2.post >> "$HOOK_TEST"'
common certonly -a manual -d dns.le.wtf --preferred-challenges dns-01 \ common certonly -a manual -d dns.le.wtf --preferred-challenges dns,tls-sni \
--manual-auth-hook ./tests/manual-dns-auth.sh --manual-auth-hook ./tests/manual-dns-auth.sh
export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \ export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \
@@ -113,29 +113,30 @@ common --domains le3.wtf install \
--key-path "${root}/csr/key.pem" --key-path "${root}/csr/key.pem"
CheckCertCount() { CheckCertCount() {
CERTCOUNT=`ls "${root}/conf/archive/le.wtf/cert"* | wc -l` CERTCOUNT=`ls "${root}/conf/archive/$1/cert"* | wc -l`
if [ "$CERTCOUNT" -ne "$1" ] ; then if [ "$CERTCOUNT" -ne "$2" ] ; then
echo Wrong cert count, not "$1" `ls "${root}/conf/archive/le.wtf/"*` echo Wrong cert count, not "$2" `ls "${root}/conf/archive/$1/"*`
exit 1 exit 1
fi fi
} }
CheckCertCount 1 CheckCertCount "le.wtf" 1
# This won't renew (because it's not time yet) # This won't renew (because it's not time yet)
common_no_force_renew renew common_no_force_renew renew
CheckCertCount 1 CheckCertCount "le.wtf" 1
# --renew-by-default is used, so renewal should occur # renew using HTTP manual auth hooks
[ -f "$HOOK_TEST" ] && rm -f "$HOOK_TEST" common renew --cert-name le.wtf --authenticator manual
common renew CheckCertCount "le.wtf" 2
CheckCertCount 2
CheckHooks
# renew using DNS manual auth hooks
common renew --cert-name dns.le.wtf --authenticator manual
CheckCertCount "dns.le.wtf" 2
# This will renew because the expiry is less than 10 years from now # This will renew because the expiry is less than 10 years from now
sed -i "4arenew_before_expiry = 4 years" "$root/conf/renewal/le.wtf.conf" sed -i "4arenew_before_expiry = 4 years" "$root/conf/renewal/le.wtf.conf"
common_no_force_renew renew --rsa-key-size 2048 common_no_force_renew renew --rsa-key-size 2048
CheckCertCount 3 CheckCertCount "le.wtf" 3
# The 4096 bit setting should persist to the first renewal, but be overriden in the second # The 4096 bit setting should persist to the first renewal, but be overriden in the second
@@ -149,6 +150,12 @@ if [ "$size1" -lt 3000 ] || [ "$size2" -lt 3000 ] || [ "$size3" -gt 1800 ] ; the
exit 1 exit 1
fi fi
# --renew-by-default is used, so renewal should occur
[ -f "$HOOK_TEST" ] && rm -f "$HOOK_TEST"
common renew
CheckCertCount "le.wtf" 4
CheckHooks
# ECDSA # ECDSA
openssl ecparam -genkey -name secp384r1 -out "${root}/privkey-p384.pem" openssl ecparam -genkey -name secp384r1 -out "${root}/privkey-p384.pem"
SAN="DNS:ecdsa.le.wtf" openssl req -new -sha256 \ SAN="DNS:ecdsa.le.wtf" openssl req -new -sha256 \