Merge remote-tracking branch 'origin/master' into nginx-compatibility-test

This commit is contained in:
Seth Schoen 2016-08-08 17:27:30 -07:00
commit a966641ef4
20 changed files with 384 additions and 31 deletions

View file

@ -39,4 +39,4 @@ Current Features
.. Do not modify this comment unless you know what you're doing. tag:features-end
For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide <https://certbot.eff.org/docs/contributing.html>`.
For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide <https://certbot.eff.org/docs/contributing.html>`_.

View file

@ -14,7 +14,6 @@ from acme import crypto_util
from acme import fields
from acme import jose
logger = logging.getLogger(__name__)
@ -206,6 +205,74 @@ class KeyAuthorizationChallenge(_TokenChallenge):
self.validation(account_key, *args, **kwargs))
@ChallengeResponse.register
class DNS01Response(KeyAuthorizationChallengeResponse):
"""ACME dns-01 challenge response."""
typ = "dns-01"
def simple_verify(self, chall, domain, account_public_key):
"""Simple verify.
:param challenges.DNS01 chall: Corresponding challenge.
:param unicode domain: Domain name being verified.
:param JWK account_public_key: Public key for the key pair
being authorized.
:returns: ``True`` iff validation with the TXT records resolved from a
DNS server is successful.
:rtype: bool
"""
if not self.verify(chall, account_public_key):
logger.debug("Verification of key authorization in response failed")
return False
validation_domain_name = chall.validation_domain_name(domain)
validation = chall.validation(account_public_key)
logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name)
try:
from acme import dns_resolver
except ImportError: # pragma: no cover
raise errors.Error("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:
logger.debug("Key authorization from response (%r) doesn't match "
"any DNS response in %r", self.key_authorization,
txt_records)
return exists
@Challenge.register # pylint: disable=too-many-ancestors
class DNS01(KeyAuthorizationChallenge):
"""ACME dns-01 challenge."""
response_cls = DNS01Response
typ = response_cls.typ
LABEL = "_acme-challenge"
"""Label clients prepend to the domain name being validated."""
def validation(self, account_key, **unused_kwargs):
"""Generate validation.
:param JWK account_key:
:rtype: unicode
"""
return jose.b64encode(hashlib.sha256(self.key_authorization(
account_key).encode("utf-8")).digest()).decode()
def validation_domain_name(self, name):
"""Domain name for TXT validation record.
:param unicode name: Domain name being validated.
"""
return "{0}.{1}".format(self.LABEL, name)
@ChallengeResponse.register
class HTTP01Response(KeyAuthorizationChallengeResponse):
"""ACME http-01 challenge response."""
@ -231,8 +298,8 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
being authorized.
:param int port: Port used in the validation.
:returns: ``True`` iff validation is successful, ``False``
otherwise.
:returns: ``True`` iff validation with the files currently served by the
HTTP server is successful.
:rtype: bool
"""
@ -410,7 +477,7 @@ class TLSSNI01Response(KeyAuthorizationChallengeResponse):
:returns: ``True`` iff client's control of the domain has been
verified, ``False`` otherwise.
verified.
:rtype: bool
"""

View file

@ -77,6 +77,93 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase):
self.assertFalse(response.verify(self.chall, KEY.public_key()))
class DNS01ResponseTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
def setUp(self):
from acme.challenges import DNS01Response
self.msg = DNS01Response(key_authorization=u'foo')
self.jmsg = {
'resource': 'challenge',
'type': 'dns-01',
'keyAuthorization': u'foo',
}
from acme.challenges import DNS01
self.chall = DNS01(token=(b'x' * 16))
self.response = self.chall.response(KEY)
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNS01Response
self.assertEqual(self.msg, DNS01Response.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DNS01Response
hash(DNS01Response.from_json(self.jmsg))
def test_simple_verify_bad_key_authorization(self):
key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
self.response.simple_verify(self.chall, "local", key2.public_key())
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_good_validation(self, mock_resolver):
mock_resolver.return_value = [self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_good_validation_multiple_txts(self, mock_resolver):
mock_resolver.return_value = [
"!", self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_bad_validation(self, mock_dns):
mock_dns.return_value = ["!"]
self.assertFalse(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
class DNS01Test(unittest.TestCase):
def setUp(self):
from acme.challenges import DNS01
self.msg = DNS01(token=jose.decode_b64jose(
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA'))
self.jmsg = {
'type': 'dns-01',
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
}
def test_validation_domain_name(self):
self.assertEqual('_acme-challenge.www.example.com',
self.msg.validation_domain_name('www.example.com'))
def test_validation(self):
self.assertEqual(
"rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk",
self.msg.validation(KEY))
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNS01
self.assertEqual(self.msg, DNS01.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DNS01
hash(DNS01.from_json(self.jmsg))
class HTTP01ResponseTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes

30
acme/acme/dns_resolver.py Normal file
View file

@ -0,0 +1,30 @@
"""DNS Resolver for ACME client.
Required only for local validation of 'dns-01' challenges.
"""
import logging
import dns.resolver
import dns.exception
logger = logging.getLogger(__name__)
def txt_records_for_name(name):
"""Resolve the name and return the TXT records.
:param unicode name: Domain name being verified.
:returns: A list of txt records, if empty the name could not be resolved
:rtype: list of unicode
"""
try:
dns_response = dns.resolver.query(name, 'TXT')
except dns.resolver.NXDOMAIN as error:
return []
except dns.exception.DNSException as error:
logger.error("Error resolving %s: %s", name, str(error))
return []
return [txt_rec.decode("utf-8") for rdata in dns_response
for txt_rec in rdata.strings]

View file

@ -0,0 +1,53 @@
"""Tests for acme.dns_resolver."""
import unittest
import mock
from acme import dns_resolver
try:
import dns
except ImportError: # pragma: no cover
dns = None
def create_txt_response(name, txt_records):
"""
Returns an RRSet containing the 'txt_records' as the result of a DNS
query for 'name'.
This takes advantage of the fact that an Answer object mostly behaves
like an RRset.
"""
return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records)
class TxtRecordsForNameTest(unittest.TestCase):
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_with_single_response(self, mock_dns):
mock_dns.return_value = create_txt_response('name', ['response'])
self.assertEqual(['response'],
dns_resolver.txt_records_for_name('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_with_multiple_responses(self, mock_dns):
mock_dns.return_value = create_txt_response(
'name', ['response1', 'response2'])
self.assertEqual(['response1', 'response2'],
dns_resolver.txt_records_for_name('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_domain_not_found(self, mock_dns):
mock_dns.side_effect = dns.resolver.NXDOMAIN
self.assertEquals([], dns_resolver.txt_records_for_name('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_domain_other_error(self, mock_dns):
mock_dns.side_effect = dns.exception.DNSException
self.assertEquals([], dns_resolver.txt_records_for_name('name'))
def run(self, result=None):
if dns is None: # pragma: no cover
print(self, "... SKIPPING, no dnspython available")
return
super(TxtRecordsForNameTest, self).run(result)

View file

@ -35,6 +35,11 @@ if sys.version_info < (2, 7):
else:
install_requires.append('mock')
# dnspython 1.12 is required to support both Python 2 and Python 3.
dns_extras = [
'dnspython>=1.12',
]
dev_extras = [
'nose',
'pep8',
@ -76,6 +81,7 @@ setup(
include_package_data=True,
install_requires=install_requires,
extras_require={
'dns': dns_extras,
'dev': dev_extras,
'docs': docs_extras,
},

View file

@ -2,7 +2,23 @@
import pkg_resources
from certbot import util
CLI_DEFAULTS_DEFAULT = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/sites-available",
vhost_files="*",
version_cmd=['apache2ctl', '-v'],
define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apache2ctl', 'graceful'],
conftest_cmd=['apache2ctl', 'configtest'],
enmod=None,
dismod=None,
le_vhost_ext="-le-ssl.conf",
handle_mods=False,
handle_sites=False,
challenge_location="/etc/apache2",
MOD_SSL_CONF_SRC=pkg_resources.resource_filename(
"certbot_apache", "options-ssl-apache.conf")
)
CLI_DEFAULTS_DEBIAN = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/sites-available",
@ -71,7 +87,25 @@ CLI_DEFAULTS_DARWIN = dict(
MOD_SSL_CONF_SRC=pkg_resources.resource_filename(
"certbot_apache", "options-ssl-apache.conf")
)
CLI_DEFAULTS_SUSE = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/vhosts.d",
vhost_files="*.conf",
version_cmd=['apache2ctl', '-v'],
define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apache2ctl', 'graceful'],
conftest_cmd=['apache2ctl', 'configtest'],
enmod="a2enmod",
dismod="a2dismod",
le_vhost_ext="-le-ssl.conf",
handle_mods=False,
handle_sites=False,
challenge_location="/etc/apache2/vhosts.d",
MOD_SSL_CONF_SRC=pkg_resources.resource_filename(
"certbot_apache", "options-ssl-apache.conf")
)
CLI_DEFAULTS = {
"default": CLI_DEFAULTS_DEFAULT,
"debian": CLI_DEFAULTS_DEBIAN,
"ubuntu": CLI_DEFAULTS_DEBIAN,
"centos": CLI_DEFAULTS_CENTOS,
@ -83,6 +117,8 @@ CLI_DEFAULTS = {
"gentoo": CLI_DEFAULTS_GENTOO,
"gentoo base system": CLI_DEFAULTS_GENTOO,
"darwin": CLI_DEFAULTS_DARWIN,
"opensuse": CLI_DEFAULTS_SUSE,
"suse": CLI_DEFAULTS_SUSE,
}
"""CLI defaults."""
@ -115,13 +151,36 @@ HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS,
def os_constant(key):
"""Get a constant value for operating system
"""
Get a constant value for operating system
:param key: name of cli constant
:return: value of constant for active os
"""
os_info = util.get_os_info()
try:
constants = CLI_DEFAULTS[os_info[0].lower()]
except KeyError:
constants = CLI_DEFAULTS["debian"]
constants = os_like_constants()
if not constants:
constants = CLI_DEFAULTS["default"]
return constants[key]
def os_like_constants():
"""
Try to get constants for distribution with
similar layout and configuration, indicated by
/etc/os-release variable "LIKE"
:returns: Constants dictionary
:rtype: `dict`
"""
os_like = util.get_systemd_os_like()
if os_like:
for os_name in os_like:
if os_name in CLI_DEFAULTS.keys():
return CLI_DEFAULTS[os_name]
return {}

View file

@ -25,3 +25,20 @@ class ConstantsTest(unittest.TestCase):
os_info.return_value = ('Nonexistent Linux', '', '')
self.assertEqual(constants.os_constant("vhost_root"),
"/etc/apache2/sites-available")
@mock.patch("certbot.util.get_os_info")
def test_get_default_constants(self, os_info):
os_info.return_value = ('Nonexistent Linux', '', '')
with mock.patch("certbot.util.get_systemd_os_like") as os_like:
# Get defaults
os_like.return_value = False
c_hm = constants.os_constant("handle_mods")
c_sr = constants.os_constant("server_root")
self.assertFalse(c_hm)
self.assertEqual(c_sr, "/etc/apache2")
# Use darwin as like test target
os_like.return_value = ["something", "nonexistent", "darwin"]
d_vr = constants.os_constant("vhost_root")
d_em = constants.os_constant("enmod")
self.assertFalse(d_em)
self.assertEqual(d_vr, "/etc/apache2/other")

View file

@ -1,8 +1,8 @@
"""ACME AuthHandler."""
import itertools
import logging
import time
import six
import zope.component
from acme import challenges
@ -141,7 +141,7 @@ class AuthHandler(object):
"""
active_achalls = []
for achall, resp in itertools.izip(achalls, resps):
for achall, resp in six.moves.zip(achalls, resps):
# This line needs to be outside of the if block below to
# ensure failed challenges are cleaned up correctly
active_achalls.append(achall)
@ -472,7 +472,7 @@ def _report_failed_challs(failed_achalls):
problems.setdefault(achall.error.typ, []).append(achall)
reporter = zope.component.getUtility(interfaces.IReporter)
for achalls in problems.itervalues():
for achalls in six.itervalues(problems):
reporter.add_message(
_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY)

View file

@ -343,8 +343,10 @@ class HelpfulArgumentParser(object):
self.determine_verb()
help1 = self.prescan_for_flag("-h", self.help_topics)
help2 = self.prescan_for_flag("--help", self.help_topics)
assert max(True, "a") == "a", "Gravity changed direction"
self.help_arg = max(help1, help2)
if isinstance(help1, bool) and isinstance(help2, bool):
self.help_arg = help1 or help2
else:
self.help_arg = help1 if isinstance(help1, str) else help2
if self.help_arg is True:
# just --help with no topic; avoid argparse altogether
print(usage)

View file

@ -18,7 +18,7 @@ CLI_DEFAULTS = dict(
os.path.join(os.environ.get("XDG_CONFIG_HOME", "~/.config"),
"letsencrypt", "cli.ini"),
],
verbose_count=-(logging.INFO / 10),
verbose_count=-int(logging.INFO / 10),
server="https://acme-v01.api.letsencrypt.org/directory",
rsa_key_size=2048,
rollback_checkpoints=1,

View file

@ -3,6 +3,7 @@ import collections
import itertools
import logging
import pkg_resources
import six
import zope.interface
import zope.interface.verify
@ -194,12 +195,12 @@ class PluginsRegistry(collections.Mapping):
def init(self, config):
"""Initialize all plugins in the registry."""
return [plugin_ep.init(config) for plugin_ep
in self._plugins.itervalues()]
in six.itervalues(self._plugins)]
def filter(self, pred):
"""Filter plugins based on predicate."""
return type(self)(dict((name, plugin_ep) for name, plugin_ep
in self._plugins.iteritems() if pred(plugin_ep)))
in six.iteritems(self._plugins) if pred(plugin_ep)))
def visible(self):
"""Filter plugins based on visibility."""
@ -216,7 +217,7 @@ class PluginsRegistry(collections.Mapping):
def prepare(self):
"""Prepare all plugins in the registry."""
return [plugin_ep.prepare() for plugin_ep in self._plugins.itervalues()]
return [plugin_ep.prepare() for plugin_ep in six.itervalues(self._plugins)]
def available(self):
"""Filter plugins based on availability."""
@ -238,7 +239,7 @@ class PluginsRegistry(collections.Mapping):
"""
# use list instead of set because PluginEntryPoint is not hashable
candidates = [plugin_ep for plugin_ep in self._plugins.itervalues()
candidates = [plugin_ep for plugin_ep in six.itervalues(self._plugins)
if plugin_ep.initialized and plugin_ep.init() is plugin]
assert len(candidates) <= 1
if candidates:
@ -249,7 +250,7 @@ class PluginsRegistry(collections.Mapping):
def __repr__(self):
return "{0}({1})".format(
self.__class__.__name__, ','.join(
repr(p_ep) for p_ep in self._plugins.itervalues()))
repr(p_ep) for p_ep in six.itervalues(self._plugins)))
def __str__(self):
if not self._plugins:

View file

@ -10,6 +10,7 @@ import sys
import tempfile
import time
import six
import zope.component
import zope.interface
@ -187,7 +188,7 @@ s.serve_forever()" """
#answer = zope.component.getUtility(interfaces.IDisplay).notification(
# message=message, height=25, pause=True)
sys.stdout.write(message)
raw_input("Press ENTER to continue")
six.moves.input("Press ENTER to continue")
def cleanup(self, achalls):
# pylint: disable=missing-docstring,no-self-use,unused-argument

View file

@ -84,7 +84,7 @@ def pick_plugin(config, default, plugins, question, ifaces):
else:
return plugin_ep.init()
elif len(prepared) == 1:
plugin_ep = prepared.values()[0]
plugin_ep = list(prepared.values())[0]
logger.debug("Single candidate plugin: %s", plugin_ep)
if plugin_ep.misconfigured:
return None

View file

@ -1,7 +1,7 @@
NAME="SystemdOS"
VERSION="42.42.42 LTS, Unreal"
ID=systemdos
ID_LIKE=debian
ID_LIKE="something nonexistent debian"
VERSION_ID="42"
HOME_URL="http://www.example.com/"
SUPPORT_URL="http://help.example.com/"

View file

@ -359,6 +359,15 @@ class OsInfoTest(unittest.TestCase):
with mock.patch('os.path.isfile', return_value=False):
self.assertEqual(get_systemd_os_info(), ("", ""))
def test_systemd_os_release_like(self):
from certbot.util import get_systemd_os_like
with mock.patch('os.path.isfile', return_value=True):
id_likes = get_systemd_os_like(test_util.vector_path(
"os-release"))
self.assertEqual(len(id_likes), 3)
self.assertTrue("debian" in id_likes)
@mock.patch("certbot.util.subprocess.Popen")
def test_non_systemd_os_info(self, popen_mock):
from certbot.util import (get_os_info, get_python_os_info,

View file

@ -268,6 +268,19 @@ def get_systemd_os_info(filepath="/etc/os-release"):
return (os_name, os_version)
def get_systemd_os_like(filepath="/etc/os-release"):
"""
Get a list of strings that indicate the distribution likeness to
other distributions.
:param str filepath: File path of os-release file
:returns: List of distribution acronyms
:rtype: `list` of `str`
"""
return _get_systemd_os_release_var("ID_LIKE", filepath).split(" ")
def _get_systemd_os_release_var(varname, filepath="/etc/os-release"):
"""
Get single value from systemd /etc/os-release
@ -409,6 +422,9 @@ def enforce_domain_sanity(domain):
else:
raise errors.ConfigurationError(str(error_fmt).format(domain))
if six.PY3:
domain = domain.decode('ascii')
# Remove trailing dot
domain = domain[:-1] if domain.endswith('.') else domain

View file

@ -6,9 +6,9 @@ Developer Guide
:local:
.. _hacking:
.. _getting_started:
Hacking
Getting Started
=======
Running a local copy of the client

View file

@ -42,7 +42,6 @@ install_requires = [
'parsedatetime>=1.3', # Calendar.parseDT
'PyOpenSSL',
'pyrfc3339',
'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280
'pytz',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
@ -52,6 +51,12 @@ install_requires = [
'zope.interface',
]
# Debian squeeze support, cf. #280
if sys.version_info[0] == 2:
install_requires.append('python2-pythondialog>=3.2.2rc1')
else:
install_requires.append('pythondialog>=3.2.2rc1')
# env markers in extras_require cause problems with older pip: #517
# Keep in sync with conditional_requirements.py.
if sys.version_info < (2, 7):

12
tox.ini
View file

@ -13,7 +13,7 @@ envlist = py{26,33,34,35},cover,lint
# packages installed separately to ensure that downstream deps problems
# are detected, c.f. #1002
commands =
pip install -e acme[dev]
pip install -e acme[dns,dev]
nosetests -v acme
pip install -e .[dev]
nosetests -v certbot
@ -38,23 +38,23 @@ deps =
[testenv:py33]
commands =
pip install -e acme[dev]
pip install -e acme[dns,dev]
nosetests -v acme
[testenv:py34]
commands =
pip install -e acme[dev]
pip install -e acme[dns,dev]
nosetests -v acme
[testenv:py35]
commands =
pip install -e acme[dev]
pip install -e acme[dns,dev]
nosetests -v acme
[testenv:cover]
basepython = python2.7
commands =
pip install -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e letshelp-certbot
pip install -e acme[dns,dev] -e .[dev] -e certbot-apache -e certbot-nginx -e letshelp-certbot
./tox.cover.sh
[testenv:lint]
@ -64,7 +64,7 @@ basepython = python2.7
# duplicate code checking; if one of the commands fails, others will
# continue, but tox return code will reflect previous error
commands =
pip install -q -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e certbot-compatibility-test -e letshelp-certbot
pip install -q -e acme[dns,dev] -e .[dev] -e certbot-apache -e certbot-nginx -e certbot-compatibility-test -e letshelp-certbot
./pep8.travis.sh
pylint --reports=n --rcfile=.pylintrc certbot
pylint --reports=n --rcfile=acme/.pylintrc acme/acme