Merge remote-tracking branch 'origin/master' into enhance_verb

This commit is contained in:
Joona Hoikkala 2018-03-11 21:04:55 +02:00
commit 5a2785a711
No known key found for this signature in database
GPG key ID: 1708DAE66E87A524
112 changed files with 2319 additions and 848 deletions

View file

@ -13,7 +13,11 @@ before_script:
matrix:
include:
- python: "2.7"
env: TOXENV=py27_install BOULDER_INTEGRATION=1
env: TOXENV=py27_install BOULDER_INTEGRATION=v1
sudo: required
services: docker
- python: "2.7"
env: TOXENV=py27_install BOULDER_INTEGRATION=v2
sudo: required
services: docker
- python: "2.7"
@ -25,16 +29,12 @@ matrix:
addons:
- python: "2.7"
env: TOXENV=lint
- python: "2.6"
env: TOXENV=py26
sudo: required
services: docker
- python: "2.7"
env: TOXENV=py27-oldest
env: TOXENV='py27-{acme,apache,certbot,dns,nginx}-oldest'
sudo: required
services: docker
- python: "3.3"
env: TOXENV=py33
- python: "3.4"
env: TOXENV=py34
sudo: required
services: docker
- python: "3.6"

View file

@ -10,13 +10,3 @@ supported version: `draft-ietf-acme-01`_.
https://github.com/ietf-wg-acme/acme/tree/draft-ietf-acme-acme-01
"""
import sys
import warnings
for (major, minor) in [(2, 6), (3, 3)]:
if sys.version_info[:2] == (major, minor):
warnings.warn(
"Python {0}.{1} support will be dropped in the next release of "
"acme. Please upgrade your Python version.".format(major, minor),
DeprecationWarning,
) #pragma: no cover

View file

@ -70,7 +70,7 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes
terms_of_service=terms_of_service)
def _send_recv_regr(self, regr, body):
response = self.net.post(regr.uri, body, acme_version=self.acme_version)
response = self._post(regr.uri, body)
# TODO: Boulder returns httplib.ACCEPTED
#assert response.status_code == httplib.OK
@ -82,6 +82,13 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes
response, uri=regr.uri,
terms_of_service=regr.terms_of_service)
def _post(self, *args, **kwargs):
"""Wrapper around self.net.post that adds the acme_version.
"""
kwargs.setdefault('acme_version', self.acme_version)
return self.net.post(*args, **kwargs)
def update_registration(self, regr, update=None):
"""Update registration.
@ -143,8 +150,7 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes
:raises .UnexpectedUpdate:
"""
response = self.net.post(challb.uri, response,
acme_version=self.acme_version)
response = self._post(challb.uri, response)
try:
authzr_uri = response.links['up']['url']
except KeyError:
@ -205,7 +211,7 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes
response, authzr.body.identifier, authzr.uri)
return updated_authzr, response
def revoke(self, cert, rsn):
def _revoke(self, cert, rsn, url):
"""Revoke certificate.
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
@ -213,15 +219,15 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes
:param int rsn: Reason code for certificate revocation.
:param str url: ACME URL to post to
:raises .ClientError: If revocation is unsuccessful.
"""
response = self.net.post(self.directory[messages.Revocation],
messages.Revocation(
certificate=cert,
reason=rsn),
content_type=None,
acme_version=self.acme_version)
response = self._post(url,
messages.Revocation(
certificate=cert,
reason=rsn))
if response.status_code != http_client.OK:
raise errors.ClientError(
'Successful revocation must return HTTP OK status')
@ -271,8 +277,7 @@ class Client(ClientBase):
"""
new_reg = messages.NewRegistration() if new_reg is None else new_reg
response = self.net.post(self.directory[new_reg], new_reg,
acme_version=1)
response = self._post(self.directory[new_reg], new_reg)
# TODO: handle errors
assert response.status_code == http_client.CREATED
@ -304,12 +309,19 @@ class Client(ClientBase):
:returns: Authorization Resource.
:rtype: `.AuthorizationResource`
:raises errors.WildcardUnsupportedError: if a wildcard is requested
"""
if new_authzr_uri is not None:
logger.debug("request_challenges with new_authzr_uri deprecated.")
if identifier.value.startswith("*"):
raise errors.WildcardUnsupportedError(
"Requesting an authorization for a wildcard name is"
" forbidden by this version of the ACME protocol.")
new_authz = messages.NewAuthorization(identifier=identifier)
response = self.net.post(self.directory.new_authz, new_authz,
acme_version=1)
response = self._post(self.directory.new_authz, new_authz)
# TODO: handle errors
assert response.status_code == http_client.CREATED
return self._authzr_from_response(response, identifier)
@ -328,6 +340,8 @@ class Client(ClientBase):
:returns: Authorization Resource.
:rtype: `.AuthorizationResource`
:raises errors.WildcardUnsupportedError: if a wildcard is requested
"""
return self.request_challenges(messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value=domain), new_authzr_uri)
@ -351,12 +365,11 @@ class Client(ClientBase):
req = messages.CertificateRequest(csr=csr)
content_type = DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
response = self.net.post(
response = self._post(
self.directory.new_cert,
req,
content_type=content_type,
headers={'Accept': content_type},
acme_version=1)
headers={'Accept': content_type})
cert_chain_uri = response.links.get('up', {}).get('url')
@ -526,6 +539,18 @@ class Client(ClientBase):
"Recursion limit reached. Didn't get {0}".format(uri))
return chain
def revoke(self, cert, rsn):
"""Revoke certificate.
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
`.ComparableX509`
:param int rsn: Reason code for certificate revocation.
:raises .ClientError: If revocation is unsuccessful.
"""
return self._revoke(cert, rsn, self.directory[messages.Revocation])
class ClientV2(ClientBase):
@ -552,8 +577,7 @@ class ClientV2(ClientBase):
:returns: Registration Resource.
:rtype: `.RegistrationResource`
"""
response = self.net.post(self.directory['newAccount'], new_account,
acme_version=2)
response = self._post(self.directory['newAccount'], new_account)
# "Instance of 'Field' has no key/contact member" bug:
# pylint: disable=no-member
regr = self._regr_from_response(response)
@ -577,11 +601,11 @@ class ClientV2(ClientBase):
identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN,
value=name))
order = messages.NewOrder(identifiers=identifiers)
response = self.net.post(self.directory['newOrder'], order)
response = self._post(self.directory['newOrder'], order)
body = messages.Order.from_json(response.json())
authorizations = []
for url in body.authorizations:
authorizations.append(self._authzr_from_response(self.net.get(url)))
authorizations.append(self._authzr_from_response(self.net.get(url), uri=url))
return messages.OrderResource(
body=body,
uri=response.headers.get('Location'),
@ -643,7 +667,7 @@ class ClientV2(ClientBase):
csr = OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, orderr.csr_pem)
wrapped_csr = messages.CertificateRequest(csr=jose.ComparableX509(csr))
self.net.post(orderr.body.finalize, wrapped_csr)
self._post(orderr.body.finalize, wrapped_csr)
while datetime.datetime.now() < deadline:
time.sleep(1)
response = self.net.get(orderr.uri)
@ -651,17 +675,42 @@ class ClientV2(ClientBase):
if body.error is not None:
raise errors.IssuanceError(body.error)
if body.certificate is not None:
certificate_response = self.net.get(body.certificate).text
certificate_response = self.net.get(body.certificate,
content_type=DER_CONTENT_TYPE).text
return orderr.update(body=body, fullchain_pem=certificate_response)
raise errors.TimeoutError()
def revoke(self, cert, rsn):
"""Revoke certificate.
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
`.ComparableX509`
:param int rsn: Reason code for certificate revocation.
:raises .ClientError: If revocation is unsuccessful.
"""
return self._revoke(cert, rsn, self.directory['revokeCert'])
class BackwardsCompatibleClientV2(object):
"""ACME client wrapper that tends towards V2-style calls, but
supports V1 servers.
supports V1 servers.
:ivar int acme_version: 1 or 2, corresponding to the Let's Encrypt endpoint
:ivar .ClientBase client: either Client or ClientV2
.. note:: While this class handles the majority of the differences
between versions of the ACME protocol, if you need to support an
ACME server based on version 3 or older of the IETF ACME draft
that uses combinations in authorizations (or lack thereof) to
signal that the client needs to complete something other than
any single challenge in the authorization to make it valid, the
user of this class needs to understand and handle these
differences themselves. This does not apply to either of Let's
Encrypt's endpoints where successfully completing any challenge
in an authorization will make it valid.
:ivar int acme_version: 1 or 2, corresponding to the Let's Encrypt endpoint
:ivar .ClientBase client: either Client or ClientV2
"""
def __init__(self, net, key, server):
@ -677,9 +726,6 @@ class BackwardsCompatibleClientV2(object):
return getattr(self.client, name)
elif name in dir(ClientBase):
return getattr(self.client, name)
# temporary, for breaking changes into smaller pieces
elif name in dir(Client):
return getattr(self.client, name)
else:
raise AttributeError()
@ -705,6 +751,83 @@ class BackwardsCompatibleClientV2(object):
regr = regr.update(terms_of_service_agreed=True)
return self.client.new_account(regr)
def new_order(self, csr_pem):
"""Request a new Order object from the server.
If using ACMEv1, returns a dummy OrderResource with only
the authorizations field filled in.
:param str csr_pem: A CSR in PEM format.
:returns: The newly created order.
:rtype: OrderResource
:raises errors.WildcardUnsupportedError: if a wildcard domain is
requested but unsupported by the ACME version
"""
if self.acme_version == 1:
csr = OpenSSL.crypto.load_certificate_request(OpenSSL.crypto.FILETYPE_PEM, csr_pem)
# pylint: disable=protected-access
dnsNames = crypto_util._pyopenssl_cert_or_req_all_names(csr)
authorizations = []
for domain in dnsNames:
authorizations.append(self.client.request_domain_challenges(domain))
return messages.OrderResource(authorizations=authorizations, csr_pem=csr_pem)
else:
return self.client.new_order(csr_pem)
def finalize_order(self, orderr, deadline):
"""Finalize an order and obtain a certificate.
:param messages.OrderResource orderr: order to finalize
:param datetime.datetime deadline: when to stop polling and timeout
:returns: finalized order
:rtype: messages.OrderResource
"""
if self.acme_version == 1:
csr_pem = orderr.csr_pem
certr = self.client.request_issuance(
jose.ComparableX509(
OpenSSL.crypto.load_certificate_request(OpenSSL.crypto.FILETYPE_PEM, csr_pem)),
orderr.authorizations)
chain = None
while datetime.datetime.now() < deadline:
try:
chain = self.client.fetch_chain(certr)
break
except errors.Error:
time.sleep(1)
if chain is None:
raise errors.TimeoutError(
'Failed to fetch chain. You should not deploy the generated '
'certificate, please rerun the command for a new one.')
cert = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped).decode()
chain = crypto_util.dump_pyopenssl_chain(chain).decode()
return orderr.update(fullchain_pem=(cert + chain))
else:
return self.client.finalize_order(orderr, deadline)
def revoke(self, cert, rsn):
"""Revoke certificate.
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
`.ComparableX509`
:param int rsn: Reason code for certificate revocation.
:raises .ClientError: If revocation is unsuccessful.
"""
return self.client.revoke(cert, rsn)
def _acme_version_from_directory(self, directory):
if hasattr(directory, 'newNonce'):
return 2
@ -772,7 +895,9 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
}
if acme_version == 2:
kwargs["url"] = url
kwargs["kid"] = self.account["uri"]
# newAccount and revokeCert work without the kid
if self.account is not None:
kwargs["kid"] = self.account["uri"]
kwargs["key"] = self.key
# pylint: disable=star-args
return jws.JWS.sign(jobj, **kwargs).json_dumps(indent=2)

View file

@ -8,6 +8,7 @@ from six.moves import http_client # pylint: disable=import-error
import josepy as jose
import mock
import OpenSSL
import requests
from acme import challenges
@ -39,6 +40,7 @@ DIRECTORY_V2 = messages.Directory({
'newAccount': 'https://www.letsencrypt-demo.org/acme/new-account',
'newNonce': 'https://www.letsencrypt-demo.org/acme/new-nonce',
'newOrder': 'https://www.letsencrypt-demo.org/acme/new-order',
'revokeCert': 'https://www.letsencrypt-demo.org/acme/revoke-cert',
})
@ -78,10 +80,36 @@ class ClientTestBase(unittest.TestCase):
self.authzr = messages.AuthorizationResource(
body=self.authz, uri=authzr_uri)
# Reason code for revocation
self.rsn = 1
class BackwardsCompatibleClientV2Test(ClientTestBase):
"""Tests for acme.client.BackwardsCompatibleClientV2."""
def setUp(self):
super(BackwardsCompatibleClientV2Test, self).setUp()
# contains a loaded cert
self.certr = messages.CertificateResource(
body=messages_test.CERT)
loaded = OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, CERT_SAN_PEM)
wrapped = jose.ComparableX509(loaded)
self.chain = [wrapped, wrapped]
self.cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, messages_test.CERT.wrapped).decode()
single_chain = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, loaded).decode()
self.chain_pem = single_chain + single_chain
self.fullchain_pem = self.cert_pem + self.chain_pem
self.orderr = messages.OrderResource(
csr_pem=CSR_SAN_PEM)
def _init(self):
uri = 'http://www.letsencrypt-demo.org/directory'
from acme.client import BackwardsCompatibleClientV2
@ -109,8 +137,6 @@ class BackwardsCompatibleClientV2Test(ClientTestBase):
client = self._init()
self.assertEqual(client.directory, client.client.directory)
self.assertEqual(client.key, KEY)
# delete this line once we finish migrating to new API:
self.assertEqual(client.register, client.client.register)
self.assertEqual(client.update_registration, client.client.update_registration)
self.assertRaises(AttributeError, client.__getattr__, 'nonexistent')
self.assertRaises(AttributeError, client.__getattr__, 'new_account_and_tos')
@ -161,6 +187,87 @@ class BackwardsCompatibleClientV2Test(ClientTestBase):
mock_client().register.assert_called_once_with(self.new_reg)
mock_client().agree_to_tos.assert_not_called()
@mock.patch('OpenSSL.crypto.load_certificate_request')
@mock.patch('acme.crypto_util._pyopenssl_cert_or_req_all_names')
def test_new_order_v1(self, mock__pyopenssl_cert_or_req_all_names,
unused_mock_load_certificate_request):
self.response.json.return_value = DIRECTORY_V1.to_json()
mock__pyopenssl_cert_or_req_all_names.return_value = ['example.com', 'www.example.com']
mock_csr_pem = mock.MagicMock()
with mock.patch('acme.client.Client') as mock_client:
mock_client().request_domain_challenges.return_value = mock.sentinel.auth
client = self._init()
orderr = client.new_order(mock_csr_pem)
self.assertEqual(orderr.authorizations, [mock.sentinel.auth, mock.sentinel.auth])
def test_new_order_v2(self):
self.response.json.return_value = DIRECTORY_V2.to_json()
mock_csr_pem = mock.MagicMock()
with mock.patch('acme.client.ClientV2') as mock_client:
client = self._init()
client.new_order(mock_csr_pem)
mock_client().new_order.assert_called_once_with(mock_csr_pem)
@mock.patch('acme.client.Client')
def test_finalize_order_v1_success(self, mock_client):
self.response.json.return_value = DIRECTORY_V1.to_json()
mock_client().request_issuance.return_value = self.certr
mock_client().fetch_chain.return_value = self.chain
deadline = datetime.datetime(9999, 9, 9)
client = self._init()
result = client.finalize_order(self.orderr, deadline)
self.assertEqual(result.fullchain_pem, self.fullchain_pem)
mock_client().fetch_chain.assert_called_once_with(self.certr)
@mock.patch('acme.client.Client')
def test_finalize_order_v1_fetch_chain_error(self, mock_client):
self.response.json.return_value = DIRECTORY_V1.to_json()
mock_client().request_issuance.return_value = self.certr
mock_client().fetch_chain.return_value = self.chain
mock_client().fetch_chain.side_effect = [errors.Error, self.chain]
deadline = datetime.datetime(9999, 9, 9)
client = self._init()
result = client.finalize_order(self.orderr, deadline)
self.assertEqual(result.fullchain_pem, self.fullchain_pem)
self.assertEqual(mock_client().fetch_chain.call_count, 2)
@mock.patch('acme.client.Client')
def test_finalize_order_v1_timeout(self, mock_client):
self.response.json.return_value = DIRECTORY_V1.to_json()
mock_client().request_issuance.return_value = self.certr
deadline = deadline = datetime.datetime.now() - datetime.timedelta(seconds=60)
client = self._init()
self.assertRaises(errors.TimeoutError, client.finalize_order,
self.orderr, deadline)
def test_finalize_order_v2(self):
self.response.json.return_value = DIRECTORY_V2.to_json()
mock_orderr = mock.MagicMock()
mock_deadline = mock.MagicMock()
with mock.patch('acme.client.ClientV2') as mock_client:
client = self._init()
client.finalize_order(mock_orderr, mock_deadline)
mock_client().finalize_order.assert_called_once_with(mock_orderr, mock_deadline)
def test_revoke(self):
self.response.json.return_value = DIRECTORY_V1.to_json()
with mock.patch('acme.client.Client') as mock_client:
client = self._init()
client.revoke(messages_test.CERT, self.rsn)
mock_client().revoke.assert_called_once_with(messages_test.CERT, self.rsn)
self.response.json.return_value = DIRECTORY_V2.to_json()
with mock.patch('acme.client.ClientV2') as mock_client:
client = self._init()
client.revoke(messages_test.CERT, self.rsn)
mock_client().revoke.assert_called_once_with(messages_test.CERT, self.rsn)
class ClientTest(ClientTestBase):
"""Tests for acme.client.Client."""
@ -181,9 +288,6 @@ class ClientTest(ClientTestBase):
uri='https://www.letsencrypt-demo.org/acme/cert/1',
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
# Reason code for revocation
self.rsn = 1
from acme.client import Client
self.client = Client(
directory=self.directory, key=KEY, alg=jose.RS256, net=self.net)
@ -272,6 +376,13 @@ class ClientTest(ClientTestBase):
errors.UnexpectedUpdate, self.client.request_challenges,
self.identifier)
def test_request_challenges_wildcard(self):
wildcard_identifier = messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value='*.example.org')
self.assertRaises(
errors.WildcardUnsupportedError, self.client.request_challenges,
wildcard_identifier)
def test_request_domain_challenges(self):
self.client.request_challenges = mock.MagicMock()
self.assertEqual(
@ -524,8 +635,7 @@ class ClientTest(ClientTestBase):
def test_revoke(self):
self.client.revoke(self.certr.body, self.rsn)
self.net.post.assert_called_once_with(
self.directory[messages.Revocation], mock.ANY, content_type=None,
acme_version=1)
self.directory[messages.Revocation], mock.ANY, acme_version=1)
def test_revocation_payload(self):
obj = messages.Revocation(certificate=self.certr.body, reason=self.rsn)
@ -662,6 +772,11 @@ class ClientV2Test(ClientTestBase):
deadline = datetime.datetime.now() - datetime.timedelta(seconds=60)
self.assertRaises(errors.TimeoutError, self.client.finalize_order, self.orderr, deadline)
def test_revoke(self):
self.client.revoke(messages_test.CERT, self.rsn)
self.net.post.assert_called_once_with(
self.directory["revokeCert"], mock.ANY, acme_version=2)
class MockJSONDeSerializable(jose.JSONDeSerializable):
# pylint: disable=missing-docstring

View file

@ -5,9 +5,10 @@ import logging
import os
import re
import socket
import sys
import OpenSSL
import josepy as jose
from acme import errors
@ -130,8 +131,7 @@ def probe_sni(name, host, port=443, timeout=300,
context = OpenSSL.SSL.Context(method)
context.set_timeout(timeout)
socket_kwargs = {} if sys.version_info < (2, 7) else {
'source_address': source_address}
socket_kwargs = {'source_address': source_address}
host_protocol_agnostic = None if host == '::' or host == '0' else host
@ -280,3 +280,26 @@ def gen_ss_cert(key, domains, not_before=None,
cert.set_pubkey(key)
cert.sign(key, "sha256")
return cert
def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM):
"""Dump certificate chain into a bundle.
:param list chain: List of `OpenSSL.crypto.X509` (or wrapped in
:class:`josepy.util.ComparableX509`).
:returns: certificate chain bundle
:rtype: bytes
"""
# XXX: returns empty string when no chain is available, which
# shuts up RenewableCert, but might not be the best solution...
def _dump_cert(cert):
if isinstance(cert, jose.ComparableX509):
# pylint: disable=protected-access
cert = cert.wrapped
return OpenSSL.crypto.dump_certificate(filetype, cert)
# assumes that OpenSSL.crypto.dump_certificate includes ending
# newline character
return b"".join(_dump_cert(cert) for cert in chain)

View file

@ -194,9 +194,9 @@ class MakeCSRTest(unittest.TestCase):
self.assertTrue(b'--END CERTIFICATE REQUEST--' in csr_pem)
csr = OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
# In pyopenssl 0.13 (used with TOXENV=py26-oldest and py27-oldest), csr
# objects don't have a get_extensions() method, so we skip this test if
# the method isn't available.
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
# have a get_extensions() method, so we skip this test if the method
# isn't available.
if hasattr(csr, 'get_extensions'):
self.assertEquals(len(csr.get_extensions()), 1)
self.assertEquals(csr.get_extensions()[0].get_data(),
@ -212,9 +212,9 @@ class MakeCSRTest(unittest.TestCase):
csr = OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
# In pyopenssl 0.13 (used with TOXENV=py26-oldest and py27-oldest), csr
# objects don't have a get_extensions() method, so we skip this test if
# the method isn't available.
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
# have a get_extensions() method, so we skip this test if the method
# isn't available.
if hasattr(csr, 'get_extensions'):
self.assertEquals(len(csr.get_extensions()), 2)
# NOTE: Ideally we would filter by the TLS Feature OID, but
@ -225,5 +225,33 @@ class MakeCSRTest(unittest.TestCase):
self.assertEqual(len(must_staple_exts), 1,
"Expected exactly one Must Staple extension")
class DumpPyopensslChainTest(unittest.TestCase):
"""Test for dump_pyopenssl_chain."""
@classmethod
def _call(cls, loaded):
# pylint: disable=protected-access
from acme.crypto_util import dump_pyopenssl_chain
return dump_pyopenssl_chain(loaded)
def test_dump_pyopenssl_chain(self):
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
loaded = [test_util.load_cert(name) for name in names]
length = sum(
len(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))
for cert in loaded)
self.assertEqual(len(self._call(loaded)), length)
def test_dump_pyopenssl_chain_wrapped(self):
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
loaded = [test_util.load_cert(name) for name in names]
wrap_func = jose.ComparableX509
wrapped = [wrap_func(cert) for cert in loaded]
dump_func = OpenSSL.crypto.dump_certificate
length = sum(len(dump_func(OpenSSL.crypto.FILETYPE_PEM, cert)) for cert in loaded)
self.assertEqual(len(self._call(wrapped)), length)
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -115,3 +115,6 @@ class ConflictError(ClientError):
self.location = location
super(ConflictError, self).__init__()
class WildcardUnsupportedError(Error):
"""Error for when a wildcard is requested but is unsupported by ACME CA."""

View file

@ -19,19 +19,10 @@ install_requires = [
'pyrfc3339',
'pytz',
'requests[security]>=2.4.1', # security extras added in 2.4.1
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'six>=1.9.0', # needed for python_2_unicode_compatible
]
# env markers cause problems with older pip and setuptools
if sys.version_info < (2, 7):
install_requires.extend([
'argparse',
'ordereddict',
])
dev_extras = [
'pytest',
'pytest-xdist',
@ -52,16 +43,15 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'License :: OSI Approved :: Apache Software License',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -5,6 +5,7 @@ import logging
import os
import pkg_resources
import re
import six
import socket
import time
@ -152,6 +153,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.assoc = dict()
# Outstanding challenges
self._chall_out = set()
# List of vhosts configured per wildcard domain on this run.
# used by deploy_cert() and enhance()
self._wildcard_vhosts = dict()
# Maps enhancements to vhosts we've enabled the enhancement for
self._enhanced_vhosts = defaultdict(set)
@ -262,6 +266,21 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.aug, self.conf("server-root"), self.conf("vhost-root"),
self.version, configurator=self)
def _wildcard_domain(self, domain):
"""
Checks if domain is a wildcard domain
:param str domain: Domain to check
:returns: If the domain is wildcard domain
:rtype: bool
"""
if isinstance(domain, six.text_type):
wildcard_marker = u"*."
else:
wildcard_marker = b"*."
return domain.startswith(wildcard_marker)
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None):
"""Deploys certificate to specified virtual host.
@ -280,9 +299,112 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
a lack of directives
"""
# Choose vhost before (possible) enabling of mod_ssl, to keep the
# vhost choice namespace similar with the pre-validation one.
vhost = self.choose_vhost(domain)
vhosts = self.choose_vhosts(domain)
for vhost in vhosts:
self._deploy_cert(vhost, cert_path, key_path, chain_path, fullchain_path)
def choose_vhosts(self, domain, create_if_no_ssl=True):
"""
Finds VirtualHosts that can be used with the provided domain
:param str domain: Domain name to match VirtualHosts to
:param bool create_if_no_ssl: If found VirtualHost doesn't have a HTTPS
counterpart, should one get created
:returns: List of VirtualHosts or None
:rtype: `list` of :class:`~certbot_apache.obj.VirtualHost`
"""
if self._wildcard_domain(domain):
if domain in self._wildcard_vhosts:
# Vhosts for a wildcard domain were already selected
return self._wildcard_vhosts[domain]
# Ask user which VHosts to support.
# Returned objects are guaranteed to be ssl vhosts
return self._choose_vhosts_wildcard(domain, create_if_no_ssl)
else:
return [self.choose_vhost(domain)]
def _vhosts_for_wildcard(self, domain):
"""
Get VHost objects for every VirtualHost that the user wants to handle
with the wildcard certificate.
"""
# Collect all vhosts that match the name
matched = set()
for vhost in self.vhosts:
for name in vhost.get_names():
if self._in_wildcard_scope(name, domain):
matched.add(vhost)
return list(matched)
def _in_wildcard_scope(self, name, domain):
"""
Helper method for _vhosts_for_wildcard() that makes sure that the domain
is in the scope of wildcard domain.
eg. in scope: domain = *.wild.card, name = 1.wild.card
not in scope: domain = *.wild.card, name = 1.2.wild.card
"""
if len(name.split(".")) == len(domain.split(".")):
return fnmatch.fnmatch(name, domain)
def _choose_vhosts_wildcard(self, domain, create_ssl=True):
"""Prompts user to choose vhosts to install a wildcard certificate for"""
# Get all vhosts that are covered by the wildcard domain
vhosts = self._vhosts_for_wildcard(domain)
# Go through the vhosts, making sure that we cover all the names
# present, but preferring the SSL vhosts
filtered_vhosts = dict()
for vhost in vhosts:
for name in vhost.get_names():
if vhost.ssl:
# Always prefer SSL vhosts
filtered_vhosts[name] = vhost
elif name not in filtered_vhosts and create_ssl:
# Add if not in list previously
filtered_vhosts[name] = vhost
# Only unique VHost objects
dialog_input = set([vhost for vhost in filtered_vhosts.values()])
# Ask the user which of names to enable, expect list of names back
dialog_output = display_ops.select_vhost_multiple(list(dialog_input))
if not dialog_output:
logger.error(
"No vhost exists with servername or alias for domain %s. "
"No vhost was selected. Please specify ServerName or ServerAlias "
"in the Apache config.",
domain)
raise errors.PluginError("No vhost selected")
# Make sure we create SSL vhosts for the ones that are HTTP only
# if requested.
return_vhosts = list()
for vhost in dialog_output:
if not vhost.ssl:
return_vhosts.append(self.make_vhost_ssl(vhost))
else:
return_vhosts.append(vhost)
self._wildcard_vhosts[domain] = return_vhosts
return return_vhosts
def _deploy_cert(self, vhost, cert_path, key_path, chain_path, fullchain_path):
"""
Helper function for deploy_cert() that handles the actual deployment
this exists because we might want to do multiple deployments per
domain originally passed for deploy_cert(). This is especially true
with wildcard certificates
"""
# This is done first so that ssl module is enabled and cert_path,
# cert_key... can all be parsed appropriately
@ -311,7 +433,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
raise errors.PluginError(
"Unable to find cert and/or key directives")
logger.info("Deploying Certificate for %s to VirtualHost %s", domain, vhost.filep)
logger.info("Deploying Certificate to VirtualHost %s", vhost.filep)
if self.version < (2, 4, 8) or (chain_path and not fullchain_path):
# install SSLCertificateFile, SSLCertificateKeyFile,
@ -327,8 +449,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"version of Apache")
else:
if not fullchain_path:
raise errors.PluginError("Please provide the --fullchain-path\
option pointing to your full chain file")
raise errors.PluginError("Please provide the --fullchain-path "
"option pointing to your full chain file")
set_cert_path = fullchain_path
self.aug.set(path["cert_path"][-1], fullchain_path)
self.aug.set(path["cert_key"][-1], key_path)
@ -391,7 +513,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
logger.error(
"No vhost exists with servername or alias of %s. "
"No vhost was selected. Please specify ServerName or ServerAlias "
"in the Apache config, or split vhosts into separate files.",
"in the Apache config.",
target_name)
raise errors.PluginError("No vhost selected")
elif temp:
@ -1376,20 +1498,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
except KeyError:
raise errors.PluginError(
"Unsupported enhancement: {0}".format(enhancement))
vhosts = self.choose_vhosts(domain, create_if_no_ssl=False)
try:
# Temp value tells choose_vhost to not to create a new SSL vhost if
# one is not found.
vhost = self.choose_vhost(domain, temp=True)
if not vhost.ssl:
msg_tmpl = ("Certbot was not able to find SSL VirtualHost for a "
"domain {0} for enabling enhancement \"{1}\". The requested "
"enhancement was not configured.")
msg_enhancement = enhancement
if options:
msg_enhancement += ": " + options
msg = msg_tmpl.format(domain, msg_enhancement)
logger.warning(msg)
else:
for vhost in vhosts:
func(vhost, options)
except errors.PluginError:
logger.warning("Failed %s for %s", enhancement, domain)

View file

@ -13,10 +13,44 @@ import certbot.display.util as display_util
logger = logging.getLogger(__name__)
def select_vhost_multiple(vhosts):
"""Select multiple Vhosts to install the certificate for
:param vhosts: Available Apache VirtualHosts
:type vhosts: :class:`list` of type `~obj.Vhost`
:returns: List of VirtualHosts
:rtype: :class:`list`of type `~obj.Vhost`
"""
if not vhosts:
return list()
tags_list = [vhost.display_repr()+"\n" for vhost in vhosts]
# Remove the extra newline from the last entry
if len(tags_list):
tags_list[-1] = tags_list[-1][:-1]
code, names = zope.component.getUtility(interfaces.IDisplay).checklist(
"Which VirtualHosts would you like to install the wildcard certificate for?",
tags=tags_list, force_interactive=True)
if code == display_util.OK:
return_vhosts = _reversemap_vhosts(names, vhosts)
return return_vhosts
return []
def _reversemap_vhosts(names, vhosts):
"""Helper function for select_vhost_multiple for mapping string
representations back to actual vhost objects"""
return_vhosts = list()
for selection in names:
for vhost in vhosts:
if vhost.display_repr().strip() == selection.strip():
return_vhosts.append(vhost)
return return_vhosts
def select_vhost(domain, vhosts):
"""Select an appropriate Apache Vhost.
:param vhosts: Available Apache Virtual Hosts
:param vhosts: Available Apache VirtualHosts
:type vhosts: :class:`list` of type `~obj.Vhost`
:returns: VirtualHost or `None`
@ -25,13 +59,11 @@ def select_vhost(domain, vhosts):
"""
if not vhosts:
return None
while True:
code, tag = _vhost_menu(domain, vhosts)
if code == display_util.OK:
return vhosts[tag]
else:
return None
code, tag = _vhost_menu(domain, vhosts)
if code == display_util.OK:
return vhosts[tag]
else:
return None
def _vhost_menu(domain, vhosts):
"""Select an appropriate Apache Vhost.

View file

@ -17,6 +17,7 @@ OVERRIDE_CLASSES = {
"centos": override_centos.CentOSConfigurator,
"centos linux": override_centos.CentOSConfigurator,
"fedora": override_centos.CentOSConfigurator,
"ol": override_centos.CentOSConfigurator,
"red hat enterprise linux server": override_centos.CentOSConfigurator,
"rhel": override_centos.CentOSConfigurator,
"amazon": override_centos.CentOSConfigurator,

View file

@ -167,6 +167,19 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
active="Yes" if self.enabled else "No",
modmacro="Yes" if self.modmacro else "No"))
def display_repr(self):
"""Return a representation of VHost to be used in dialog"""
return (
"File: {filename}\n"
"Addresses: {addrs}\n"
"Names: {names}\n"
"HTTPS: {https}\n".format(
filename=self.filep,
addrs=", ".join(str(addr) for addr in self.addrs),
names=", ".join(self.get_names()),
https="Yes" if self.ssl else "No"))
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.filep == other.filep and self.path == other.path and

View file

@ -1363,6 +1363,106 @@ class MultipleVhostsTest(util.ApacheTest):
self.config.enable_mod,
"whatever")
def test_wildcard_domain(self):
# pylint: disable=protected-access
cases = {u"*.example.org": True, b"*.x.example.org": True,
u"a.example.org": False, b"a.x.example.org": False}
for key in cases.keys():
self.assertEqual(self.config._wildcard_domain(key), cases[key])
def test_choose_vhosts_wildcard(self):
# pylint: disable=protected-access
mock_path = "certbot_apache.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
mock_select_vhs.return_value = [self.vh_truth[3]]
vhs = self.config._choose_vhosts_wildcard("*.certbot.demo",
create_ssl=True)
# Check that the dialog was called with one vh: certbot.demo
self.assertEquals(mock_select_vhs.call_args[0][0][0], self.vh_truth[3])
self.assertEquals(len(mock_select_vhs.call_args_list), 1)
# And the actual returned values
self.assertEquals(len(vhs), 1)
self.assertTrue(vhs[0].name == "certbot.demo")
self.assertTrue(vhs[0].ssl)
self.assertFalse(vhs[0] == self.vh_truth[3])
@mock.patch("certbot_apache.configurator.ApacheConfigurator.make_vhost_ssl")
def test_choose_vhosts_wildcard_no_ssl(self, mock_makessl):
# pylint: disable=protected-access
mock_path = "certbot_apache.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
mock_select_vhs.return_value = [self.vh_truth[1]]
vhs = self.config._choose_vhosts_wildcard("*.certbot.demo",
create_ssl=False)
self.assertFalse(mock_makessl.called)
self.assertEquals(vhs[0], self.vh_truth[1])
@mock.patch("certbot_apache.configurator.ApacheConfigurator._vhosts_for_wildcard")
@mock.patch("certbot_apache.configurator.ApacheConfigurator.make_vhost_ssl")
def test_choose_vhosts_wildcard_already_ssl(self, mock_makessl, mock_vh_for_w):
# pylint: disable=protected-access
# Already SSL vhost
mock_vh_for_w.return_value = [self.vh_truth[7]]
mock_path = "certbot_apache.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
mock_select_vhs.return_value = [self.vh_truth[7]]
vhs = self.config._choose_vhosts_wildcard("whatever",
create_ssl=True)
self.assertEquals(mock_select_vhs.call_args[0][0][0], self.vh_truth[7])
self.assertEquals(len(mock_select_vhs.call_args_list), 1)
# Ensure that make_vhost_ssl was not called, vhost.ssl == true
self.assertFalse(mock_makessl.called)
# And the actual returned values
self.assertEquals(len(vhs), 1)
self.assertTrue(vhs[0].ssl)
self.assertEquals(vhs[0], self.vh_truth[7])
def test_deploy_cert_wildcard(self):
# pylint: disable=protected-access
mock_choose_vhosts = mock.MagicMock()
mock_choose_vhosts.return_value = [self.vh_truth[7]]
self.config._choose_vhosts_wildcard = mock_choose_vhosts
mock_d = "certbot_apache.configurator.ApacheConfigurator._deploy_cert"
with mock.patch(mock_d) as mock_dep:
self.config.deploy_cert("*.wildcard.example.org", "/tmp/path",
"/tmp/path", "/tmp/path", "/tmp/path")
self.assertTrue(mock_dep.called)
self.assertEquals(len(mock_dep.call_args_list), 1)
self.assertEqual(self.vh_truth[7], mock_dep.call_args_list[0][0][0])
@mock.patch("certbot_apache.display_ops.select_vhost_multiple")
def test_deploy_cert_wildcard_no_vhosts(self, mock_dialog):
# pylint: disable=protected-access
mock_dialog.return_value = []
self.assertRaises(errors.PluginError,
self.config.deploy_cert,
"*.wild.cat", "/tmp/path", "/tmp/path",
"/tmp/path", "/tmp/path")
@mock.patch("certbot_apache.configurator.ApacheConfigurator._choose_vhosts_wildcard")
def test_enhance_wildcard_after_install(self, mock_choose):
# pylint: disable=protected-access
self.config.parser.modules.add("mod_ssl.c")
self.config.parser.modules.add("headers_module")
self.config._wildcard_vhosts["*.certbot.demo"] = [self.vh_truth[3]]
self.config.enhance("*.certbot.demo", "ensure-http-header",
"Upgrade-Insecure-Requests")
self.assertFalse(mock_choose.called)
@mock.patch("certbot_apache.configurator.ApacheConfigurator._choose_vhosts_wildcard")
def test_enhance_wildcard_no_install(self, mock_choose):
mock_choose.return_value = [self.vh_truth[3]]
self.config.parser.modules.add("mod_ssl.c")
self.config.parser.modules.add("headers_module")
self.config.enhance("*.certbot.demo", "ensure-http-header",
"Upgrade-Insecure-Requests")
self.assertTrue(mock_choose.called)
class AugeasVhostsTest(util.ApacheTest):
"""Test vhosts with illegal names dependent on augeas version."""
# pylint: disable=protected-access

View file

@ -11,9 +11,39 @@ from certbot.tests import util as certbot_util
from certbot_apache import obj
from certbot_apache.display_ops import select_vhost_multiple
from certbot_apache.tests import util
class SelectVhostMultiTest(unittest.TestCase):
"""Tests for certbot_apache.display_ops.select_vhost_multiple."""
def setUp(self):
self.base_dir = "/example_path"
self.vhosts = util.get_vh_truth(
self.base_dir, "debian_apache_2_4/multiple_vhosts")
def test_select_no_input(self):
self.assertFalse(select_vhost_multiple([]))
@certbot_util.patch_get_utility()
def test_select_correct(self, mock_util):
mock_util().checklist.return_value = (
display_util.OK, [self.vhosts[3].display_repr(),
self.vhosts[2].display_repr()])
vhs = select_vhost_multiple([self.vhosts[3],
self.vhosts[2],
self.vhosts[1]])
self.assertTrue(self.vhosts[2] in vhs)
self.assertTrue(self.vhosts[3] in vhs)
self.assertFalse(self.vhosts[1] in vhs)
@certbot_util.patch_get_utility()
def test_select_cancel(self, mock_util):
mock_util().checklist.return_value = (display_util.CANCEL, "whatever")
vhs = select_vhost_multiple([self.vhosts[2], self.vhosts[3]])
self.assertFalse(vhs)
class SelectVhostTest(unittest.TestCase):
"""Tests for certbot_apache.display_ops.select_vhost."""

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'mock',
'python-augeas',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.component',
'zope.interface',
]
@ -32,6 +31,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -40,10 +40,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -34,16 +34,15 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'License :: OSI Approved :: Apache Software License',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-cloudflare
RUN pip install --no-cache-dir --editable src/certbot-dns-cloudflare

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-cloudflare[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'cloudflare>=1.5.1',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -39,10 +39,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-cloudxns
RUN pip install --no-cache-dir --editable src/certbot-dns-cloudxns

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-cloudxns[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dns-lexicon',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -41,7 +41,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-digitalocean
RUN pip install --no-cache-dir --editable src/certbot-dns-digitalocean

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-digitalocean[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'mock',
'python-digitalocean>=1.11',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'six',
'zope.interface',
]
@ -32,6 +31,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -40,10 +40,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-dnsimple
RUN pip install --no-cache-dir --editable src/certbot-dns-dnsimple

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-dnsimple[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dns-lexicon',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -41,7 +41,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-dnsmadeeasy
RUN pip install --no-cache-dir --editable src/certbot-dns-dnsmadeeasy

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-dnsmadeeasy[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dns-lexicon',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -41,7 +41,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-google
RUN pip install --no-cache-dir --editable src/certbot-dns-google

View file

@ -29,6 +29,8 @@ for an account with the following permissions:
* ``dns.managedZones.list``
* ``dns.resourceRecordSets.create``
* ``dns.resourceRecordSets.delete``
* ``dns.resourceRecordSets.list``
* ``dns.resourceRecordSets.update``
Google provides instructions for `creating a service account <https://developers
.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`_ and

View file

@ -107,6 +107,17 @@ class _GoogleClient(object):
zone_id = self._find_managed_zone_id(domain)
record_contents = self.get_existing_txt_rrset(zone_id, record_name)
if record_contents is None:
record_contents = []
add_records = record_contents[:]
if "\""+record_content+"\"" in record_contents:
# The process was interrupted previously and validation token exists
return
add_records.append(record_content)
data = {
"kind": "dns#change",
"additions": [
@ -114,12 +125,24 @@ class _GoogleClient(object):
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": record_name + ".",
"rrdatas": [record_content, ],
"rrdatas": add_records,
"ttl": record_ttl,
},
],
}
if record_contents:
# We need to remove old records in the same request
data["deletions"] = [
{
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": record_name + ".",
"rrdatas": record_contents,
"ttl": record_ttl,
},
]
changes = self.dns.changes() # changes | pylint: disable=no-member
try:
@ -154,6 +177,10 @@ class _GoogleClient(object):
logger.warn('Error finding zone. Skipping cleanup.')
return
record_contents = self.get_existing_txt_rrset(zone_id, record_name)
if record_contents is None:
record_contents = ["\"" + record_content + "\""]
data = {
"kind": "dns#change",
"deletions": [
@ -161,12 +188,26 @@ class _GoogleClient(object):
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": record_name + ".",
"rrdatas": [record_content, ],
"rrdatas": record_contents,
"ttl": record_ttl,
},
],
}
# Remove the record being deleted from the list
readd_contents = [r for r in record_contents if r != "\"" + record_content + "\""]
if readd_contents:
# We need to remove old records in the same request
data["additions"] = [
{
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": record_name + ".",
"rrdatas": readd_contents,
"ttl": record_ttl,
},
]
changes = self.dns.changes() # changes | pylint: disable=no-member
try:
@ -175,6 +216,37 @@ class _GoogleClient(object):
except googleapiclient_errors.Error as e:
logger.warn('Encountered error deleting TXT record: %s', e)
def get_existing_txt_rrset(self, zone_id, record_name):
"""
Get existing TXT records from the RRset for the record name.
If an error occurs while requesting the record set, it is suppressed
and None is returned.
:param str zone_id: The ID of the managed zone.
:param str record_name: The record name (typically beginning with '_acme-challenge.').
:returns: List of TXT record values or None
:rtype: `list` of `string` or `None`
"""
rrs_request = self.dns.resourceRecordSets() # pylint: disable=no-member
request = rrs_request.list(managedZone=zone_id, project=self.project_id)
# Add dot as the API returns absolute domains
record_name += "."
try:
response = request.execute()
except googleapiclient_errors.Error:
logger.info("Unable to list existing records. If you're "
"requesting a wildcard certificate, this might not work.")
logger.debug("Error was:", exc_info=True)
else:
if response:
for rr in response["rrsets"]:
if rr["name"] == record_name and rr["type"] == "TXT":
return rr["rrdatas"]
return None
def _find_managed_zone_id(self, domain):
"""
Find the managed zone for a given domain.
@ -224,4 +296,7 @@ class _GoogleClient(object):
if r.status != 200:
raise ValueError("Invalid status code: {0}".format(r))
return content
if isinstance(content, bytes):
return content.decode()
else:
return content

View file

@ -74,10 +74,15 @@ class GoogleClientTest(unittest.TestCase):
mock_mz = mock.MagicMock()
mock_mz.list.return_value.execute.side_effect = zone_request_side_effect
mock_rrs = mock.MagicMock()
rrsets = {"rrsets": [{"name": "_acme-challenge.example.org.", "type": "TXT",
"rrdatas": ["\"example-txt-contents\""]}]}
mock_rrs.list.return_value.execute.return_value = rrsets
mock_changes = mock.MagicMock()
client.dns.managedZones = mock.MagicMock(return_value=mock_mz)
client.dns.changes = mock.MagicMock(return_value=mock_changes)
client.dns.resourceRecordSets = mock.MagicMock(return_value=mock_rrs)
return client, mock_changes
@ -137,6 +142,30 @@ class GoogleClientTest(unittest.TestCase):
managedZone=self.zone,
project=PROJECT_ID)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_delete_old(self, unused_credential_mock):
client, changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
mock_get_rrs = "certbot_dns_google.dns_google._GoogleClient.get_existing_txt_rrset"
with mock.patch(mock_get_rrs) as mock_rrs:
mock_rrs.return_value = ["sample-txt-contents"]
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
self.assertTrue(changes.create.called)
self.assertTrue("sample-txt-contents" in
changes.create.call_args_list[0][1]["body"]["deletions"][0]["rrdatas"])
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_noop(self, unused_credential_mock):
client, changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
client.add_txt_record(DOMAIN, "_acme-challenge.example.org",
"example-txt-contents", self.record_ttl)
self.assertFalse(changes.create.called)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
@ -172,7 +201,12 @@ class GoogleClientTest(unittest.TestCase):
def test_del_txt_record(self, unused_credential_mock):
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
mock_get_rrs = "certbot_dns_google.dns_google._GoogleClient.get_existing_txt_rrset"
with mock.patch(mock_get_rrs) as mock_rrs:
mock_rrs.return_value = ["\"sample-txt-contents\"",
"\"example-txt-contents\""]
client.del_txt_record(DOMAIN, "_acme-challenge.example.org",
"example-txt-contents", self.record_ttl)
expected_body = {
"kind": "dns#change",
@ -180,8 +214,17 @@ class GoogleClientTest(unittest.TestCase):
{
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": self.record_name + ".",
"rrdatas": [self.record_content, ],
"name": "_acme-challenge.example.org.",
"rrdatas": ["\"sample-txt-contents\"", "\"example-txt-contents\""],
"ttl": self.record_ttl,
},
],
"additions": [
{
"kind": "dns#resourceRecordSet",
"type": "TXT",
"name": "_acme-challenge.example.org.",
"rrdatas": ["\"sample-txt-contents\"", ],
"ttl": self.record_ttl,
},
],
@ -217,15 +260,44 @@ class GoogleClientTest(unittest.TestCase):
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing(self, unused_credential_mock):
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
# Record name mocked in setUp
found = client.get_existing_txt_rrset(self.zone, "_acme-challenge.example.org")
self.assertEquals(found, ["\"example-txt-contents\""])
not_found = client.get_existing_txt_rrset(self.zone, "nonexistent.tld")
self.assertEquals(not_found, None)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing_fallback(self, unused_credential_mock):
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
# pylint: disable=no-member
mock_execute = client.dns.resourceRecordSets.return_value.list.return_value.execute
mock_execute.side_effect = API_ERROR
rrset = client.get_existing_txt_rrset(self.zone, "_acme-challenge.example.org")
self.assertFalse(rrset)
def test_get_project_id(self):
from certbot_dns_google.dns_google import _GoogleClient
response = DummyResponse()
response.status = 200
with mock.patch('httplib2.Http.request', return_value=(response, 1234)):
with mock.patch('httplib2.Http.request', return_value=(response, 'test-test-1')):
project_id = _GoogleClient.get_project_id()
self.assertEqual(project_id, 1234)
self.assertEqual(project_id, 'test-test-1')
with mock.patch('httplib2.Http.request', return_value=(response, b'test-test-1')):
project_id = _GoogleClient.get_project_id()
self.assertEqual(project_id, 'test-test-1')
failed_response = DummyResponse()
failed_response.status = 404

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-google[docs]

View file

@ -6,18 +6,17 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
# 1.5 is the first version that supports oauth2client>=2.0
'google-api-python-client>=1.5',
'mock',
# for oauth2client.service_account.ServiceAccountCredentials
'oauth2client>=2.0',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
# already a dependency of google-api-python-client, but added for consistency
'httplib2'
@ -36,6 +35,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -44,10 +44,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-luadns
RUN pip install --no-cache-dir --editable src/certbot-dns-luadns

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-luadns[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dns-lexicon',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -41,7 +41,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-nsone
RUN pip install --no-cache-dir --editable src/certbot-dns-nsone

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-nsone[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dns-lexicon',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -41,7 +41,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-rfc2136
RUN pip install --no-cache-dir --editable src/certbot-dns-rfc2136

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-rfc2136[docs]

View file

@ -6,15 +6,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'dnspython',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -31,6 +30,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -39,10 +39,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -0,0 +1,5 @@
FROM certbot/certbot
COPY . src/certbot-dns-route53
RUN pip install --no-cache-dir --editable src/certbot-dns-route53

View file

@ -1,4 +1,5 @@
"""Certbot Route53 authenticator plugin."""
import collections
import logging
import time
@ -33,6 +34,7 @@ class Authenticator(dns_common.DNSAuthenticator):
def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
self.r53 = boto3.client("route53")
self._resource_records = collections.defaultdict(list)
def more_info(self): # pylint: disable=missing-docstring,no-self-use
return "Solve a DNS01 challenge using AWS Route53"
@ -88,6 +90,20 @@ class Authenticator(dns_common.DNSAuthenticator):
def _change_txt_record(self, action, validation_domain_name, validation):
zone_id = self._find_zone_id_for_domain(validation_domain_name)
rrecords = self._resource_records[validation_domain_name]
challenge = {"Value": '"{0}"'.format(validation)}
if action == "DELETE":
# Remove the record being deleted from the list of tracked records
rrecords.remove(challenge)
if rrecords:
# Need to update instead, as we're not deleting the rrset
action = "UPSERT"
else:
# Create a new list containing the record to use with DELETE
rrecords = [challenge]
else:
rrecords.append(challenge)
response = self.r53.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
@ -99,11 +115,7 @@ class Authenticator(dns_common.DNSAuthenticator):
"Name": validation_domain_name,
"Type": "TXT",
"TTL": self.ttl,
"ResourceRecords": [
# For some reason TXT records need to be
# manually quoted.
{"Value": '"{0}"'.format(validation)}
],
"ResourceRecords": rrecords,
}
}
]

View file

@ -186,6 +186,48 @@ class ClientTest(unittest.TestCase):
call_count = self.client.r53.change_resource_record_sets.call_count
self.assertEqual(call_count, 1)
def test_change_txt_record_delete(self):
self.client._find_zone_id_for_domain = mock.MagicMock()
self.client.r53.change_resource_record_sets = mock.MagicMock(
return_value={"ChangeInfo": {"Id": 1}})
validation = "some-value"
validation_record = {"Value": '"{0}"'.format(validation)}
self.client._resource_records[DOMAIN] = [validation_record]
self.client._change_txt_record("DELETE", DOMAIN, validation)
call_count = self.client.r53.change_resource_record_sets.call_count
self.assertEqual(call_count, 1)
call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1]
call_args_batch = call_args["ChangeBatch"]["Changes"][0]
self.assertEqual(call_args_batch["Action"], "DELETE")
self.assertEqual(
call_args_batch["ResourceRecordSet"]["ResourceRecords"],
[validation_record])
def test_change_txt_record_multirecord(self):
self.client._find_zone_id_for_domain = mock.MagicMock()
self.client._get_validation_rrset = mock.MagicMock()
self.client._resource_records[DOMAIN] = [
{"Value": "\"pre-existing-value\""},
{"Value": "\"pre-existing-value-two\""},
]
self.client.r53.change_resource_record_sets = mock.MagicMock(
return_value={"ChangeInfo": {"Id": 1}})
self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value")
call_count = self.client.r53.change_resource_record_sets.call_count
call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1]
call_args_batch = call_args["ChangeBatch"]["Changes"][0]
self.assertEqual(call_args_batch["Action"], "UPSERT")
self.assertEqual(
call_args_batch["ResourceRecordSet"]["ResourceRecords"],
[{"Value": "\"pre-existing-value-two\""}])
self.assertEqual(call_count, 1)
def test_wait_for_change(self):
self.client.r53.get_change = mock.MagicMock(
side_effect=[{"ChangeInfo": {"Status": "PENDING"}},

View file

@ -0,0 +1,2 @@
acme[dev]==0.21.1
certbot[dev]==0.21.1

View file

@ -0,0 +1,12 @@
# readthedocs.org gives no way to change the install command to "pip
# install -e .[docs]" (that would in turn install documentation
# dependencies), but it allows to specify a requirements.txt file at
# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259)
# Although ReadTheDocs certainly doesn't need to install the project
# in --editable mode (-e), just "pip install .[docs]" does not work as
# expected and "pip install -e .[docs]" must be used instead
-e acme
-e .
-e certbot-dns-route53[docs]

View file

@ -5,14 +5,14 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'acme>=0.21.1',
'certbot>=0.21.1',
'boto3',
'mock',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -24,6 +24,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -32,10 +33,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -23,6 +23,7 @@ from certbot import util
from certbot.plugins import common
from certbot_nginx import constants
from certbot_nginx import display_ops
from certbot_nginx import nginxparser
from certbot_nginx import parser
from certbot_nginx import tls_sni_01
@ -92,6 +93,11 @@ class NginxConfigurator(common.Installer):
# For creating new vhosts if no names match
self.new_vhost = None
# List of vhosts configured per wildcard domain on this run.
# used by deploy_cert() and enhance()
self._wildcard_vhosts = {}
self._wildcard_redirect_vhosts = {}
# Add number of outstanding challenges
self._chall_out = 0
@ -146,6 +152,7 @@ class NginxConfigurator(common.Installer):
raise errors.PluginError(
'Unable to lock %s', self.conf('server-root'))
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None):
@ -166,14 +173,24 @@ class NginxConfigurator(common.Installer):
"The nginx plugin currently requires --fullchain-path to "
"install a cert.")
vhost = self.choose_vhost(domain, create_if_no_match=True)
vhosts = self.choose_vhosts(domain, create_if_no_match=True)
for vhost in vhosts:
self._deploy_cert(vhost, cert_path, key_path, chain_path, fullchain_path)
def _deploy_cert(self, vhost, cert_path, key_path, chain_path, fullchain_path):
# pylint: disable=unused-argument
"""
Helper function for deploy_cert() that handles the actual deployment
this exists because we might want to do multiple deployments per
domain originally passed for deploy_cert(). This is especially true
with wildcard certificates
"""
cert_directives = [['\n ', 'ssl_certificate', ' ', fullchain_path],
['\n ', 'ssl_certificate_key', ' ', key_path]]
self.parser.add_server_directives(vhost,
cert_directives, replace=True)
logger.info("Deployed Certificate to VirtualHost %s for %s",
vhost.filep, ", ".join(vhost.names))
logger.info("Deploying Certificate to VirtualHost %s", vhost.filep)
self.save_notes += ("Changed vhost at %s with addresses of %s\n" %
(vhost.filep,
@ -181,10 +198,61 @@ class NginxConfigurator(common.Installer):
self.save_notes += "\tssl_certificate %s\n" % fullchain_path
self.save_notes += "\tssl_certificate_key %s\n" % key_path
def _choose_vhosts_wildcard(self, domain, prefer_ssl, no_ssl_filter_port=None):
"""Prompts user to choose vhosts to install a wildcard certificate for"""
if prefer_ssl:
vhosts_cache = self._wildcard_vhosts
preference_test = lambda x: x.ssl
else:
vhosts_cache = self._wildcard_redirect_vhosts
preference_test = lambda x: not x.ssl
# Caching!
if domain in vhosts_cache:
# Vhosts for a wildcard domain were already selected
return vhosts_cache[domain]
# Get all vhosts whether or not they are covered by the wildcard domain
vhosts = self.parser.get_vhosts()
# Go through the vhosts, making sure that we cover all the names
# present, but preferring the SSL or non-SSL vhosts
filtered_vhosts = {}
for vhost in vhosts:
# Ensure we're listening non-sslishly on no_ssl_filter_port
if no_ssl_filter_port is not None:
if not self._vhost_listening_on_port_no_ssl(vhost, no_ssl_filter_port):
continue
for name in vhost.names:
if preference_test(vhost):
# Prefer either SSL or non-SSL vhosts
filtered_vhosts[name] = vhost
elif name not in filtered_vhosts:
# Add if not in list previously
filtered_vhosts[name] = vhost
# Only unique VHost objects
dialog_input = set([vhost for vhost in filtered_vhosts.values()])
# Ask the user which of names to enable, expect list of names back
return_vhosts = display_ops.select_vhost_multiple(list(dialog_input))
for vhost in return_vhosts:
if domain not in vhosts_cache:
vhosts_cache[domain] = []
vhosts_cache[domain].append(vhost)
return return_vhosts
#######################
# Vhost parsing methods
#######################
def choose_vhost(self, target_name, create_if_no_match=False):
def _choose_vhost_single(self, target_name):
matches = self._get_ranked_matches(target_name)
vhosts = [x for x in [self._select_best_name_match(matches)] if x is not None]
return vhosts
def choose_vhosts(self, target_name, create_if_no_match=False):
"""Chooses a virtual host based on the given domain name.
.. note:: This makes the vhost SSL-enabled if it isn't already. Follows
@ -202,17 +270,19 @@ class NginxConfigurator(common.Installer):
when there is no match found. If we can't choose a default, raise a
MisconfigurationError.
:returns: ssl vhost associated with name
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
:returns: ssl vhosts associated with name
:rtype: list of :class:`~certbot_nginx.obj.VirtualHost`
"""
vhost = None
matches = self._get_ranked_matches(target_name)
vhost = self._select_best_name_match(matches)
if not vhost:
if util.is_wildcard_domain(target_name):
# Ask user which VHosts to support.
vhosts = self._choose_vhosts_wildcard(target_name, prefer_ssl=True)
else:
vhosts = self._choose_vhost_single(target_name)
if not vhosts:
if create_if_no_match:
vhost = self._vhost_from_duplicated_default(target_name)
# result will not be [None] because it errors on failure
vhosts = [self._vhost_from_duplicated_default(target_name)]
else:
# No matches. Raise a misconfiguration error.
raise errors.MisconfigurationError(
@ -222,10 +292,11 @@ class NginxConfigurator(common.Installer):
"nginx configuration: "
"https://nginx.org/en/docs/http/server_names.html") % (target_name))
# Note: if we are enhancing with ocsp, vhost should already be ssl.
if not vhost.ssl:
self._make_server_ssl(vhost)
for vhost in vhosts:
if not vhost.ssl:
self._make_server_ssl(vhost)
return vhost
return vhosts
def ipv6_info(self, port):
"""Returns tuple of booleans (ipv6_active, ipv6only_present)
@ -240,6 +311,9 @@ class NginxConfigurator(common.Installer):
configuration, and existence of ipv6only directive for specified port
:rtype: tuple of type (bool, bool)
"""
# port should be a string, but it's easy to mess up, so let's
# make sure it is one
port = str(port)
vhosts = self.parser.get_vhosts()
ipv6_active = False
ipv6only_present = False
@ -359,7 +433,7 @@ class NginxConfigurator(common.Installer):
return sorted(matches, key=lambda x: x['rank'])
def choose_redirect_vhost(self, target_name, port, create_if_no_match=False):
def choose_redirect_vhosts(self, target_name, port, create_if_no_match=False):
"""Chooses a single virtual host for redirect enhancement.
Chooses the vhost most closely matching target_name that is
@ -377,15 +451,20 @@ class NginxConfigurator(common.Installer):
when there is no match found. If we can't choose a default, raise a
MisconfigurationError.
:returns: vhost associated with name
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
:returns: vhosts associated with name
:rtype: list of :class:`~certbot_nginx.obj.VirtualHost`
"""
matches = self._get_redirect_ranked_matches(target_name, port)
vhost = self._select_best_name_match(matches)
if not vhost and create_if_no_match:
vhost = self._vhost_from_duplicated_default(target_name, port=port)
return vhost
if util.is_wildcard_domain(target_name):
# Ask user which VHosts to enhance.
vhosts = self._choose_vhosts_wildcard(target_name, prefer_ssl=False,
no_ssl_filter_port=port)
else:
matches = self._get_redirect_ranked_matches(target_name, port)
vhosts = [x for x in [self._select_best_name_match(matches)]if x is not None]
if not vhosts and create_if_no_match:
vhosts = [self._vhost_from_duplicated_default(target_name, port=port)]
return vhosts
def _port_matches(self, test_port, matching_port):
# test_port is a number, matching is a number or "" or None
@ -395,6 +474,23 @@ class NginxConfigurator(common.Installer):
else:
return test_port == matching_port
def _vhost_listening_on_port_no_ssl(self, vhost, port):
found_matching_port = False
if len(vhost.addrs) == 0:
# if there are no listen directives at all, Nginx defaults to
# listening on port 80.
found_matching_port = (port == self.DEFAULT_LISTEN_PORT)
else:
for addr in vhost.addrs:
if self._port_matches(port, addr.get_port()) and addr.ssl == False:
found_matching_port = True
if found_matching_port:
# make sure we don't have an 'ssl on' directive
return not self.parser.has_ssl_on_directive(vhost)
else:
return False
def _get_redirect_ranked_matches(self, target_name, port):
"""Gets a ranked list of plaintextish port-listening vhosts matching target_name
@ -411,21 +507,7 @@ class NginxConfigurator(common.Installer):
all_vhosts = self.parser.get_vhosts()
def _vhost_matches(vhost, port):
found_matching_port = False
if len(vhost.addrs) == 0:
# if there are no listen directives at all, Nginx defaults to
# listening on port 80.
found_matching_port = (port == self.DEFAULT_LISTEN_PORT)
else:
for addr in vhost.addrs:
if self._port_matches(port, addr.get_port()) and addr.ssl == False:
found_matching_port = True
if found_matching_port:
# make sure we don't have an 'ssl on' directive
return not self.parser.has_ssl_on_directive(vhost)
else:
return False
return self._vhost_listening_on_port_no_ssl(vhost, port)
matching_vhosts = [vhost for vhost in all_vhosts if _vhost_matches(vhost, port)]
@ -587,17 +669,31 @@ class NginxConfigurator(common.Installer):
"""
port = self.DEFAULT_LISTEN_PORT
vhost = None
# If there are blocks listening plaintextishly on self.DEFAULT_LISTEN_PORT,
# choose the most name-matching one.
vhost = self.choose_redirect_vhost(domain, port)
vhosts = self.choose_redirect_vhosts(domain, port)
if vhost is None:
if not vhosts:
logger.info("No matching insecure server blocks listening on port %s found.",
self.DEFAULT_LISTEN_PORT)
return
for vhost in vhosts:
self._enable_redirect_single(domain, vhost)
def _enable_redirect_single(self, domain, vhost):
"""Redirect all equivalent HTTP traffic to ssl_vhost.
If the vhost is listening plaintextishly, separate out the
relevant directives into a new server block and add a rewrite directive.
.. note:: This function saves the configuration
:param str domain: domain to enable redirect for
:param `~obj.Vhost` vhost: vhost to enable redirect for
"""
new_vhost = None
if vhost.ssl:
new_vhost = self.parser.duplicate_vhost(vhost,
@ -638,7 +734,18 @@ class NginxConfigurator(common.Installer):
:type chain_path: `str` or `None`
"""
vhost = self.choose_vhost(domain)
vhosts = self.choose_vhosts(domain)
for vhost in vhosts:
self._enable_ocsp_stapling_single(vhost, chain_path)
def _enable_ocsp_stapling_single(self, vhost, chain_path):
"""Include OCSP response in TLS handshake
:param str vhost: vhost to enable OCSP response for
:param chain_path: chain file path
:type chain_path: `str` or `None`
"""
if self.version < (1, 3, 7):
raise errors.PluginError("Version 1.3.7 or greater of nginx "
"is needed to enable OCSP stapling")
@ -889,14 +996,23 @@ def _test_block_from_block(block):
parser.comment_directive(test_block, 0)
return test_block[:-1]
def _redirect_block_for_domain(domain):
updated_domain = domain
match_symbol = '='
if util.is_wildcard_domain(domain):
match_symbol = '~'
updated_domain = updated_domain.replace('.', r'\.')
updated_domain = updated_domain.replace('*', '[^.]+')
updated_domain = '^' + updated_domain + '$'
redirect_block = [[
['\n ', 'if', ' ', '($host', ' ', '=', ' ', '%s)' % domain, ' '],
['\n ', 'if', ' ', '($host', ' ', match_symbol, ' ', '%s)' % updated_domain, ' '],
[['\n ', 'return', ' ', '301', ' ', 'https://$host$request_uri'],
'\n ']],
['\n']]
return redirect_block
def nginx_restart(nginx_ctl, nginx_conf):
"""Restarts the Nginx Server.

View file

@ -0,0 +1,44 @@
"""Contains UI methods for Nginx operations."""
import logging
import zope.component
from certbot import interfaces
import certbot.display.util as display_util
logger = logging.getLogger(__name__)
def select_vhost_multiple(vhosts):
"""Select multiple Vhosts to install the certificate for
:param vhosts: Available Nginx VirtualHosts
:type vhosts: :class:`list` of type `~obj.Vhost`
:returns: List of VirtualHosts
:rtype: :class:`list`of type `~obj.Vhost`
"""
if not vhosts:
return list()
tags_list = [vhost.display_repr()+"\n" for vhost in vhosts]
# Remove the extra newline from the last entry
if len(tags_list):
tags_list[-1] = tags_list[-1][:-1]
code, names = zope.component.getUtility(interfaces.IDisplay).checklist(
"Which server blocks would you like to modify?",
tags=tags_list, force_interactive=True)
if code == display_util.OK:
return_vhosts = _reversemap_vhosts(names, vhosts)
return return_vhosts
return []
def _reversemap_vhosts(names, vhosts):
"""Helper function for select_vhost_multiple for mapping string
representations back to actual vhost objects"""
return_vhosts = list()
for selection in names:
for vhost in vhosts:
if vhost.display_repr().strip() == selection.strip():
return_vhosts.append(vhost)
return return_vhosts

View file

@ -179,13 +179,17 @@ class NginxHttp01(common.ChallengePerformer):
"""
try:
vhost = self.configurator.choose_redirect_vhost(achall.domain,
vhosts = self.configurator.choose_redirect_vhosts(achall.domain,
'%i' % self.configurator.config.http01_port, create_if_no_match=True)
except errors.MisconfigurationError:
# Couldn't find either a matching name+port server block
# or a port+default_server block, so create a dummy block
return self._make_server_block(achall)
# len is max 1 because Nginx doesn't authenticate wildcards
# if len were or vhosts None, we would have errored
vhost = vhosts[0]
# Modify existing server block
validation = achall.validation(achall.account_key)
validation_path = self._get_validation_path(achall)

View file

@ -193,6 +193,11 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
return False
def __hash__(self):
return hash((self.filep, tuple(self.path),
tuple(self.addrs), tuple(self.names),
self.ssl, self.enabled))
def contains_list(self, test):
"""Determine if raw server block contains test list at top level
"""
@ -216,3 +221,15 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
for a in self.addrs:
if not a.ipv6:
return True
def display_repr(self):
"""Return a representation of VHost to be used in dialog"""
return (
"File: {filename}\n"
"Addresses: {addrs}\n"
"Names: {names}\n"
"HTTPS: {https}\n".format(
filename=self.filep,
addrs=", ".join(str(addr) for addr in self.addrs),
names=", ".join(self.names),
https="Yes" if self.ssl else "No"))

View file

@ -128,7 +128,7 @@ class NginxConfiguratorTest(util.NginxTest):
['#', parser.COMMENT]]]],
parsed[0])
def test_choose_vhost(self):
def test_choose_vhosts(self):
localhost_conf = set(['localhost', r'~^(www\.)?(example|bar)\.'])
server_conf = set(['somename', 'another.alias', 'alias'])
example_conf = set(['.example.com', 'example.*'])
@ -159,7 +159,7 @@ class NginxConfiguratorTest(util.NginxTest):
'69.255.225.155']
for name in results:
vhost = self.config.choose_vhost(name)
vhost = self.config.choose_vhosts(name)[0]
path = os.path.relpath(vhost.filep, self.temp_dir)
self.assertEqual(results[name], vhost.names)
@ -173,7 +173,7 @@ class NginxConfiguratorTest(util.NginxTest):
for name in bad_results:
self.assertRaises(errors.MisconfigurationError,
self.config.choose_vhost, name)
self.config.choose_vhosts, name)
def test_ipv6only(self):
# ipv6_info: (ipv6_active, ipv6only_present)
@ -181,6 +181,18 @@ class NginxConfiguratorTest(util.NginxTest):
# Port 443 has ipv6only=on because of ipv6ssl.com vhost
self.assertEquals((True, True), self.config.ipv6_info("443"))
def test_ipv6only_detection(self):
self.config.version = (1, 3, 1)
self.config.deploy_cert(
"ipv6.com",
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
for addr in self.config.choose_vhosts("ipv6.com")[0].addrs:
self.assertFalse(addr.ipv6only)
def test_more_info(self):
self.assertTrue('nginx.conf' in self.config.more_info())
@ -702,6 +714,100 @@ class NginxConfiguratorTest(util.NginxTest):
self.config.rollback_checkpoints()
self.assertTrue(mock_parser_load.call_count == 3)
def test_choose_vhosts_wildcard(self):
# pylint: disable=protected-access
mock_path = "certbot_nginx.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
vhost = [x for x in self.config.parser.get_vhosts()
if 'summer.com' in x.names][0]
mock_select_vhs.return_value = [vhost]
vhs = self.config._choose_vhosts_wildcard("*.com",
prefer_ssl=True)
# Check that the dialog was called with migration.com
self.assertTrue(vhost in mock_select_vhs.call_args[0][0])
# And the actual returned values
self.assertEquals(len(vhs), 1)
self.assertEqual(vhs[0], vhost)
def test_choose_vhosts_wildcard_redirect(self):
# pylint: disable=protected-access
mock_path = "certbot_nginx.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
vhost = [x for x in self.config.parser.get_vhosts()
if 'summer.com' in x.names][0]
mock_select_vhs.return_value = [vhost]
vhs = self.config._choose_vhosts_wildcard("*.com",
prefer_ssl=False)
# Check that the dialog was called with migration.com
self.assertTrue(vhost in mock_select_vhs.call_args[0][0])
# And the actual returned values
self.assertEquals(len(vhs), 1)
self.assertEqual(vhs[0], vhost)
def test_deploy_cert_wildcard(self):
# pylint: disable=protected-access
mock_choose_vhosts = mock.MagicMock()
vhost = [x for x in self.config.parser.get_vhosts()
if 'geese.com' in x.names][0]
mock_choose_vhosts.return_value = [vhost]
self.config._choose_vhosts_wildcard = mock_choose_vhosts
mock_d = "certbot_nginx.configurator.NginxConfigurator._deploy_cert"
with mock.patch(mock_d) as mock_dep:
self.config.deploy_cert("*.com", "/tmp/path",
"/tmp/path", "/tmp/path", "/tmp/path")
self.assertTrue(mock_dep.called)
self.assertEquals(len(mock_dep.call_args_list), 1)
self.assertEqual(vhost, mock_dep.call_args_list[0][0][0])
@mock.patch("certbot_nginx.display_ops.select_vhost_multiple")
def test_deploy_cert_wildcard_no_vhosts(self, mock_dialog):
# pylint: disable=protected-access
mock_dialog.return_value = []
self.assertRaises(errors.PluginError,
self.config.deploy_cert,
"*.wild.cat", "/tmp/path", "/tmp/path",
"/tmp/path", "/tmp/path")
@mock.patch("certbot_nginx.display_ops.select_vhost_multiple")
def test_enhance_wildcard_ocsp_after_install(self, mock_dialog):
# pylint: disable=protected-access
vhost = [x for x in self.config.parser.get_vhosts()
if 'geese.com' in x.names][0]
self.config._wildcard_vhosts["*.com"] = [vhost]
self.config.enhance("*.com", "staple-ocsp", "example/chain.pem")
self.assertFalse(mock_dialog.called)
@mock.patch("certbot_nginx.display_ops.select_vhost_multiple")
def test_enhance_wildcard_redirect_or_ocsp_no_install(self, mock_dialog):
vhost = [x for x in self.config.parser.get_vhosts()
if 'summer.com' in x.names][0]
mock_dialog.return_value = [vhost]
self.config.enhance("*.com", "staple-ocsp", "example/chain.pem")
self.assertTrue(mock_dialog.called)
@mock.patch("certbot_nginx.display_ops.select_vhost_multiple")
def test_enhance_wildcard_double_redirect(self, mock_dialog):
# pylint: disable=protected-access
vhost = [x for x in self.config.parser.get_vhosts()
if 'summer.com' in x.names][0]
self.config._wildcard_redirect_vhosts["*.com"] = [vhost]
self.config.enhance("*.com", "redirect")
self.assertFalse(mock_dialog.called)
def test_choose_vhosts_wildcard_no_ssl_filter_port(self):
# pylint: disable=protected-access
mock_path = "certbot_nginx.display_ops.select_vhost_multiple"
with mock.patch(mock_path) as mock_select_vhs:
mock_select_vhs.return_value = []
self.config._choose_vhosts_wildcard("*.com",
prefer_ssl=False,
no_ssl_filter_port='80')
# Check that the dialog was called with only port 80 vhosts
self.assertEqual(len(mock_select_vhs.call_args[0][0]), 4)
class InstallSslOptionsConfTest(util.NginxTest):
"""Test that the options-ssl-nginx.conf file is installed and updated properly."""

View file

@ -0,0 +1,45 @@
"""Test certbot_apache.display_ops."""
import unittest
from certbot.display import util as display_util
from certbot.tests import util as certbot_util
from certbot_nginx import parser
from certbot_nginx.display_ops import select_vhost_multiple
from certbot_nginx.tests import util
class SelectVhostMultiTest(util.NginxTest):
"""Tests for certbot_nginx.display_ops.select_vhost_multiple."""
def setUp(self):
super(SelectVhostMultiTest, self).setUp()
nparser = parser.NginxParser(self.config_path)
self.vhosts = nparser.get_vhosts()
def test_select_no_input(self):
self.assertFalse(select_vhost_multiple([]))
@certbot_util.patch_get_utility()
def test_select_correct(self, mock_util):
mock_util().checklist.return_value = (
display_util.OK, [self.vhosts[3].display_repr(),
self.vhosts[2].display_repr()])
vhs = select_vhost_multiple([self.vhosts[3],
self.vhosts[2],
self.vhosts[1]])
self.assertTrue(self.vhosts[2] in vhs)
self.assertTrue(self.vhosts[3] in vhs)
self.assertFalse(self.vhosts[1] in vhs)
@certbot_util.patch_get_utility()
def test_select_cancel(self, mock_util):
mock_util().checklist.return_value = (display_util.CANCEL, "whatever")
vhs = select_vhost_multiple([self.vhosts[2], self.vhosts[3]])
self.assertFalse(vhs)
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -1,5 +1,7 @@
server {
listen 443 ssl;
listen [::]:443 ssl ipv6only=on;
listen 5001 ssl;
listen [::]:5001 ssl ipv6only=on;
server_name ipv6ssl.com;
}

View file

@ -61,10 +61,10 @@ class TlsSniPerformTest(util.NginxTest):
shutil.rmtree(self.work_dir)
@mock.patch("certbot_nginx.configurator"
".NginxConfigurator.choose_vhost")
".NginxConfigurator.choose_vhosts")
def test_perform(self, mock_choose):
self.sni.add_chall(self.achalls[1])
mock_choose.return_value = None
mock_choose.return_value = []
result = self.sni.perform()
self.assertFalse(result is None)

View file

@ -55,10 +55,11 @@ class NginxTlsSni01(common.TLSSNI01):
self.configurator.config.tls_sni_01_port)
for achall in self.achalls:
vhost = self.configurator.choose_vhost(achall.domain, create_if_no_match=True)
vhosts = self.configurator.choose_vhosts(achall.domain, create_if_no_match=True)
if vhost is not None and vhost.addrs:
addresses.append(list(vhost.addrs))
# len is max 1 because Nginx doesn't authenticate wildcards
if vhosts and vhosts[0].addrs:
addresses.append(list(vhosts[0].addrs))
else:
if ipv6:
# If IPv6 is active in Nginx configuration

View file

@ -0,0 +1,2 @@
-e acme[dev]
-e .[dev]

View file

@ -6,16 +6,18 @@ from setuptools import find_packages
version = '0.22.0.dev0'
# Please update tox.ini when modifying dependency version requirements
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
# This plugin works with an older version of acme, but Certbot does not.
# 0.22.0 is specified here to work around
# https://github.com/pypa/pip/issues/988.
'acme>0.21.1',
'certbot>0.21.1',
'mock',
'PyOpenSSL',
'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary?
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
'setuptools',
'zope.interface',
]
@ -32,6 +34,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
@ -40,10 +43,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

View file

@ -223,12 +223,17 @@ class AccountFileStorage(interfaces.AccountStorage):
try:
with open(self._regr_path(account_dir_path), "w") as regr_file:
regr = account.regr
with_uri = RegistrationResourceWithNewAuthzrURI(
new_authzr_uri=acme.directory.new_authz,
body=regr.body,
uri=regr.uri,
terms_of_service=regr.terms_of_service)
regr_file.write(with_uri.json_dumps())
# If we have a value for new-authz, save it for forwards
# compatibility with older versions of Certbot. If we don't
# have a value for new-authz, this is an ACMEv2 directory where
# an older version of Certbot won't work anyway.
if hasattr(acme.directory, "new-authz"):
regr = RegistrationResourceWithNewAuthzrURI(
new_authzr_uri=acme.directory.new_authz,
body=regr.body,
uri=regr.uri,
terms_of_service=regr.terms_of_service)
regr_file.write(regr.json_dumps())
if not regr_only:
with util.safe_open(self._key_path(account_dir_path),
"w", chmod=0o400) as key_file:

View file

@ -1,4 +1,5 @@
"""ACME AuthHandler."""
import collections
import logging
import time
@ -17,6 +18,10 @@ from certbot import interfaces
logger = logging.getLogger(__name__)
AnnotatedAuthzr = collections.namedtuple("AnnotatedAuthzr", ["authzr", "achalls"])
"""Stores an authorization resource and its active annotated challenges."""
class AuthHandler(object):
"""ACME Authorization Handler for a client.
@ -24,15 +29,11 @@ class AuthHandler(object):
:class:`~acme.challenges.Challenge` types
:type auth: :class:`certbot.interfaces.IAuthenticator`
:ivar acme.client.Client acme: ACME client API.
:ivar acme.client.BackwardsCompatibleClientV2 acme: ACME client API.
:ivar account: Client's Account
:type account: :class:`certbot.account.Account`
:ivar dict authzr: ACME Authorization Resource dict where keys are domains
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
type strings with the most preferred challenge listed first
@ -42,18 +43,15 @@ class AuthHandler(object):
self.acme = acme
self.account = account
self.authzr = dict()
self.pref_challs = pref_challs
# List must be used to keep responses straight.
self.achalls = []
def get_authorizations(self, domains, best_effort=False):
def handle_authorizations(self, orderr, best_effort=False):
"""Retrieve all authorizations for challenges.
:param list domains: Domains for authorization
:param acme.messages.OrderResource orderr: must have
authorizations filled in
:param bool best_effort: Whether or not all authorizations are
required (this is useful in renewal)
required (this is useful in renewal)
:returns: List of authorization resources
:rtype: list
@ -62,30 +60,30 @@ class AuthHandler(object):
authorizations
"""
for domain in domains:
self.authzr[domain] = self.acme.request_domain_challenges(domain)
aauthzrs = [AnnotatedAuthzr(authzr, [])
for authzr in orderr.authorizations]
self._choose_challenges(domains)
self._choose_challenges(aauthzrs)
config = zope.component.getUtility(interfaces.IConfig)
notify = zope.component.getUtility(interfaces.IDisplay).notification
# While there are still challenges remaining...
while self.achalls:
resp = self._solve_challenges()
while self._has_challenges(aauthzrs):
resp = self._solve_challenges(aauthzrs)
logger.info("Waiting for verification...")
if config.debug_challenges:
notify('Challenges loaded. Press continue to submit to CA. '
'Pass "-v" for more info about challenges.', pause=True)
# Send all Responses - this modifies achalls
self._respond(resp, best_effort)
self._respond(aauthzrs, resp, best_effort)
# Just make sure all decisions are complete.
self.verify_authzr_complete()
self.verify_authzr_complete(aauthzrs)
# Only return valid authorizations
retVal = [authzr for authzr in self.authzr.values()
if authzr.body.status == messages.STATUS_VALID]
retVal = [aauthzr.authzr for aauthzr in aauthzrs
if aauthzr.authzr.body.status == messages.STATUS_VALID]
if not retVal:
raise errors.AuthorizationError(
@ -93,36 +91,55 @@ class AuthHandler(object):
return retVal
def _choose_challenges(self, domains):
def _choose_challenges(self, aauthzrs):
"""Retrieve necessary challenges to satisfy server."""
logger.info("Performing the following challenges:")
for dom in domains:
for aauthzr in aauthzrs:
aauthzr_challenges = aauthzr.authzr.body.challenges
if self.acme.acme_version == 1:
combinations = aauthzr.authzr.body.combinations
else:
combinations = tuple((i,) for i in range(len(aauthzr_challenges)))
path = gen_challenge_path(
self.authzr[dom].body.challenges,
self._get_chall_pref(dom),
self.authzr[dom].body.combinations)
aauthzr_challenges,
self._get_chall_pref(aauthzr.authzr.body.identifier.value),
combinations)
dom_achalls = self._challenge_factory(
dom, path)
self.achalls.extend(dom_achalls)
aauthzr_achalls = self._challenge_factory(
aauthzr.authzr, path)
aauthzr.achalls.extend(aauthzr_achalls)
def _solve_challenges(self):
def _has_challenges(self, aauthzrs):
"""Do we have any challenges to perform?"""
return any(aauthzr.achalls for aauthzr in aauthzrs)
def _solve_challenges(self, aauthzrs):
"""Get Responses for challenges from authenticators."""
resp = []
with error_handler.ErrorHandler(self._cleanup_challenges):
all_achalls = self._get_all_achalls(aauthzrs)
with error_handler.ErrorHandler(self._cleanup_challenges, all_achalls):
try:
if self.achalls:
resp = self.auth.perform(self.achalls)
if all_achalls:
resp = self.auth.perform(all_achalls)
except errors.AuthorizationError:
logger.critical("Failure in setting up challenges.")
logger.info("Attempting to clean up outstanding challenges...")
raise
assert len(resp) == len(self.achalls)
assert len(resp) == len(all_achalls)
return resp
def _respond(self, resp, best_effort):
def _get_all_achalls(self, aauthzrs):
"""Return all active challenges."""
all_achalls = []
for aauthzr in aauthzrs:
all_achalls.extend(aauthzr.achalls)
return all_achalls
def _respond(self, aauthzrs, resp, best_effort):
"""Send/Receive confirmation of all challenges.
.. note:: This method also cleans up the auth_handler state.
@ -130,69 +147,70 @@ class AuthHandler(object):
"""
# TODO: chall_update is a dirty hack to get around acme-spec #105
chall_update = dict()
active_achalls = self._send_responses(self.achalls,
resp, chall_update)
active_achalls = self._send_responses(aauthzrs, resp, chall_update)
# Check for updated status...
try:
self._poll_challenges(chall_update, best_effort)
self._poll_challenges(aauthzrs, chall_update, best_effort)
finally:
# This removes challenges from self.achalls
self._cleanup_challenges(active_achalls)
self._cleanup_challenges(aauthzrs, active_achalls)
def _send_responses(self, achalls, resps, chall_update):
def _send_responses(self, aauthzrs, resps, chall_update):
"""Send responses and make sure errors are handled.
:param aauthzrs: authorizations and the selected annotated challenges
to try and perform
:type aauthzrs: `list` of `AnnotatedAuthzr`
:param dict chall_update: parameter that is updated to hold
authzr -> list of outstanding solved annotated challenges
aauthzr index to list of outstanding solved annotated challenges
"""
active_achalls = []
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)
resps_iter = iter(resps)
for i, aauthzr in enumerate(aauthzrs):
for achall in aauthzr.achalls:
# This line needs to be outside of the if block below to
# ensure failed challenges are cleaned up correctly
active_achalls.append(achall)
# Don't send challenges for None and False authenticator responses
if resp is not None and resp:
self.acme.answer_challenge(achall.challb, resp)
# TODO: answer_challenge returns challr, with URI,
# that can be used in _find_updated_challr
# comparisons...
if achall.domain in chall_update:
chall_update[achall.domain].append(achall)
else:
chall_update[achall.domain] = [achall]
resp = next(resps_iter)
# Don't send challenges for None and False authenticator responses
if resp:
self.acme.answer_challenge(achall.challb, resp)
# TODO: answer_challenge returns challr, with URI,
# that can be used in _find_updated_challr
# comparisons...
chall_update.setdefault(i, []).append(achall)
return active_achalls
def _poll_challenges(
self, chall_update, best_effort, min_sleep=3, max_rounds=15):
def _poll_challenges(self, aauthzrs, chall_update,
best_effort, min_sleep=3, max_rounds=15):
"""Wait for all challenge results to be determined."""
dom_to_check = set(chall_update.keys())
comp_domains = set()
indices_to_check = set(chall_update.keys())
comp_indices = set()
rounds = 0
while dom_to_check and rounds < max_rounds:
while indices_to_check and rounds < max_rounds:
# TODO: Use retry-after...
time.sleep(min_sleep)
all_failed_achalls = set()
for domain in dom_to_check:
for index in indices_to_check:
comp_achalls, failed_achalls = self._handle_check(
domain, chall_update[domain])
aauthzrs, index, chall_update[index])
if len(comp_achalls) == len(chall_update[domain]):
comp_domains.add(domain)
if len(comp_achalls) == len(chall_update[index]):
comp_indices.add(index)
elif not failed_achalls:
for achall, _ in comp_achalls:
chall_update[domain].remove(achall)
chall_update[index].remove(achall)
# We failed some challenges... damage control
else:
if best_effort:
comp_domains.add(domain)
comp_indices.add(index)
logger.warning(
"Challenge failed for domain %s",
domain)
aauthzrs[index].authzr.body.identifier.value)
else:
all_failed_achalls.update(
updated for _, updated in failed_achalls)
@ -201,24 +219,26 @@ class AuthHandler(object):
_report_failed_challs(all_failed_achalls)
raise errors.FailedChallenges(all_failed_achalls)
dom_to_check -= comp_domains
comp_domains.clear()
indices_to_check -= comp_indices
comp_indices.clear()
rounds += 1
def _handle_check(self, domain, achalls):
def _handle_check(self, aauthzrs, index, achalls):
"""Returns tuple of ('completed', 'failed')."""
completed = []
failed = []
self.authzr[domain], _ = self.acme.poll(self.authzr[domain])
if self.authzr[domain].body.status == messages.STATUS_VALID:
original_aauthzr = aauthzrs[index]
updated_authzr, _ = self.acme.poll(original_aauthzr.authzr)
aauthzrs[index] = AnnotatedAuthzr(updated_authzr, original_aauthzr.achalls)
if updated_authzr.body.status == messages.STATUS_VALID:
return achalls, []
# Note: if the whole authorization is invalid, the individual failed
# challenges will be determined here...
for achall in achalls:
updated_achall = achall.update(challb=self._find_updated_challb(
self.authzr[domain], achall))
updated_authzr, achall))
# This does nothing for challenges that have yet to be decided yet.
if updated_achall.status == messages.STATUS_VALID:
@ -267,7 +287,7 @@ class AuthHandler(object):
chall_prefs.extend(plugin_pref)
return chall_prefs
def _cleanup_challenges(self, achall_list=None):
def _cleanup_challenges(self, aauthzrs, achall_list=None):
"""Cleanup challenges.
If achall_list is not provided, cleanup all achallenges.
@ -276,31 +296,39 @@ class AuthHandler(object):
logger.info("Cleaning up challenges")
if achall_list is None:
achalls = self.achalls
achalls = self._get_all_achalls(aauthzrs)
else:
achalls = achall_list
if achalls:
self.auth.cleanup(achalls)
for achall in achalls:
self.achalls.remove(achall)
for aauthzr in aauthzrs:
if achall in aauthzr.achalls:
aauthzr.achalls.remove(achall)
break
def verify_authzr_complete(self):
def verify_authzr_complete(self, aauthzrs):
"""Verifies that all authorizations have been decided.
:param aauthzrs: authorizations and their selected annotated
challenges
:type aauthzrs: `list` of `AnnotatedAuthzr`
:returns: Whether all authzr are complete
:rtype: bool
"""
for authzr in self.authzr.values():
for aauthzr in aauthzrs:
authzr = aauthzr.authzr
if (authzr.body.status != messages.STATUS_VALID and
authzr.body.status != messages.STATUS_INVALID):
raise errors.AuthorizationError("Incomplete authorizations")
def _challenge_factory(self, domain, path):
def _challenge_factory(self, authzr, path):
"""Construct Namedtuple Challenges
:param str domain: domain of the enrollee
:param messages.AuthorizationResource authzr: authorization
:param list path: List of indices from `challenges`.
@ -314,8 +342,9 @@ class AuthHandler(object):
achalls = []
for index in path:
challb = self.authzr[domain].body.challenges[index]
achalls.append(challb_to_achall(challb, self.account.key, domain))
challb = authzr.body.challenges[index]
achalls.append(challb_to_achall(
challb, self.account.key, authzr.body.identifier.value))
return achalls
@ -408,7 +437,7 @@ def _find_smart_path(challbs, preferences, combinations):
combo_total = 0
if not best_combo:
_report_no_chall_path()
_report_no_chall_path(challbs)
return best_combo
@ -429,15 +458,23 @@ def _find_dumb_path(challbs, preferences):
if supported:
path.append(i)
else:
_report_no_chall_path()
_report_no_chall_path(challbs)
return path
def _report_no_chall_path():
"""Logs and raises an error that no satisfiable chall path exists."""
def _report_no_chall_path(challbs):
"""Logs and raises an error that no satisfiable chall path exists.
:param challbs: challenges from the authorization that can't be satisfied
"""
msg = ("Client with the currently selected authenticator does not support "
"any combination of challenges that will satisfy the CA.")
if len(challbs) == 1 and isinstance(challbs[0].chall, challenges.DNS01):
msg += (
" You may need to use an authenticator "
"plugin that can do challenges over DNS.")
logger.fatal(msg)
raise errors.AuthorizationError(msg)

View file

@ -607,6 +607,11 @@ class HelpfulArgumentParser(object):
if parsed_args.validate_hooks:
hooks.validate_hooks(parsed_args)
if parsed_args.allow_subset_of_names:
if any(util.is_wildcard_domain(d) for d in parsed_args.domains):
raise errors.Error("Using --allow-subset-of-names with a"
" wildcard domain is not supported.")
possible_deprecation_warning(parsed_args)
return parsed_args

View file

@ -1,4 +1,5 @@
"""Certbot client API."""
import datetime
import logging
import os
import platform
@ -212,8 +213,8 @@ class Client(object):
:ivar .IAuthenticator auth: Prepared (`.IAuthenticator.prepare`)
authenticator that can solve ACME challenges.
:ivar .IInstaller installer: Installer.
:ivar acme.client.Client acme: Optional ACME client API handle.
You might already have one from `register`.
:ivar acme.client.BackwardsCompatibleClientV2 acme: Optional ACME
client API handle. You might already have one from `register`.
"""
@ -235,21 +236,15 @@ class Client(object):
else:
self.auth_handler = None
def obtain_certificate_from_csr(self, domains, csr, authzr=None):
def obtain_certificate_from_csr(self, csr, orderr=None):
"""Obtain certificate.
Internal function with precondition that `domains` are
consistent with identifiers present in the `csr`.
:param list domains: Domain names.
:param .util.CSR csr: PEM-encoded Certificate Signing
Request. The key used to generate this CSR can be different
than `authkey`.
:param list authzr: List of
:class:`acme.messages.AuthorizationResource`
:param acme.messages.OrderResource orderr: contains authzrs
:returns: `.CertificateResource` and certificate chain (as
returned by `.fetch_chain`).
:returns: certificate and chain as PEM byte strings
:rtype: tuple
"""
@ -261,37 +256,15 @@ class Client(object):
if self.account.regr is None:
raise errors.Error("Please register with the ACME server first.")
logger.debug("CSR: %s, domains: %s", csr, domains)
logger.debug("CSR: %s", csr)
if authzr is None:
authzr = self.auth_handler.get_authorizations(domains)
if orderr is None:
orderr = self._get_order_and_authorizations(csr.data, best_effort=False)
certr = self.acme.request_issuance(
jose.ComparableX509(
OpenSSL.crypto.load_certificate_request(OpenSSL.crypto.FILETYPE_PEM, csr.data)),
authzr)
notify = zope.component.getUtility(interfaces.IDisplay).notification
retries = 0
chain = None
while retries <= 1:
if retries:
notify('Failed to fetch chain, please check your network '
'and continue', pause=True)
try:
chain = self.acme.fetch_chain(certr)
break
except acme_errors.Error:
logger.debug('Failed to fetch chain', exc_info=True)
retries += 1
if chain is None:
raise acme_errors.Error(
'Failed to fetch chain. You should not deploy the generated '
'certificate, please rerun the command for a new one.')
return certr, chain
deadline = datetime.datetime.now() + datetime.timedelta(seconds=90)
orderr = self.acme.finalize_order(orderr, deadline)
cert, chain = crypto_util.cert_and_chain_from_fullchain(orderr.fullchain_pem)
return cert.encode(), chain.encode()
def obtain_certificate(self, domains):
"""Obtains a certificate from the ACME server.
@ -300,20 +273,12 @@ class Client(object):
:param list domains: domains to get a certificate
:returns: `.CertificateResource`, certificate chain (as
returned by `.fetch_chain`), and newly generated private key
(`.util.Key`) and DER-encoded Certificate Signing Request
(`.util.CSR`).
:returns: certificate as PEM string, chain as PEM string,
newly generated private key (`.util.Key`), and DER-encoded
Certificate Signing Request (`.util.CSR`).
:rtype: tuple
"""
authzr = self.auth_handler.get_authorizations(
domains,
self.config.allow_subset_of_names)
auth_domains = set(a.body.identifier.value for a in authzr)
domains = [d for d in domains if d in auth_domains]
# Create CSR from names
if self.config.dry_run:
key = util.Key(file=None,
@ -326,10 +291,44 @@ class Client(object):
self.config.rsa_key_size, self.config.key_dir)
csr = crypto_util.init_save_csr(key, domains, self.config.csr_dir)
certr, chain = self.obtain_certificate_from_csr(
domains, csr, authzr=authzr)
orderr = self._get_order_and_authorizations(csr.data, self.config.allow_subset_of_names)
authzr = orderr.authorizations
auth_domains = set(a.body.identifier.value for a in authzr)
successful_domains = [d for d in domains if d in auth_domains]
return certr, chain, key, csr
# allow_subset_of_names is currently disabled for wildcard
# certificates. The reason for this and checking allow_subset_of_names
# below is because successful_domains == domains is never true if
# domains contains a wildcard because the ACME spec forbids identifiers
# in authzs from containing a wildcard character.
if self.config.allow_subset_of_names and successful_domains != domains:
if not self.config.dry_run:
os.remove(key.file)
os.remove(csr.file)
return self.obtain_certificate(successful_domains)
else:
cert, chain = self.obtain_certificate_from_csr(csr, orderr)
return cert, chain, key, csr
def _get_order_and_authorizations(self, csr_pem, best_effort):
"""Request a new order and complete its authorizations.
:param str csr_pem: A CSR in PEM format.
:param bool best_effort: True if failing to complete all
authorizations should not raise an exception
:returns: order resource containing its completed authorizations
:rtype: acme.messages.OrderResource
"""
try:
orderr = self.acme.new_order(csr_pem)
except acme_errors.WildcardUnsupportedError:
raise errors.Error("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.")
authzr = self.auth_handler.handle_authorizations(orderr, best_effort)
return orderr.update(authorizations=authzr)
# pylint: disable=no-member
def obtain_and_enroll_certificate(self, domains, certname):
@ -348,7 +347,7 @@ class Client(object):
be obtained, or None if doing a successful dry run.
"""
certr, chain, key, _ = self.obtain_certificate(domains)
cert, chain, key, _ = self.obtain_certificate(domains)
if (self.config.config_dir != constants.CLI_DEFAULTS["config_dir"] or
self.config.work_dir != constants.CLI_DEFAULTS["work_dir"]):
@ -356,26 +355,30 @@ class Client(object):
"Non-standard path(s), might not work with crontab installed "
"by your operating system package manager")
new_name = certname if certname else domains[0]
if certname:
new_name = certname
elif util.is_wildcard_domain(domains[0]):
# Don't make files and directories starting with *.
new_name = domains[0][2:]
else:
new_name = domains[0]
if self.config.dry_run:
logger.debug("Dry run: Skipping creating new lineage for %s",
new_name)
return None
else:
return storage.RenewableCert.new_lineage(
new_name, OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped),
key.pem, crypto_util.dump_pyopenssl_chain(chain),
new_name, cert,
key.pem, chain,
self.config)
def save_certificate(self, certr, chain_cert,
def save_certificate(self, cert_pem, chain_pem,
cert_path, chain_path, fullchain_path):
"""Saves the certificate received from the ACME server.
:param certr: ACME "certificate" resource.
:type certr: :class:`acme.messages.Certificate`
:param list chain_cert:
:param str cert_pem:
:param str chain_pem:
:param str cert_path: Candidate path to a certificate.
:param str chain_path: Candidate path to a certificate chain.
:param str fullchain_path: Candidate path to a full cert chain.
@ -392,8 +395,6 @@ class Client(object):
os.path.dirname(path), 0o755, os.geteuid(),
self.config.strict_permissions)
cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped)
cert_file, abs_cert_path = _open_pem_file('cert_path', cert_path)
@ -404,20 +405,15 @@ class Client(object):
logger.info("Server issued certificate; certificate written to %s",
abs_cert_path)
if not chain_cert:
return abs_cert_path, None, None
else:
chain_pem = crypto_util.dump_pyopenssl_chain(chain_cert)
chain_file, abs_chain_path =\
_open_pem_file('chain_path', chain_path)
fullchain_file, abs_fullchain_path =\
_open_pem_file('fullchain_path', fullchain_path)
chain_file, abs_chain_path =\
_open_pem_file('chain_path', chain_path)
fullchain_file, abs_fullchain_path =\
_open_pem_file('fullchain_path', fullchain_path)
_save_chain(chain_pem, chain_file)
_save_chain(cert_pem + chain_pem, fullchain_file)
_save_chain(chain_pem, chain_file)
_save_chain(cert_pem + chain_pem, fullchain_file)
return abs_cert_path, abs_chain_path, abs_fullchain_path
return abs_cert_path, abs_chain_path, abs_fullchain_path
def deploy_certificate(self, domains, privkey_path,
cert_path, chain_path, fullchain_path):

View file

@ -14,7 +14,6 @@ import six
import zope.component
from cryptography.hazmat.backends import default_backend
from cryptography import x509
import josepy as jose
from acme import crypto_util as acme_crypto_util
@ -367,16 +366,7 @@ def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM):
"""
# XXX: returns empty string when no chain is available, which
# shuts up RenewableCert, but might not be the best solution...
def _dump_cert(cert):
if isinstance(cert, jose.ComparableX509):
# pylint: disable=protected-access
cert = cert.wrapped
return OpenSSL.crypto.dump_certificate(filetype, cert)
# assumes that OpenSSL.crypto.dump_certificate includes ending
# newline character
return b"".join(_dump_cert(cert) for cert in chain)
return acme_crypto_util.dump_pyopenssl_chain(chain, filetype)
def notBefore(cert_path):
@ -443,3 +433,17 @@ def sha256sum(filename):
with open(filename, 'rb') as f:
sha256.update(f.read())
return sha256.hexdigest()
def cert_and_chain_from_fullchain(fullchain_pem):
"""Split fullchain_pem into cert_pem and chain_pem
:param str fullchain_pem: concatenated cert + chain
:returns: tuple of string cert_pem and chain_pem
:rtype: tuple
"""
cert = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, fullchain_pem)).decode()
chain = fullchain_pem[len(cert):]
return (cert, chain)

View file

@ -165,12 +165,7 @@ class ColoredStreamHandler(logging.StreamHandler):
"""
def __init__(self, stream=None):
# logging handlers use old style classes in Python 2.6 so
# super() cannot be used
if sys.version_info < (2, 7): # pragma: no cover
logging.StreamHandler.__init__(self, stream)
else:
super(ColoredStreamHandler, self).__init__(stream)
super(ColoredStreamHandler, self).__init__(stream)
self.colored = (sys.stderr.isatty() if stream is None else
stream.isatty())
self.red_level = logging.WARNING
@ -184,9 +179,7 @@ class ColoredStreamHandler(logging.StreamHandler):
:rtype: str
"""
out = (logging.StreamHandler.format(self, record)
if sys.version_info < (2, 7)
else super(ColoredStreamHandler, self).format(record))
out = super(ColoredStreamHandler, self).format(record)
if self.colored and record.levelno >= self.red_level:
return ''.join((util.ANSI_SGR_RED, out, util.ANSI_SGR_RESET))
else:
@ -203,23 +196,14 @@ class MemoryHandler(logging.handlers.MemoryHandler):
def __init__(self, target=None):
# capacity doesn't matter because should_flush() is overridden
capacity = float('inf')
# logging handlers use old style classes in Python 2.6 so
# super() cannot be used
if sys.version_info < (2, 7): # pragma: no cover
logging.handlers.MemoryHandler.__init__(
self, capacity, target=target)
else:
super(MemoryHandler, self).__init__(capacity, target=target)
super(MemoryHandler, self).__init__(capacity, target=target)
def close(self):
"""Close the memory handler, but don't set the target to None."""
# This allows the logging module which may only have a weak
# reference to the target handler to properly flush and close it.
target = self.target
if sys.version_info < (2, 7): # pragma: no cover
logging.handlers.MemoryHandler.close(self)
else:
super(MemoryHandler, self).close()
super(MemoryHandler, self).close()
self.target = target
def flush(self, force=False): # pylint: disable=arguments-differ
@ -233,10 +217,7 @@ class MemoryHandler(logging.handlers.MemoryHandler):
# This method allows flush() calls in logging.shutdown to be a
# noop so we can control when this handler is flushed.
if force:
if sys.version_info < (2, 7): # pragma: no cover
logging.handlers.MemoryHandler.flush(self)
else:
super(MemoryHandler, self).flush()
super(MemoryHandler, self).flush()
def shouldFlush(self, record):
"""Should the buffer be automatically flushed?
@ -262,12 +243,7 @@ class TempHandler(logging.StreamHandler):
"""
def __init__(self):
stream = tempfile.NamedTemporaryFile('w', delete=False)
# logging handlers use old style classes in Python 2.6 so
# super() cannot be used
if sys.version_info < (2, 7): # pragma: no cover
logging.StreamHandler.__init__(self, stream)
else:
super(TempHandler, self).__init__(stream)
super(TempHandler, self).__init__(stream)
self.path = stream.name
self._delete = True
@ -278,12 +254,7 @@ class TempHandler(logging.StreamHandler):
"""
self._delete = False
# logging handlers use old style classes in Python 2.6 so
# super() cannot be used
if sys.version_info < (2, 7): # pragma: no cover
logging.StreamHandler.emit(self, record)
else:
super(TempHandler, self).emit(record)
super(TempHandler, self).emit(record)
def close(self):
"""Close the handler and the temporary log file.
@ -299,10 +270,7 @@ class TempHandler(logging.StreamHandler):
if self._delete:
os.remove(self.path)
self._delete = False
if sys.version_info < (2, 7): # pragma: no cover
logging.StreamHandler.close(self)
else:
super(TempHandler, self).close()
super(TempHandler, self).close()
finally:
self.release()

View file

@ -5,7 +5,6 @@ import functools
import logging.handlers
import os
import sys
import warnings
import configobj
import josepy as jose
@ -1016,11 +1015,11 @@ def revoke(config, unused_plugins): # TODO: coop with renewal config
config.cert_path[0], config.key_path[0])
crypto_util.verify_cert_matches_priv_key(config.cert_path[0], config.key_path[0])
key = jose.JWK.load(config.key_path[1])
acme = client.acme_from_config_key(config, key)
else: # revocation by account key
logger.debug("Revoking %s using Account Key", config.cert_path[0])
acc, _ = _determine_account(config)
key = acc.key
acme = client.acme_from_config_key(config, key)
acme = client.acme_from_config_key(config, acc.key, acc.regr)
cert = crypto_util.pyopenssl_load_certificate(config.cert_path[1])[0]
logger.debug("Reason code for revocation: %s", config.reason)
@ -1097,13 +1096,13 @@ def _csr_get_and_save_cert(config, le_client):
"""
csr, _ = config.actual_csr
certr, chain = le_client.obtain_certificate_from_csr(config.domains, csr)
cert, chain = le_client.obtain_certificate_from_csr(csr)
if config.dry_run:
logger.debug(
"Dry run: skipping saving certificate to %s", config.cert_path)
return None, None
cert_path, _, fullchain_path = le_client.save_certificate(
certr, chain, config.cert_path, config.chain_path, config.fullchain_path)
cert, chain, config.cert_path, config.chain_path, config.fullchain_path)
return cert_path, fullchain_path
def renew_cert(config, plugins, lineage):
@ -1289,17 +1288,6 @@ def main(cli_args=sys.argv[1:]):
# Let plugins_cmd be run as un-privileged user.
if config.func != plugins_cmd:
raise
deprecation_fmt = (
"Python %s.%s support will be dropped in the next "
"release of Certbot - please upgrade your Python version.")
# We use the warnings system for Python 2.6 and logging for Python 3
# because DeprecationWarnings are only reported by default in Python <= 2.6
# and warnings can be disabled by the user.
if sys.version_info[:2] == (2, 6):
warning = deprecation_fmt % sys.version_info[:2]
warnings.warn(warning, DeprecationWarning)
elif sys.version_info[:2] == (3, 3):
logger.warning(deprecation_fmt, *sys.version_info[:2])
set_displayer(config)

View file

@ -5,6 +5,8 @@ import logging
import pkg_resources
import six
from collections import OrderedDict
import zope.interface
import zope.interface.verify
@ -12,12 +14,6 @@ from certbot import constants
from certbot import errors
from certbot import interfaces
try:
from collections import OrderedDict
except ImportError: # pragma: no cover
# OrderedDict was added in Python 2.7
from ordereddict import OrderedDict # pylint: disable=import-error
logger = logging.getLogger(__name__)
@ -194,6 +190,7 @@ class PluginsRegistry(collections.Mapping):
def find_all(cls):
"""Find plugins using setuptools entry points."""
plugins = {}
# pylint: disable=not-callable
entry_points = itertools.chain(
pkg_resources.iter_entry_points(
constants.SETUPTOOLS_PLUGINS_ENTRY_POINT),

View file

@ -189,7 +189,7 @@ when it receives a TLS ClientHello with the SNI extension set to
os.environ.update(env)
_, out = hooks.execute(self.conf('auth-hook'))
env['CERTBOT_AUTH_OUTPUT'] = out.strip()
self.env[achall.domain] = env
self.env[achall] = env
def _perform_achall_manually(self, achall):
validation = achall.validation(achall.account_key)
@ -215,7 +215,7 @@ when it receives a TLS ClientHello with the SNI extension set to
def cleanup(self, achalls): # pylint: disable=missing-docstring
if self.conf('cleanup-hook'):
for achall in achalls:
env = self.env.pop(achall.domain)
env = self.env.pop(achall)
if 'CERTBOT_TOKEN' not in env:
os.environ.pop('CERTBOT_TOKEN', None)
os.environ.update(env)

View file

@ -93,10 +93,10 @@ class AuthenticatorTest(test_util.TempDirTestCase):
self.auth.perform(self.achalls),
[achall.response(achall.account_key) for achall in self.achalls])
self.assertEqual(
self.auth.env[self.dns_achall.domain]['CERTBOT_AUTH_OUTPUT'],
self.auth.env[self.dns_achall]['CERTBOT_AUTH_OUTPUT'],
dns_expected)
self.assertEqual(
self.auth.env[self.http_achall.domain]['CERTBOT_AUTH_OUTPUT'],
self.auth.env[self.http_achall]['CERTBOT_AUTH_OUTPUT'],
http_expected)
# tls_sni_01 challenge must be perform()ed above before we can
# get the cert_path and key_path.
@ -107,7 +107,7 @@ class AuthenticatorTest(test_util.TempDirTestCase):
self.auth.tls_sni_01.get_z_domain(self.tls_sni_achall),
'novalidation')
self.assertEqual(
self.auth.env[self.tls_sni_achall.domain]['CERTBOT_AUTH_OUTPUT'],
self.auth.env[self.tls_sni_achall]['CERTBOT_AUTH_OUTPUT'],
tls_sni_expected)
@test_util.patch_get_utility()

View file

@ -294,15 +294,12 @@ def renew_cert(config, domains, le_client, lineage):
_avoid_invalidating_lineage(config, lineage, original_server)
if not domains:
domains = lineage.names()
new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains)
new_cert, new_chain, new_key, _ = le_client.obtain_certificate(domains)
if config.dry_run:
logger.debug("Dry run: skipping updating lineage at %s",
os.path.dirname(lineage.cert))
else:
prior_version = lineage.latest_common_version()
new_cert = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, new_certr.body.wrapped)
new_chain = crypto_util.dump_pyopenssl_chain(new_chain)
# TODO: Check return value of save_successor
lineage.save_successor(prior_version, new_cert, new_key.pem, new_chain, config)
lineage.update_all_links_to(lineage.latest_common_version())

View file

@ -29,36 +29,35 @@ class ChallengeFactoryTest(unittest.TestCase):
# Account is mocked...
self.handler = AuthHandler(None, None, mock.Mock(key="mock_key"), [])
self.dom = "test"
self.handler.authzr[self.dom] = acme_util.gen_authzr(
messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES,
self.authzr = acme_util.gen_authzr(
messages.STATUS_PENDING, "test", acme_util.CHALLENGES,
[messages.STATUS_PENDING] * 6, False)
def test_all(self):
achalls = self.handler._challenge_factory(
self.dom, range(0, len(acme_util.CHALLENGES)))
self.authzr, range(0, len(acme_util.CHALLENGES)))
self.assertEqual(
[achall.chall for achall in achalls], acme_util.CHALLENGES)
def test_one_tls_sni(self):
achalls = self.handler._challenge_factory(self.dom, [1])
achalls = self.handler._challenge_factory(self.authzr, [1])
self.assertEqual(
[achall.chall for achall in achalls], [acme_util.TLSSNI01])
def test_unrecognized(self):
self.handler.authzr["failure.com"] = acme_util.gen_authzr(
messages.STATUS_PENDING, "failure.com",
[mock.Mock(chall="chall", typ="unrecognized")],
[messages.STATUS_PENDING])
authzr = acme_util.gen_authzr(
messages.STATUS_PENDING, "test",
[mock.Mock(chall="chall", typ="unrecognized")],
[messages.STATUS_PENDING])
self.assertRaises(
errors.Error, self.handler._challenge_factory, "failure.com", [0])
errors.Error, self.handler._challenge_factory, authzr, [0])
class GetAuthorizationsTest(unittest.TestCase):
"""get_authorizations test.
class HandleAuthorizationsTest(unittest.TestCase):
"""handle_authorizations test.
This tests everything except for all functions under _poll_challenges.
@ -81,6 +80,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.mock_account = mock.Mock(key=util.Key("file_path", "PEM"))
self.mock_net = mock.MagicMock(spec=acme_client.Client)
self.mock_net.acme_version = 1
self.handler = AuthHandler(
self.mock_auth, self.mock_net, self.mock_account, [])
@ -90,20 +90,19 @@ class GetAuthorizationsTest(unittest.TestCase):
def tearDown(self):
logging.disable(logging.NOTSET)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_name1_tls_sni_01_1(self, mock_poll):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
def _test_name1_tls_sni_01_1_common(self, combos):
authzr = gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES, combos=combos)
mock_order = mock.MagicMock(authorizations=[authzr])
mock_poll.side_effect = self._validate_all
authzr = self.handler.get_authorizations(["0"])
with mock.patch("certbot.auth_handler.AuthHandler._poll_challenges") as mock_poll:
mock_poll.side_effect = self._validate_all
authzr = self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_net.answer_challenge.call_count, 1)
self.assertEqual(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][0]
self.assertEqual(list(six.iterkeys(chall_update)), ["0"])
chall_update = mock_poll.call_args[0][1]
self.assertEqual(list(six.iterkeys(chall_update)), [0])
self.assertEqual(len(chall_update.values()), 1)
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
@ -113,22 +112,28 @@ class GetAuthorizationsTest(unittest.TestCase):
self.assertEqual(len(authzr), 1)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_name1_tls_sni_01_1_http_01_1_dns_1(self, mock_poll):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES, combos=False)
def test_name1_tls_sni_01_1_acme_1(self):
self._test_name1_tls_sni_01_1_common(combos=True)
def test_name1_tls_sni_01_1_acme_2(self):
self.mock_net.acme_version = 2
self._test_name1_tls_sni_01_1_common(combos=False)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_name1_tls_sni_01_1_http_01_1_dns_1_acme_1(self, mock_poll):
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.DNS01)
authzr = self.handler.get_authorizations(["0"])
authzr = gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES, combos=False)
mock_order = mock.MagicMock(authorizations=[authzr])
authzr = self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_net.answer_challenge.call_count, 3)
self.assertEqual(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][0]
self.assertEqual(list(six.iterkeys(chall_update)), ["0"])
chall_update = mock_poll.call_args[0][1]
self.assertEqual(list(six.iterkeys(chall_update)), [0])
self.assertEqual(len(chall_update.values()), 1)
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
@ -140,89 +145,149 @@ class GetAuthorizationsTest(unittest.TestCase):
self.assertEqual(len(authzr), 1)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_name3_tls_sni_01_3(self, mock_poll):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
def test_name1_tls_sni_01_1_http_01_1_dns_1_acme_2(self, mock_poll):
self.mock_net.acme_version = 2
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.DNS01)
authzr = self.handler.get_authorizations(["0", "1", "2"])
authzr = gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES, combos=False)
mock_order = mock.MagicMock(authorizations=[authzr])
authzr = self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_net.answer_challenge.call_count, 1)
self.assertEqual(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][1]
self.assertEqual(list(six.iterkeys(chall_update)), [0])
self.assertEqual(len(chall_update.values()), 1)
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
cleaned_up_achalls = self.mock_auth.cleanup.call_args[0][0]
self.assertEqual(len(cleaned_up_achalls), 1)
self.assertEqual(cleaned_up_achalls[0].typ, "tls-sni-01")
# Length of authorizations list
self.assertEqual(len(authzr), 1)
def _test_name3_tls_sni_01_3_common(self, combos):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES, combos=combos)
authzrs = [gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES),
gen_dom_authzr(domain="1", challs=acme_util.CHALLENGES),
gen_dom_authzr(domain="2", challs=acme_util.CHALLENGES)]
mock_order = mock.MagicMock(authorizations=authzrs)
with mock.patch("certbot.auth_handler.AuthHandler._poll_challenges") as mock_poll:
mock_poll.side_effect = self._validate_all
authzr = self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_net.answer_challenge.call_count, 3)
# Check poll call
self.assertEqual(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][0]
chall_update = mock_poll.call_args[0][1]
self.assertEqual(len(list(six.iterkeys(chall_update))), 3)
self.assertTrue("0" in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update["0"]), 1)
self.assertTrue("1" in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update["1"]), 1)
self.assertTrue("2" in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update["2"]), 1)
self.assertTrue(0 in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update[0]), 1)
self.assertTrue(1 in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update[1]), 1)
self.assertTrue(2 in list(six.iterkeys(chall_update)))
self.assertEqual(len(chall_update[2]), 1)
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
self.assertEqual(len(authzr), 3)
def test_name3_tls_sni_01_3_common_acme_1(self):
self._test_name3_tls_sni_01_3_common(combos=True)
def test_name3_tls_sni_01_3_common_acme_2(self):
self.mock_net.acme_version = 2
self._test_name3_tls_sni_01_3_common(combos=False)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_debug_challenges(self, mock_poll):
zope.component.provideUtility(
mock.Mock(debug_challenges=True), interfaces.IConfig)
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
authzrs = [gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES)]
mock_order = mock.MagicMock(authorizations=authzrs)
mock_poll.side_effect = self._validate_all
self.handler.get_authorizations(["0"])
self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_net.answer_challenge.call_count, 1)
self.assertEqual(self.mock_display.notification.call_count, 1)
def test_perform_failure(self):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
authzrs = [gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES)]
mock_order = mock.MagicMock(authorizations=authzrs)
self.mock_auth.perform.side_effect = errors.AuthorizationError
self.assertRaises(
errors.AuthorizationError, self.handler.get_authorizations, ["0"])
errors.AuthorizationError, self.handler.handle_authorizations, mock_order)
def test_no_domains(self):
self.assertRaises(errors.AuthorizationError, self.handler.get_authorizations, [])
mock_order = mock.MagicMock(authorizations=[])
self.assertRaises(errors.AuthorizationError, self.handler.handle_authorizations, mock_order)
@mock.patch("certbot.auth_handler.AuthHandler._poll_challenges")
def test_preferred_challenge_choice(self, mock_poll):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
def _test_preferred_challenge_choice_common(self, combos):
authzrs = [gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES, combos=combos)]
mock_order = mock.MagicMock(authorizations=authzrs)
mock_poll.side_effect = self._validate_all
self.mock_auth.get_chall_pref.return_value.append(challenges.HTTP01)
self.handler.pref_challs.extend((challenges.HTTP01.typ,
challenges.DNS01.typ,))
self.handler.get_authorizations(["0"])
with mock.patch("certbot.auth_handler.AuthHandler._poll_challenges") as mock_poll:
mock_poll.side_effect = self._validate_all
self.handler.handle_authorizations(mock_order)
self.assertEqual(self.mock_auth.cleanup.call_count, 1)
self.assertEqual(
self.mock_auth.cleanup.call_args[0][0][0].typ, "http-01")
def test_preferred_challenges_not_supported(self):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
def test_preferred_challenge_choice_common_acme_1(self):
self._test_preferred_challenge_choice_common(combos=True)
def test_preferred_challenge_choice_common_acme_2(self):
self.mock_net.acme_version = 2
self._test_preferred_challenge_choice_common(combos=False)
def _test_preferred_challenges_not_supported_common(self, combos):
authzrs = [gen_dom_authzr(domain="0", challs=acme_util.CHALLENGES, combos=combos)]
mock_order = mock.MagicMock(authorizations=authzrs)
self.handler.pref_challs.append(challenges.HTTP01.typ)
self.assertRaises(
errors.AuthorizationError, self.handler.get_authorizations, ["0"])
errors.AuthorizationError, self.handler.handle_authorizations, mock_order)
def _validate_all(self, unused_1, unused_2):
for dom in six.iterkeys(self.handler.authzr):
azr = self.handler.authzr[dom]
self.handler.authzr[dom] = acme_util.gen_authzr(
def test_preferred_challenges_not_supported_acme_1(self):
self._test_preferred_challenges_not_supported_common(combos=True)
def test_preferred_challenges_not_supported_acme_2(self):
self.mock_net.acme_version = 2
self._test_preferred_challenges_not_supported_common(combos=False)
def test_dns_only_challenge_not_supported(self):
authzrs = [gen_dom_authzr(domain="0", challs=[acme_util.DNS01])]
mock_order = mock.MagicMock(authorizations=authzrs)
self.assertRaises(
errors.AuthorizationError, self.handler.handle_authorizations, mock_order)
def _validate_all(self, aauthzrs, unused_1, unused_2):
for i, aauthzr in enumerate(aauthzrs):
azr = aauthzr.authzr
updated_azr = acme_util.gen_authzr(
messages.STATUS_VALID,
dom,
azr.body.identifier.value,
[challb.chall for challb in azr.body.challenges],
[messages.STATUS_VALID] * len(azr.body.challenges),
azr.body.combinations)
aauthzrs[i] = type(aauthzr)(updated_azr, aauthzr.achalls)
class PollChallengesTest(unittest.TestCase):
@ -231,7 +296,7 @@ class PollChallengesTest(unittest.TestCase):
def setUp(self):
from certbot.auth_handler import challb_to_achall
from certbot.auth_handler import AuthHandler
from certbot.auth_handler import AuthHandler, AnnotatedAuthzr
# Account and network are mocked...
self.mock_net = mock.MagicMock()
@ -239,40 +304,40 @@ class PollChallengesTest(unittest.TestCase):
None, self.mock_net, mock.Mock(key="mock_key"), [])
self.doms = ["0", "1", "2"]
self.handler.authzr[self.doms[0]] = acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[0],
[acme_util.HTTP01, acme_util.TLSSNI01],
[messages.STATUS_PENDING] * 2, False)
self.handler.authzr[self.doms[1]] = acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[1],
acme_util.CHALLENGES, [messages.STATUS_PENDING] * 3, False)
self.handler.authzr[self.doms[2]] = acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[2],
acme_util.CHALLENGES, [messages.STATUS_PENDING] * 3, False)
self.aauthzrs = [
AnnotatedAuthzr(acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[0],
[acme_util.HTTP01, acme_util.TLSSNI01],
[messages.STATUS_PENDING] * 2, False), []),
AnnotatedAuthzr(acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[1],
acme_util.CHALLENGES, [messages.STATUS_PENDING] * 3, False), []),
AnnotatedAuthzr(acme_util.gen_authzr(
messages.STATUS_PENDING, self.doms[2],
acme_util.CHALLENGES, [messages.STATUS_PENDING] * 3, False), [])
]
self.chall_update = {}
for dom in self.doms:
self.chall_update[dom] = [
challb_to_achall(challb, mock.Mock(key="dummy_key"), dom)
for challb in self.handler.authzr[dom].body.challenges]
for i, aauthzr in enumerate(self.aauthzrs):
self.chall_update[i] = [
challb_to_achall(challb, mock.Mock(key="dummy_key"), self.doms[i])
for challb in aauthzr.authzr.body.challenges]
@mock.patch("certbot.auth_handler.time")
def test_poll_challenges(self, unused_mock_time):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid
self.handler._poll_challenges(self.chall_update, False)
self.handler._poll_challenges(self.aauthzrs, self.chall_update, False)
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages.STATUS_VALID)
for aauthzr in self.aauthzrs:
self.assertEqual(aauthzr.authzr.body.status, messages.STATUS_VALID)
@mock.patch("certbot.auth_handler.time")
def test_poll_challenges_failure_best_effort(self, unused_mock_time):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
self.handler._poll_challenges(self.chall_update, True)
self.handler._poll_challenges(self.aauthzrs, self.chall_update, True)
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages.STATUS_PENDING)
for aauthzr in self.aauthzrs:
self.assertEqual(aauthzr.authzr.body.status, messages.STATUS_PENDING)
@mock.patch("certbot.auth_handler.time")
@test_util.patch_get_utility()
@ -280,21 +345,21 @@ class PollChallengesTest(unittest.TestCase):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
self.assertRaises(
errors.AuthorizationError, self.handler._poll_challenges,
self.chall_update, False)
self.aauthzrs, self.chall_update, False)
@mock.patch("certbot.auth_handler.time")
def test_unable_to_find_challenge_status(self, unused_mock_time):
from certbot.auth_handler import challb_to_achall
self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid
self.chall_update[self.doms[0]].append(
self.chall_update[0].append(
challb_to_achall(acme_util.DNS01_P, "key", self.doms[0]))
self.assertRaises(
errors.AuthorizationError, self.handler._poll_challenges,
self.chall_update, False)
self.aauthzrs, self.chall_update, False)
def test_verify_authzr_failure(self):
self.assertRaises(
errors.AuthorizationError, self.handler.verify_authzr_complete)
self.assertRaises(errors.AuthorizationError,
self.handler.verify_authzr_complete, self.aauthzrs)
def _mock_poll_solve_one_valid(self, authzr):
# Pending here because my dummy script won't change the full status.

View file

@ -426,6 +426,10 @@ class ParseTest(unittest.TestCase): # pylint: disable=too-many-public-methods
namespace = self.parse(["--no-delete-after-revoke"])
self.assertFalse(namespace.delete_after_revoke)
def test_allow_subset_with_wildcard(self):
self.assertRaises(errors.Error, self.parse,
"--allow-subset-of-names -d *.example.org".split())
class DefaultTest(unittest.TestCase):
"""Tests for certbot.cli._Default."""

View file

@ -4,12 +4,8 @@ import shutil
import tempfile
import unittest
import josepy as jose
import OpenSSL
import mock
from acme import errors as acme_errors
from certbot import account
from certbot import errors
from certbot import util
@ -134,6 +130,9 @@ class ClientTest(ClientTestCommon):
self.config.allow_subset_of_names = False
self.config.dry_run = False
self.eg_domains = ["example.com", "www.example.com"]
self.eg_order = mock.MagicMock(
authorizations=[None],
csr_pem=mock.sentinel.csr_pem)
def test_init_acme_verify_ssl(self):
net = self.acme_client.call_args[0][0]
@ -141,99 +140,64 @@ class ClientTest(ClientTestCommon):
def _mock_obtain_certificate(self):
self.client.auth_handler = mock.MagicMock()
self.client.auth_handler.get_authorizations.return_value = [None]
self.acme.request_issuance.return_value = mock.sentinel.certr
self.acme.fetch_chain.return_value = mock.sentinel.chain
self.client.auth_handler.handle_authorizations.return_value = [None]
self.acme.finalize_order.return_value = self.eg_order
self.acme.new_order.return_value = self.eg_order
self.eg_order.update.return_value = self.eg_order
def _check_obtain_certificate(self):
self.client.auth_handler.get_authorizations.assert_called_once_with(
self.eg_domains,
self.config.allow_subset_of_names)
def _check_obtain_certificate(self, auth_count=1):
if auth_count == 1:
self.client.auth_handler.handle_authorizations.assert_called_once_with(
self.eg_order,
self.config.allow_subset_of_names)
else:
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, auth_count)
authzr = self.client.auth_handler.get_authorizations()
self.acme.request_issuance.assert_called_once_with(
jose.ComparableX509(OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, CSR_SAN)),
authzr)
self.acme.fetch_chain.assert_called_once_with(mock.sentinel.certr)
self.acme.finalize_order.assert_called_once_with(
self.eg_order, mock.ANY)
@mock.patch("certbot.client.crypto_util")
@mock.patch("certbot.client.logger")
@test_util.patch_get_utility()
def test_obtain_certificate_from_csr(self, unused_mock_get_utility,
mock_logger):
mock_logger, mock_crypto_util):
self._mock_obtain_certificate()
test_csr = util.CSR(form="pem", file=None, data=CSR_SAN)
auth_handler = self.client.auth_handler
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
authzr = auth_handler.get_authorizations(self.eg_domains, False)
orderr = self.acme.new_order(test_csr.data)
auth_handler.handle_authorizations(orderr, False)
self.assertEqual(
(mock.sentinel.certr, mock.sentinel.chain),
(mock.sentinel.cert, mock.sentinel.chain),
self.client.obtain_certificate_from_csr(
self.eg_domains,
test_csr,
authzr=authzr))
orderr=orderr))
# and that the cert was obtained correctly
self._check_obtain_certificate()
# Test for authzr=None
# Test for orderr=None
self.assertEqual(
(mock.sentinel.certr, mock.sentinel.chain),
(mock.sentinel.cert, mock.sentinel.chain),
self.client.obtain_certificate_from_csr(
self.eg_domains,
test_csr,
authzr=None))
auth_handler.get_authorizations.assert_called_with(self.eg_domains)
orderr=None))
auth_handler.handle_authorizations.assert_called_with(self.eg_order, False)
# Test for no auth_handler
self.client.auth_handler = None
self.assertRaises(
errors.Error,
self.client.obtain_certificate_from_csr,
self.eg_domains,
test_csr)
mock_logger.warning.assert_called_once_with(mock.ANY)
@test_util.patch_get_utility()
def test_obtain_certificate_from_csr_retry_succeeded(
self, mock_get_utility):
self._mock_obtain_certificate()
self.acme.fetch_chain.side_effect = [acme_errors.Error,
mock.sentinel.chain]
test_csr = util.CSR(form="der", file=None, data=CSR_SAN)
auth_handler = self.client.auth_handler
authzr = auth_handler.get_authorizations(self.eg_domains, False)
self.assertEqual(
(mock.sentinel.certr, mock.sentinel.chain),
self.client.obtain_certificate_from_csr(
self.eg_domains,
test_csr,
authzr=authzr))
self.assertEqual(1, mock_get_utility().notification.call_count)
@test_util.patch_get_utility()
def test_obtain_certificate_from_csr_retry_failed(self, mock_get_utility):
self._mock_obtain_certificate()
self.acme.fetch_chain.side_effect = acme_errors.Error
test_csr = util.CSR(form="der", file=None, data=CSR_SAN)
auth_handler = self.client.auth_handler
authzr = auth_handler.get_authorizations(self.eg_domains, False)
self.assertRaises(
acme_errors.Error,
self.client.obtain_certificate_from_csr,
self.eg_domains,
test_csr,
authzr=authzr)
self.assertEqual(1, mock_get_utility().notification.call_count)
@mock.patch("certbot.client.crypto_util")
def test_obtain_certificate(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.init_save_csr.return_value = csr
mock_crypto_util.init_save_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self._test_obtain_certificate_common(mock.sentinel.key, csr)
@ -241,6 +205,26 @@ class ClientTest(ClientTestCommon):
self.config.rsa_key_size, self.config.key_dir)
mock_crypto_util.init_save_csr.assert_called_once_with(
mock.sentinel.key, self.eg_domains, self.config.csr_dir)
mock_crypto_util.cert_and_chain_from_fullchain.assert_called_once_with(
self.eg_order.fullchain_pem)
@mock.patch("certbot.client.crypto_util")
@mock.patch("os.remove")
def test_obtain_certificate_partial_success(self, mock_remove, mock_crypto_util):
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
mock_crypto_util.init_save_csr.return_value = csr
mock_crypto_util.init_save_key.return_value = key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
authzr = self._authzr_from_domains(["example.com"])
self.config.allow_subset_of_names = True
self._test_obtain_certificate_common(key, csr, authzr_ret=authzr, auth_count=2)
self.assertEqual(mock_crypto_util.init_save_key.call_count, 2)
self.assertEqual(mock_crypto_util.init_save_csr.call_count, 2)
self.assertEqual(mock_remove.call_count, 2)
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 1)
@mock.patch("certbot.client.crypto_util")
@mock.patch("certbot.client.acme_crypto_util")
@ -249,6 +233,7 @@ class ClientTest(ClientTestCommon):
mock_acme_crypto.make_csr.return_value = CSR_SAN
mock_crypto.make_key.return_value = mock.sentinel.key_pem
key = util.Key(file=None, pem=mock.sentinel.key_pem)
self._set_mock_from_fullchain(mock_crypto.cert_and_chain_from_fullchain)
self.client.config.dry_run = True
self._test_obtain_certificate_common(key, csr)
@ -258,40 +243,51 @@ class ClientTest(ClientTestCommon):
mock.sentinel.key_pem, self.eg_domains, self.config.must_staple)
mock_crypto.init_save_key.assert_not_called()
mock_crypto.init_save_csr.assert_not_called()
self.assertEqual(mock_crypto.cert_and_chain_from_fullchain.call_count, 1)
def _test_obtain_certificate_common(self, key, csr):
self._mock_obtain_certificate()
# return_value is essentially set to (None, None) in
# _mock_obtain_certificate(), which breaks this test.
# Thus fixed by the next line.
def _set_mock_from_fullchain(self, mock_from_fullchain):
mock_cert = mock.Mock()
mock_cert.encode.return_value = mock.sentinel.cert
mock_chain = mock.Mock()
mock_chain.encode.return_value = mock.sentinel.chain
mock_from_fullchain.return_value = (mock_cert, mock_chain)
def _authzr_from_domains(self, domains):
authzr = []
# domain ordering should not be affected by authorization order
for domain in reversed(self.eg_domains):
for domain in reversed(domains):
authzr.append(
mock.MagicMock(
body=mock.MagicMock(
identifier=mock.MagicMock(
value=domain))))
return authzr
self.client.auth_handler.get_authorizations.return_value = authzr
def _test_obtain_certificate_common(self, key, csr, authzr_ret=None, auth_count=1):
self._mock_obtain_certificate()
# return_value is essentially set to (None, None) in
# _mock_obtain_certificate(), which breaks this test.
# Thus fixed by the next line.
authzr = authzr_ret or self._authzr_from_domains(self.eg_domains)
self.eg_order.authorizations = authzr
self.client.auth_handler.handle_authorizations.return_value = authzr
with test_util.patch_get_utility():
result = self.client.obtain_certificate(self.eg_domains)
self.assertEqual(
result,
(mock.sentinel.certr, mock.sentinel.chain, key, csr))
self._check_obtain_certificate()
(mock.sentinel.cert, mock.sentinel.chain, key, csr))
self._check_obtain_certificate(auth_count)
@mock.patch('certbot.client.Client.obtain_certificate')
@mock.patch('certbot.storage.RenewableCert.new_lineage')
@mock.patch('OpenSSL.crypto.dump_certificate')
def test_obtain_and_enroll_certificate(self, mock_dump_certificate,
def test_obtain_and_enroll_certificate(self,
mock_storage, mock_obtain_certificate):
domains = ["example.com", "www.example.com"]
domains = ["*.example.com", "example.com"]
mock_obtain_certificate.return_value = (mock.MagicMock(),
mock.MagicMock(), mock.MagicMock(), None)
@ -299,13 +295,14 @@ class ClientTest(ClientTestCommon):
self.assertTrue(self.client.obtain_and_enroll_certificate(domains, "example_cert"))
self.assertTrue(self.client.obtain_and_enroll_certificate(domains, None))
self.assertTrue(self.client.obtain_and_enroll_certificate(domains[1:], None))
self.client.config.dry_run = True
self.assertFalse(self.client.obtain_and_enroll_certificate(domains, None))
self.assertTrue(mock_storage.call_count == 2)
self.assertTrue(mock_dump_certificate.call_count == 2)
names = [call[0][0] for call in mock_storage.call_args_list]
self.assertEqual(names, ["example_cert", "example.com", "example.com"])
@mock.patch("certbot.cli.helpful_parser")
def test_save_certificate(self, mock_parser):
@ -314,9 +311,8 @@ class ClientTest(ClientTestCommon):
tmp_path = tempfile.mkdtemp()
os.chmod(tmp_path, 0o755) # TODO: really??
certr = mock.MagicMock(body=test_util.load_comparable_cert(certs[0]))
chain_cert = [test_util.load_comparable_cert(certs[0]),
test_util.load_comparable_cert(certs[1])]
cert_pem = test_util.load_vector(certs[0])
chain_pem = (test_util.load_vector(certs[0]) + test_util.load_vector(certs[1]))
candidate_cert_path = os.path.join(tmp_path, "certs", "cert_512.pem")
candidate_chain_path = os.path.join(tmp_path, "chains", "chain.pem")
candidate_fullchain_path = os.path.join(tmp_path, "chains", "fullchain.pem")
@ -326,7 +322,7 @@ class ClientTest(ClientTestCommon):
"--fullchain-path", candidate_fullchain_path]
cert_path, chain_path, fullchain_path = self.client.save_certificate(
certr, chain_cert, candidate_cert_path, candidate_chain_path,
cert_pem, chain_pem, candidate_cert_path, candidate_chain_path,
candidate_fullchain_path)
self.assertEqual(os.path.dirname(cert_path),

View file

@ -373,5 +373,18 @@ class Sha256sumTest(unittest.TestCase):
'914ffed8daf9e2c99d90ac95c77d54f32cbd556672facac380f0c063498df84e')
class CertAndChainFromFullchainTest(unittest.TestCase):
"""Tests for certbot.crypto_util.cert_and_chain_from_fullchain"""
def test_cert_and_chain_from_fullchain(self):
cert_pem = CERT.decode()
chain_pem = cert_pem + SS_CERT.decode()
fullchain_pem = cert_pem + chain_pem
from certbot.crypto_util import cert_and_chain_from_fullchain
cert_out, chain_out = cert_and_chain_from_fullchain(fullchain_pem)
self.assertEqual(cert_out, cert_pem)
self.assertEqual(chain_out, chain_pem)
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -300,8 +300,8 @@ class ChooseNamesTest(unittest.TestCase):
from certbot.display.ops import get_valid_domains
all_valid = ["example.com", "second.example.com",
"also.example.com", "under_score.example.com",
"justtld"]
all_invalid = ["öóòps.net", "*.wildcard.com", "uniçodé.com"]
"justtld", "*.wildcard.com"]
all_invalid = ["öóòps.net", "uniçodé.com"]
two_valid = ["example.com", "úniçøde.com", "also.example.com"]
self.assertEqual(get_valid_domains(all_valid), all_valid)
self.assertEqual(get_valid_domains(all_invalid), [])

View file

@ -1,3 +1,4 @@
# coding=utf-8
"""Tests for certbot.main."""
# pylint: disable=too-many-lines
from __future__ import print_function
@ -939,11 +940,6 @@ class MainTest(test_util.ConfigTestCase): # pylint: disable=too-many-public-met
self.assertRaises(errors.ConfigurationError,
self._call,
['-d', (('a' * 50) + '.') * 10])
# Wildcard
self.assertRaises(errors.ConfigurationError,
self._call,
['-d', '*.wildcard.tld'])
# Bare IP address (this is actually a different error message now)
self.assertRaises(errors.ConfigurationError,
self._call,
@ -1232,7 +1228,7 @@ class MainTest(test_util.ConfigTestCase): # pylint: disable=too-many-public-met
def test_renew_with_bad_domain(self):
renewalparams = {'authenticator': 'webroot'}
names = ['*.example.com']
names = ['uniçodé.com']
self._test_renew_common(renewalparams=renewalparams, error_expected=True,
names=names, assert_oc_called=False)

View file

@ -57,11 +57,6 @@ def load_cert(*names):
return OpenSSL.crypto.load_certificate(loader, load_vector(*names))
def load_comparable_cert(*names):
"""Load ComparableX509 cert."""
return jose.ComparableX509(load_cert(*names))
def load_csr(*names):
"""Load certificate request."""
loader = _guess_loader(

View file

@ -487,6 +487,26 @@ class EnforceDomainSanityTest(unittest.TestCase):
self._call('this.is.xn--ls8h.tld')
class IsWildcardDomainTest(unittest.TestCase):
"""Tests for is_wildcard_domain."""
def setUp(self):
self.wildcard = u"*.example.org"
self.no_wildcard = u"example.org"
def _call(self, domain):
from certbot.util import is_wildcard_domain
return is_wildcard_domain(domain)
def test_no_wildcard(self):
self.assertFalse(self._call(self.no_wildcard))
self.assertFalse(self._call(self.no_wildcard.encode()))
def test_wildcard(self):
self.assertTrue(self._call(self.wildcard))
self.assertTrue(self._call(self.wildcard.encode()))
class OsInfoTest(unittest.TestCase):
"""Test OS / distribution detection"""

View file

@ -16,18 +16,14 @@ import stat
import subprocess
import sys
from collections import OrderedDict
import configargparse
from certbot import constants
from certbot import errors
from certbot import lock
try:
from collections import OrderedDict
except ImportError: # pragma: no cover
# OrderedDict was added in Python 2.7
from ordereddict import OrderedDict # pylint: disable=import-error
logger = logging.getLogger(__name__)
@ -552,16 +548,6 @@ def enforce_domain_sanity(domain):
:returns: The domain cast to `str`, with ASCII-only contents
:rtype: str
"""
if isinstance(domain, six.text_type):
wildcard_marker = u"*."
else:
wildcard_marker = b"*."
# Check if there's a wildcard domain
if domain.startswith(wildcard_marker):
raise errors.ConfigurationError(
"Wildcard domains are not supported: {0}".format(domain))
# Unicode
try:
if isinstance(domain, six.binary_type):
@ -615,6 +601,24 @@ def enforce_domain_sanity(domain):
return domain
def is_wildcard_domain(domain):
""""Is domain a wildcard domain?
:param damain: domain to check
:type domain: `bytes` or `str` or `unicode`
:returns: True if domain is a wildcard, otherwise, False
:rtype: bool
"""
if isinstance(domain, six.text_type):
wildcard_marker = u"*."
else:
wildcard_marker = b"*."
return domain.startswith(wildcard_marker)
def get_strict_version(normalized):
"""Converts a normalized version to a strict version.

View file

@ -422,7 +422,7 @@ OS-level dependencies can be installed like so:
In general...
* ``sudo`` is required as a suggested way of running privileged process
* `Python`_ 2.6/2.7 is required
* `Python`_ 2.7 is required
* `Augeas`_ is required for the Python bindings
* ``virtualenv`` and ``pip`` are used for managing other python library
dependencies

View file

@ -22,7 +22,7 @@ your system.
System Requirements
===================
Certbot currently requires Python 2.6, 2.7, or 3.3+. By default, it requires
Certbot currently requires Python 2.7, or 3.4+. By default, it requires
root access in order to write to ``/etc/letsencrypt``,
``/var/log/letsencrypt``, ``/var/lib/letsencrypt``; to bind to ports 80 and 443
(if you use the ``standalone`` plugin) and to read and modify webserver

View file

@ -249,7 +249,7 @@ DeprecationBootstrap() {
fi
}
MIN_PYTHON_VERSION="2.6"
MIN_PYTHON_VERSION="2.7"
MIN_PYVER=$(echo "$MIN_PYTHON_VERSION" | sed 's/\.//')
# Sets LE_PYTHON to Python version string and PYVER to the first two
# digits of the python version
@ -1216,7 +1216,7 @@ UNLIKELY_EOF
# -------------------------------------------------------------------------
cat << "UNLIKELY_EOF" > "$TEMP_DIR/pipstrap.py"
#!/usr/bin/env python
"""A small script that can act as a trust root for installing pip 8
"""A small script that can act as a trust root for installing pip >=8
Embed this in your project, and your VCS checkout is all you have to trust. In
a post-peep era, this lets you claw your way to a hash-checking version of pip,
@ -1240,6 +1240,7 @@ anything goes wrong, it will exit with a non-zero status code.
from __future__ import print_function
from distutils.version import StrictVersion
from hashlib import sha256
from os import environ
from os.path import join
from pipes import quote
from shutil import rmtree
@ -1273,14 +1274,14 @@ except ImportError:
from urllib.parse import urlparse # 3.4
__version__ = 1, 3, 0
__version__ = 1, 5, 1
PIP_VERSION = '9.0.1'
DEFAULT_INDEX_BASE = 'https://pypi.python.org'
# wheel has a conditional dependency on argparse:
maybe_argparse = (
[('https://pypi.python.org/packages/18/dd/'
'e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/'
[('18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/'
'argparse-1.4.0.tar.gz',
'62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4')]
if version_info < (2, 7, 0) else [])
@ -1288,18 +1289,14 @@ maybe_argparse = (
PACKAGES = maybe_argparse + [
# Pip has no dependencies, as it vendors everything:
('https://pypi.python.org/packages/11/b6/'
'abcb525026a4be042b486df43905d6893fb04f05aac21c32c638e939e447/'
'pip-{0}.tar.gz'
.format(PIP_VERSION),
('11/b6/abcb525026a4be042b486df43905d6893fb04f05aac21c32c638e939e447/'
'pip-{0}.tar.gz'.format(PIP_VERSION),
'09f243e1a7b461f654c26a725fa373211bb7ff17a9300058b205c61658ca940d'),
# This version of setuptools has only optional dependencies:
('https://pypi.python.org/packages/69/65/'
'4c544cde88d4d876cdf5cbc5f3f15d02646477756d89547e9a7ecd6afa76/'
'setuptools-20.2.2.tar.gz',
'24fcfc15364a9fe09a220f37d2dcedc849795e3de3e4b393ee988e66a9cbd85a'),
('https://pypi.python.org/packages/c9/1d/'
'bd19e691fd4cfe908c76c429fe6e4436c9e83583c4414b54f6c85471954a/'
('59/88/2f3990916931a5de6fa9706d6d75eb32ee8b78627bb2abaab7ed9e6d0622/'
'setuptools-29.0.1.tar.gz',
'b539118819a4857378398891fa5366e090690e46b3e41421a1e07d6e9fd8feb0'),
('c9/1d/bd19e691fd4cfe908c76c429fe6e4436c9e83583c4414b54f6c85471954a/'
'wheel-0.29.0.tar.gz',
'1ebb8ad7e26b448e9caa4773d2357849bf80ff9e313964bcaf79cbf0201a1648')
]
@ -1320,12 +1317,13 @@ def hashed_download(url, temp, digest):
# >=2.7.9 verifies HTTPS certs itself, and, in any case, the cert
# authenticity has only privacy (not arbitrary code execution)
# implications, since we're checking hashes.
def opener():
def opener(using_https=True):
opener = build_opener(HTTPSHandler())
# Strip out HTTPHandler to prevent MITM spoof:
for handler in opener.handlers:
if isinstance(handler, HTTPHandler):
opener.handlers.remove(handler)
if using_https:
# Strip out HTTPHandler to prevent MITM spoof:
for handler in opener.handlers:
if isinstance(handler, HTTPHandler):
opener.handlers.remove(handler)
return opener
def read_chunks(response, chunk_size):
@ -1335,8 +1333,9 @@ def hashed_download(url, temp, digest):
break
yield chunk
response = opener().open(url)
path = join(temp, urlparse(url).path.split('/')[-1])
parsed_url = urlparse(url)
response = opener(using_https=parsed_url.scheme == 'https').open(url)
path = join(temp, parsed_url.path.split('/')[-1])
actual_hash = sha256()
with open(path, 'wb') as file:
for chunk in read_chunks(response, 4096):
@ -1349,6 +1348,24 @@ def hashed_download(url, temp, digest):
return path
def get_index_base():
"""Return the URL to the dir containing the "packages" folder.
Try to wring something out of PIP_INDEX_URL, if set. Hack "/simple" off the
end if it's there; that is likely to give us the right dir.
"""
env_var = environ.get('PIP_INDEX_URL', '').rstrip('/')
if env_var:
SIMPLE = '/simple'
if env_var.endswith(SIMPLE):
return env_var[:-len(SIMPLE)]
else:
return env_var
else:
return DEFAULT_INDEX_BASE
def main():
pip_version = StrictVersion(check_output(['pip', '--version'])
.decode('utf-8').split()[1])
@ -1356,11 +1373,13 @@ def main():
if pip_version >= min_pip_version:
return 0
has_pip_cache = pip_version >= StrictVersion('6.0')
index_base = get_index_base()
temp = mkdtemp(prefix='pipstrap-')
try:
downloads = [hashed_download(url, temp, digest)
for url, digest in PACKAGES]
downloads = [hashed_download(index_base + '/packages/' + path,
temp,
digest)
for path, digest in PACKAGES]
check_output('pip install --no-index --no-deps -U ' +
# Disable cache since we're not using it and it otherwise
# sometimes throws permission warnings:

View file

@ -249,7 +249,7 @@ DeprecationBootstrap() {
fi
}
MIN_PYTHON_VERSION="2.6"
MIN_PYTHON_VERSION="2.7"
MIN_PYVER=$(echo "$MIN_PYTHON_VERSION" | sed 's/\.//')
# Sets LE_PYTHON to Python version string and PYVER to the first two
# digits of the python version

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python
"""A small script that can act as a trust root for installing pip 8
"""A small script that can act as a trust root for installing pip >=8
Embed this in your project, and your VCS checkout is all you have to trust. In
a post-peep era, this lets you claw your way to a hash-checking version of pip,
@ -23,6 +23,7 @@ anything goes wrong, it will exit with a non-zero status code.
from __future__ import print_function
from distutils.version import StrictVersion
from hashlib import sha256
from os import environ
from os.path import join
from pipes import quote
from shutil import rmtree
@ -56,14 +57,14 @@ except ImportError:
from urllib.parse import urlparse # 3.4
__version__ = 1, 3, 0
__version__ = 1, 5, 1
PIP_VERSION = '9.0.1'
DEFAULT_INDEX_BASE = 'https://pypi.python.org'
# wheel has a conditional dependency on argparse:
maybe_argparse = (
[('https://pypi.python.org/packages/18/dd/'
'e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/'
[('18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/'
'argparse-1.4.0.tar.gz',
'62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4')]
if version_info < (2, 7, 0) else [])
@ -71,18 +72,14 @@ maybe_argparse = (
PACKAGES = maybe_argparse + [
# Pip has no dependencies, as it vendors everything:
('https://pypi.python.org/packages/11/b6/'
'abcb525026a4be042b486df43905d6893fb04f05aac21c32c638e939e447/'
'pip-{0}.tar.gz'
.format(PIP_VERSION),
('11/b6/abcb525026a4be042b486df43905d6893fb04f05aac21c32c638e939e447/'
'pip-{0}.tar.gz'.format(PIP_VERSION),
'09f243e1a7b461f654c26a725fa373211bb7ff17a9300058b205c61658ca940d'),
# This version of setuptools has only optional dependencies:
('https://pypi.python.org/packages/69/65/'
'4c544cde88d4d876cdf5cbc5f3f15d02646477756d89547e9a7ecd6afa76/'
'setuptools-20.2.2.tar.gz',
'24fcfc15364a9fe09a220f37d2dcedc849795e3de3e4b393ee988e66a9cbd85a'),
('https://pypi.python.org/packages/c9/1d/'
'bd19e691fd4cfe908c76c429fe6e4436c9e83583c4414b54f6c85471954a/'
('59/88/2f3990916931a5de6fa9706d6d75eb32ee8b78627bb2abaab7ed9e6d0622/'
'setuptools-29.0.1.tar.gz',
'b539118819a4857378398891fa5366e090690e46b3e41421a1e07d6e9fd8feb0'),
('c9/1d/bd19e691fd4cfe908c76c429fe6e4436c9e83583c4414b54f6c85471954a/'
'wheel-0.29.0.tar.gz',
'1ebb8ad7e26b448e9caa4773d2357849bf80ff9e313964bcaf79cbf0201a1648')
]
@ -103,12 +100,13 @@ def hashed_download(url, temp, digest):
# >=2.7.9 verifies HTTPS certs itself, and, in any case, the cert
# authenticity has only privacy (not arbitrary code execution)
# implications, since we're checking hashes.
def opener():
def opener(using_https=True):
opener = build_opener(HTTPSHandler())
# Strip out HTTPHandler to prevent MITM spoof:
for handler in opener.handlers:
if isinstance(handler, HTTPHandler):
opener.handlers.remove(handler)
if using_https:
# Strip out HTTPHandler to prevent MITM spoof:
for handler in opener.handlers:
if isinstance(handler, HTTPHandler):
opener.handlers.remove(handler)
return opener
def read_chunks(response, chunk_size):
@ -118,8 +116,9 @@ def hashed_download(url, temp, digest):
break
yield chunk
response = opener().open(url)
path = join(temp, urlparse(url).path.split('/')[-1])
parsed_url = urlparse(url)
response = opener(using_https=parsed_url.scheme == 'https').open(url)
path = join(temp, parsed_url.path.split('/')[-1])
actual_hash = sha256()
with open(path, 'wb') as file:
for chunk in read_chunks(response, 4096):
@ -132,6 +131,24 @@ def hashed_download(url, temp, digest):
return path
def get_index_base():
"""Return the URL to the dir containing the "packages" folder.
Try to wring something out of PIP_INDEX_URL, if set. Hack "/simple" off the
end if it's there; that is likely to give us the right dir.
"""
env_var = environ.get('PIP_INDEX_URL', '').rstrip('/')
if env_var:
SIMPLE = '/simple'
if env_var.endswith(SIMPLE):
return env_var[:-len(SIMPLE)]
else:
return env_var
else:
return DEFAULT_INDEX_BASE
def main():
pip_version = StrictVersion(check_output(['pip', '--version'])
.decode('utf-8').split()[1])
@ -139,11 +156,13 @@ def main():
if pip_version >= min_pip_version:
return 0
has_pip_cache = pip_version >= StrictVersion('6.0')
index_base = get_index_base()
temp = mkdtemp(prefix='pipstrap-')
try:
downloads = [hashed_download(url, temp, digest)
for url, digest in PACKAGES]
downloads = [hashed_download(index_base + '/packages/' + path,
temp,
digest)
for path, digest in PACKAGES]
check_output('pip install --no-index --no-deps -U ' +
# Disable cache since we're not using it and it otherwise
# sometimes throws permission warnings:

View file

@ -5,7 +5,6 @@ from __future__ import print_function
import argparse
import atexit
import contextlib
import os
import re
import shutil
@ -302,8 +301,7 @@ def main():
make_and_verify_selection(args.server_root, tempdir)
tarpath = os.path.join(tempdir, "config.tar.gz")
# contextlib.closing used for py26 support
with contextlib.closing(tarfile.open(tarpath, mode="w:gz")) as tar:
with tarfile.open(tarpath, mode="w:gz") as tar:
tar.add(tempdir, arcname=".")
# TODO: Submit tarpath

View file

@ -24,6 +24,7 @@ setup(
author="Certbot Project",
author_email='client-dev@letsencrypt.org',
license='Apache License 2.0',
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: System Administrators',
@ -31,10 +32,8 @@ setup(
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',

Some files were not shown because too many files have changed in this diff Show more