diff --git a/setup.py b/setup.py index 8057c35..d4375b2 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def read(*parts): ], install_requires=[ 'Twisted[tls]>=15.5.0', - 'acme>=0.4.0', + 'acme>=0.9.0', 'attrs', 'eliot>=0.8.0', 'pem>=16.1.0', @@ -52,7 +52,11 @@ def read(*parts): 'txsni', ], extras_require={ + 'libcloud': [ + 'apache-libcloud', + ], 'test': [ + 'apache-libcloud', 'fixtures>=1.4.0', 'hypothesis>=3.1.0,<4.0.0', 'testrepository>=0.0.20', diff --git a/src/integration/test_client.py b/src/integration/test_client.py index 57afc8e..2c2711a 100644 --- a/src/integration/test_client.py +++ b/src/integration/test_client.py @@ -22,7 +22,7 @@ from txsni.snimap import SNIMap from txsni.tlsendpoint import TLSEndpoint -from txacme.challenges import TLSSNI01Responder +from txacme.challenges import LibcloudDNSResponder, TLSSNI01Responder from txacme.client import ( answer_challenge, Client, fqdn_identifier, LETSENCRYPT_STAGING_DIRECTORY, poll_until_valid) @@ -31,9 +31,6 @@ from txacme.util import csr_for_names, generate_private_key, tap -HOST = u'acme-testing.mithrandi.net' - - class ClientTestsMixin(object): """ Integration tests for the ACME client. @@ -145,7 +142,7 @@ def _test_registration(self): .addCallback(self._test_chain) .addActionFinish()) - def test_registration(self): + def test_issuing(self): action = start_action(action_type=u'integration') with action.context(): return self._test_registration() @@ -165,22 +162,21 @@ def _getenv(name, default=None): return value -class LetsEncryptStagingTests(ClientTestsMixin, TestCase): +class LetsEncryptStagingTLSSNI01Tests(ClientTestsMixin, TestCase): """ Tests using the real ACME client against the Let's Encrypt staging - environment. + environment, and the tls-sni-01 challenge. You must set $ACME_HOST to a hostname that will, when connected to on port 443, reach a listening socket opened by the tests on $ACME_ENDPOINT. """ HOST = _getenv(u'ACME_HOST') - ENDPOINT = _getenv(u'ACME_ENDPOINT', u'tcp:443') - if not _PY3: + ENDPOINT = _getenv(u'ACME_ENDPOINT') + if None in [HOST, ENDPOINT]: + skip = 'Must provide $ACME_HOST and $ACME_ENDPOINT' + elif not _PY3: ENDPOINT = ENDPOINT.encode('utf-8') - if HOST is None: - skip = 'Must provide $ACME_HOST' - def _create_client(self, key): return ( Client.from_url(reactor, LETSENCRYPT_STAGING_DIRECTORY, key=key) @@ -205,6 +201,43 @@ def _create_responder(self): .addActionFinish()) +class LetsEncryptStagingLibcloudTests(ClientTestsMixin, TestCase): + """ + Tests using the real ACME client against the Let's Encrypt staging + environment, and the dns-01 challenge. + + You must set $ACME_HOST to a hostname that will be used for the challenge, + and $LIBCLOUD_PROVIDER, $LIBCLOUD_USERNAME, $LIBCLOUD_PASSWORD, and + $LIBCLOUD_ZONE to the appropriate values for the DNS provider to complete + the challenge with. + """ + HOST = _getenv(u'ACME_HOST') + PROVIDER = _getenv(u'LIBCLOUD_PROVIDER') + USERNAME = _getenv(u'LIBCLOUD_USERNAME') + PASSWORD = _getenv(u'LIBCLOUD_PASSWORD') + ZONE = _getenv(u'LIBCLOUD_ZONE') + + if None in (HOST, PROVIDER, USERNAME, PASSWORD): + skip = 'Must provide $ACME_HOST and $LIBCLOUD_*' + + def _create_client(self, key): + return ( + Client.from_url(reactor, LETSENCRYPT_STAGING_DIRECTORY, key=key) + .addCallback(tap( + lambda client: self.addCleanup( + client._client._treq._agent._pool.closeCachedConnections))) + ) + + def _create_responder(self): + with start_action(action_type=u'integration:create_responder'): + return LibcloudDNSResponder.create( + reactor, + self.PROVIDER, + self.USERNAME, + self.PASSWORD, + self.ZONE) + + class FakeClientTests(ClientTestsMixin, TestCase): """ Tests against our verified fake. @@ -218,4 +251,4 @@ def _create_responder(self): return succeed(NullResponder(u'tls-sni-01')) -__all__ = ['LetsEncryptStagingTests', 'FakeClientTests'] +__all__ = ['LetsEncryptStagingTLSSNI01Tests', 'FakeClientTests'] diff --git a/src/txacme/challenges/__init__.py b/src/txacme/challenges/__init__.py index af6cb85..a231bb1 100644 --- a/src/txacme/challenges/__init__.py +++ b/src/txacme/challenges/__init__.py @@ -1,4 +1,6 @@ from ._http import HTTP01Responder +from ._libcloud import LibcloudDNSResponder from ._tls import TLSSNI01Responder -__all__ = ['HTTP01Responder', 'TLSSNI01Responder'] + +__all__ = ['HTTP01Responder', 'LibcloudDNSResponder', 'TLSSNI01Responder'] diff --git a/src/txacme/challenges/_libcloud.py b/src/txacme/challenges/_libcloud.py new file mode 100644 index 0000000..43f0165 --- /dev/null +++ b/src/txacme/challenges/_libcloud.py @@ -0,0 +1,183 @@ +import hashlib +import time +from threading import Thread + +import attr +from acme import jose +from libcloud.dns.providers import get_driver +from twisted._threads import pool +from twisted.internet.defer import Deferred +from twisted.python.failure import Failure +from zope.interface import implementer + +from txacme.errors import NotInZone, ZoneNotFound +from txacme.interfaces import IResponder +from txacme.util import const + + +def _daemon_thread(*a, **kw): + """ + Create a `threading.Thread`, but always set ``daemon``. + """ + thread = Thread(*a, **kw) + thread.daemon = True + return thread + + +def _defer_to_worker(deliver, worker, work, *args, **kwargs): + """ + Run a task in a worker, delivering the result as a ``Deferred`` in the + reactor thread. + """ + deferred = Deferred() + + def wrapped_work(): + try: + result = work(*args, **kwargs) + except: + f = Failure() + deliver(lambda: deferred.errback(f)) + else: + deliver(lambda: deferred.callback(result)) + worker.do(wrapped_work) + return deferred + + +def _split_zone(server_name, zone_name): + """ + Split the zone portion off from a DNS label. + + :param str server_name: The full DNS label. + :param str zone_name: The zone name suffix. + """ + if not (server_name == zone_name or + server_name.endswith(u'.' + zone_name)): + raise NotInZone(server_name=server_name, zone_name=zone_name) + return server_name[:-len(zone_name)].rstrip(u'.') + + +def _get_existing(driver, zone_name, server_name, validation): + """ + Get existing validation records. + """ + if zone_name is None: + zones = sorted( + (z for z + in driver.list_zones() + if server_name.endswith(u'.' + z.domain)), + key=lambda z: len(z.domain), + reverse=True) + if len(zones) == 0: + raise NotInZone(server_name=server_name, zone_name=None) + else: + zones = [ + z for z + in driver.list_zones() + if z.domain == zone_name] + if len(zones) == 0: + raise ZoneNotFound(zone_name=zone_name) + zone = zones[0] + subdomain = _split_zone(server_name, zone.domain) + existing = [ + record for record + in zone.list_records() + if record.name == subdomain and + record.type == 'TXT' and + record.data == validation] + return zone, existing, subdomain + + +def _validation(response): + """ + Get the validation value for a challenge response. + """ + h = hashlib.sha256(response.key_authorization.encode("utf-8")) + return jose.b64encode(h.digest()).decode() + + +@attr.s(hash=False) +@implementer(IResponder) +class LibcloudDNSResponder(object): + """ + A ``dns-01`` challenge responder using libcloud. + + .. warning:: Some libcloud backends are broken with regard to TXT records + at the time of writing; the Route 53 backend, for example. This makes + them unusable with this responder. + + .. note:: This implementation relies on invoking libcloud in a thread, so + may not be entirely production quality. + """ + challenge_type = u'dns-01' + + _reactor = attr.ib() + _thread_pool = attr.ib() + _driver = attr.ib() + zone_name = attr.ib() + settle_delay = attr.ib() + + @classmethod + def create(cls, reactor, driver_name, username, password, zone_name=None, + settle_delay=60.0): + """ + Create a responder. + + :param reactor: The Twisted reactor to use for threading support. + :param str driver_name: The name of the libcloud DNS driver to use. + :param str username: The username to authenticate with (the meaning of + this is driver-specific). + :param str password: The username to authenticate with (the meaning of + this is driver-specific). + :param str zone_name: The zone name to respond in, or ``None`` to + automatically detect zones. Usually auto-detection should be fine, + unless restricting responses to a single specific zone is desired. + :param float settle_delay: The time, in seconds, to allow for the DNS + provider to propagate record changes. + """ + return cls( + reactor=reactor, + thread_pool=pool(const(1), threadFactory=_daemon_thread), + driver=get_driver(driver_name)(username, password), + zone_name=zone_name, + settle_delay=settle_delay) + + def _defer(self, f): + """ + Run a function in our private thread pool. + """ + return _defer_to_worker( + self._reactor.callFromThread, self.thread_pool, f) + + def start_responding(self, server_name, challenge, response): + """ + Install a TXT challenge response record. + """ + validation = _validation(response) + full_name = challenge.validation_domain_name(server_name) + _driver = self._driver + + def _go(): + zone, existing, subdomain = _get_existing( + _driver, self.zone_name, full_name, validation) + if len(existing) == 0: + zone.create_record(name=subdomain, type='TXT', data=validation) + time.sleep(self.settle_delay) + return self._defer(_go) + + def stop_responding(self, server_name, challenge, response): + """ + Remove a TXT challenge response record. + """ + validation = _validation(response) + full_name = challenge.validation_domain_name(server_name) + _driver = self._driver + + def _go(): + zone, existing, subdomain = _get_existing( + _driver, self.zone_name, full_name, validation) + for record in existing: + record.delete() + return self._defer(_go) + + +__all__ = ['LibcloudDNSResponder'] diff --git a/src/txacme/errors.py b/src/txacme/errors.py new file mode 100644 index 0000000..d336392 --- /dev/null +++ b/src/txacme/errors.py @@ -0,0 +1,24 @@ +""" +Exception types for txacme. +""" +import attr + + +@attr.s +class NotInZone(ValueError): + """ + The given domain name is not in the configured zone. + """ + server_name = attr.ib() + zone_name = attr.ib() + + +@attr.s +class ZoneNotFound(ValueError): + """ + The configured zone was not found in the zones at the configured provider. + """ + zone_name = attr.ib() + + +__all__ = ['NotInZone', 'ZoneNotFound'] diff --git a/src/txacme/newsfragments/59.feature b/src/txacme/newsfragments/59.feature new file mode 100644 index 0000000..3a6d748 --- /dev/null +++ b/src/txacme/newsfragments/59.feature @@ -0,0 +1 @@ +txacme.challenges.LibcloudDNSResponder implements a dns-01 challenge responder using libcloud. Installing txacme[libcloud] is necessary to pull in the dependencies for this. diff --git a/src/txacme/test/doubles.py b/src/txacme/test/doubles.py new file mode 100644 index 0000000..a629ec9 --- /dev/null +++ b/src/txacme/test/doubles.py @@ -0,0 +1,15 @@ +""" +Test doubles. +""" +from twisted.internet.interfaces import IReactorFromThreads +from zope.interface import implementer + + +@implementer(IReactorFromThreads) +class SynchronousReactorThreads(object): + """ + An implementation of ``IReactorFromThreads`` that calls things + synchronously in the same thread. + """ + def callFromThread(self, f, *args, **kwargs): # noqa + f(*args, **kwargs) diff --git a/src/txacme/test/test_challenges.py b/src/txacme/test/test_challenges.py index b5afd3f..f52bb26 100644 --- a/src/txacme/test/test_challenges.py +++ b/src/txacme/test/test_challenges.py @@ -6,27 +6,45 @@ from acme import challenges from acme.jose import b64encode from hypothesis import strategies as s -from hypothesis import example, given +from hypothesis import assume, example, given from testtools import TestCase from testtools.matchers import ( - AfterPreprocessing, Contains, Equals, Is, MatchesAll, MatchesPredicate, + AfterPreprocessing, Always, Contains, EndsWith, Equals, HasLength, + Is, IsInstance, MatchesAll, MatchesListwise, MatchesPredicate, MatchesStructure, Not) from testtools.twistedsupport import succeeded from treq.testing import StubTreq +from twisted._threads import createMemoryWorker +from twisted.internet.defer import maybeDeferred from twisted.python.url import URL from twisted.web.resource import Resource from zope.interface.verify import verifyObject -from txacme.challenges import HTTP01Responder, TLSSNI01Responder +from txacme.challenges import ( + HTTP01Responder, LibcloudDNSResponder, TLSSNI01Responder) +from txacme.challenges._libcloud import _daemon_thread from txacme.challenges._tls import _MergingMappingProxy +from txacme.errors import NotInZone, ZoneNotFound from txacme.interfaces import IResponder -from txacme.test.test_client import RSA_KEY_512, RSA_KEY_512_RAW +from txacme.test import strategies as ts +from txacme.test.doubles import SynchronousReactorThreads +from txacme.test.test_client import failed_with, RSA_KEY_512, RSA_KEY_512_RAW + + +# A random example token for the challenge tests that need one +EXAMPLE_TOKEN = b'BWYcfxzmOha7-7LoxziqPZIUr99BCz3BfbN9kzSFnrU' class _CommonResponderTests(object): """ Common properties which every responder implementation should satisfy. """ + def _do_one_thing(self): + """ + Make the underlying fake implementation do one thing (eg. simulate one + network request, one threaded task execution). + """ + def test_interface(self): """ The `.IResponder` interface is correctly implemented. @@ -35,7 +53,7 @@ def test_interface(self): verifyObject(IResponder, responder) self.assertThat(responder.challenge_type, Equals(self._challenge_type)) - @example(token=b'BWYcfxzmOha7-7LoxziqPZIUr99BCz3BfbN9kzSFnrU') + @example(token=EXAMPLE_TOKEN) @given(token=s.binary(min_size=32, max_size=32).map(b64encode)) def test_stop_responding_already_stopped(self, token): """ @@ -45,7 +63,13 @@ def test_stop_responding_already_stopped(self, token): challenge = self._challenge_factory(token=token) response = challenge.response(RSA_KEY_512) responder = self._responder_factory() - responder.stop_responding(u'example.com', challenge, response) + d = maybeDeferred( + responder.stop_responding, + u'example.com', + challenge, + response) + self._do_one_thing() + self.assertThat(d, succeeded(Always())) class TLSResponderTests(_CommonResponderTests, TestCase): @@ -91,7 +115,7 @@ def test_start_responding(self, token): class MergingProxyTests(TestCase): """ - `._MergingMappingProxy` merges two mappings together. + ``_MergingMappingProxy`` merges two mappings together. """ @example(underlay={}, overlay={}, key=u'foo') @given(underlay=s.dictionaries(s.text(), s.builds(object)), @@ -233,4 +257,208 @@ def test_start_responding(self, token): succeeded(MatchesStructure(code=Equals(404)))) -__all__ = ['HTTPResponderTests', 'TLSResponderTests'] +class LibcloudResponderTests(_CommonResponderTests, TestCase): + """ + `.LibcloudDNSResponder` implements a responder for dns-01 challenges using + libcloud on the backend. + """ + _challenge_factory = challenges.DNS01 + _challenge_type = u'dns-01' + + def _responder_factory(self, zone_name=u'example.com'): + responder = LibcloudDNSResponder.create( + reactor=SynchronousReactorThreads(), + driver_name='dummy', + username='ignored', + password='ignored', + zone_name=zone_name, + settle_delay=0.0) + if zone_name is not None: + responder._driver.create_zone(zone_name) + responder.thread_pool, self._perform = createMemoryWorker() + return responder + + def _do_one_thing(self): + return self._perform() + + def test_daemon_threads(self): + """ + ``_daemon_thread`` creates thread objects with ``daemon`` set. + """ + thread = _daemon_thread() + self.assertThat(thread, MatchesStructure(daemon=Equals(True))) + + @example(token=EXAMPLE_TOKEN, + subdomain=u'acme-testing', + zone_name=u'example.com') + @given(token=s.binary(min_size=32, max_size=32).map(b64encode), + subdomain=ts.dns_names(), + zone_name=ts.dns_names()) + def test_start_responding(self, token, subdomain, zone_name): + """ + Calling ``start_responding`` causes an appropriate TXT record to be + created. + """ + challenge = self._challenge_factory(token=token) + response = challenge.response(RSA_KEY_512) + responder = self._responder_factory(zone_name=zone_name) + server_name = u'{}.{}'.format(subdomain, zone_name) + zone = responder._driver.list_zones()[0] + + self.assertThat(zone.list_records(), HasLength(0)) + d = responder.start_responding(server_name, challenge, response) + self._perform() + self.assertThat(d, succeeded(Always())) + self.assertThat( + zone.list_records(), + MatchesListwise([ + MatchesStructure( + name=EndsWith(u'.' + subdomain), + type=Equals('TXT'), + )])) + + # Starting twice before stopping doesn't break things + d = responder.start_responding(server_name, challenge, response) + self._perform() + self.assertThat(d, succeeded(Always())) + self.assertThat(zone.list_records(), HasLength(1)) + + d = responder.stop_responding(server_name, challenge, response) + self._perform() + self.assertThat(d, succeeded(Always())) + self.assertThat(zone.list_records(), HasLength(0)) + + @example(token=EXAMPLE_TOKEN, + subdomain=u'acme-testing', + zone_name=u'example.com') + @given(token=s.binary(min_size=32, max_size=32).map(b64encode), + subdomain=ts.dns_names(), + zone_name=ts.dns_names()) + def test_wrong_zone(self, token, subdomain, zone_name): + """ + Trying to respond for a domain not in the configured zone results in a + `.NotInZone` exception. + """ + challenge = self._challenge_factory(token=token) + response = challenge.response(RSA_KEY_512) + responder = self._responder_factory(zone_name=zone_name) + server_name = u'{}.{}.junk'.format(subdomain, zone_name) + d = maybeDeferred( + responder.start_responding, server_name, challenge, response) + self._perform() + self.assertThat( + d, + failed_with(MatchesAll( + IsInstance(NotInZone), + MatchesStructure( + server_name=EndsWith(u'.' + server_name), + zone_name=Equals(zone_name))))) + + @example(token=EXAMPLE_TOKEN, + subdomain=u'acme-testing', + zone_name=u'example.com') + @given(token=s.binary(min_size=32, max_size=32).map(b64encode), + subdomain=ts.dns_names(), + zone_name=ts.dns_names()) + def test_missing_zone(self, token, subdomain, zone_name): + """ + `.ZoneNotFound` is raised if the configured zone cannot be found at the + configured provider. + """ + challenge = self._challenge_factory(token=token) + response = challenge.response(RSA_KEY_512) + responder = self._responder_factory(zone_name=zone_name) + server_name = u'{}.{}'.format(subdomain, zone_name) + for zone in responder._driver.list_zones(): + zone.delete() + d = maybeDeferred( + responder.start_responding, server_name, challenge, response) + self._perform() + self.assertThat( + d, + failed_with(MatchesAll( + IsInstance(ZoneNotFound), + MatchesStructure( + zone_name=Equals(zone_name))))) + + @example(token=EXAMPLE_TOKEN, + subdomain=u'acme-testing', + extra=u'extra', + zone_name1=u'example.com', + zone_name2=u'example.org') + @given(token=s.binary(min_size=32, max_size=32).map(b64encode), + subdomain=ts.dns_names(), + extra=ts.dns_names(), + zone_name1=ts.dns_names(), + zone_name2=ts.dns_names()) + def test_auto_zone(self, token, subdomain, extra, zone_name1, zone_name2): + """ + If the configured zone_name is ``None``, the zone will be guessed by + finding the longest zone that is a suffix of the server name. + """ + zone_name3 = extra + u'.' + zone_name1 + zone_name4 = extra + u'.' + zone_name2 + server_name = u'{}.{}.{}'.format(subdomain, extra, zone_name1) + assume( + len({server_name, zone_name1, zone_name2, zone_name3, zone_name4}) + == 5) + challenge = self._challenge_factory(token=token) + response = challenge.response(RSA_KEY_512) + responder = self._responder_factory(zone_name=None) + zone1 = responder._driver.create_zone(zone_name1) + zone2 = responder._driver.create_zone(zone_name2) + zone3 = responder._driver.create_zone(zone_name3) + zone4 = responder._driver.create_zone(zone_name4) + self.assertThat(zone1.list_records(), HasLength(0)) + self.assertThat(zone2.list_records(), HasLength(0)) + self.assertThat(zone3.list_records(), HasLength(0)) + self.assertThat(zone4.list_records(), HasLength(0)) + d = responder.start_responding(server_name, challenge, response) + self._perform() + self.assertThat(d, succeeded(Always())) + self.assertThat(zone1.list_records(), HasLength(0)) + self.assertThat(zone2.list_records(), HasLength(0)) + self.assertThat( + zone3.list_records(), + MatchesListwise([ + MatchesStructure( + name=EndsWith(u'.' + subdomain), + type=Equals('TXT'), + )])) + self.assertThat(zone4.list_records(), HasLength(0)) + + @example(token=EXAMPLE_TOKEN, + subdomain=u'acme-testing', + zone_name1=u'example.com', + zone_name2=u'example.org') + @given(token=s.binary(min_size=32, max_size=32).map(b64encode), + subdomain=ts.dns_names(), + zone_name1=ts.dns_names(), + zone_name2=ts.dns_names()) + def test_auto_zone_missing(self, token, subdomain, zone_name1, zone_name2): + """ + If the configured zone_name is ``None``, and no matching zone is found, + ``NotInZone`` is raised. + """ + server_name = u'{}.{}'.format(subdomain, zone_name1) + assume(not server_name.endswith(zone_name2)) + challenge = self._challenge_factory(token=token) + response = challenge.response(RSA_KEY_512) + responder = self._responder_factory(zone_name=None) + zone = responder._driver.create_zone(zone_name2) + self.assertThat(zone.list_records(), HasLength(0)) + d = maybeDeferred( + responder.start_responding, server_name, challenge, response) + self._perform() + self.assertThat( + d, + failed_with(MatchesAll( + IsInstance(NotInZone), + MatchesStructure( + server_name=EndsWith(u'.' + server_name), + zone_name=Is(None))))) + + +__all__ = [ + 'HTTPResponderTests', 'TLSResponderTests', 'MergingProxyTests', + 'LibcloudResponderTests'] diff --git a/src/txacme/test/test_util.py b/src/txacme/test/test_util.py index 9a26a49..14be47e 100644 --- a/src/txacme/test/test_util.py +++ b/src/txacme/test/test_util.py @@ -19,8 +19,8 @@ from txacme.test.matchers import ValidForName from txacme.test.test_client import RSA_KEY_512, RSA_KEY_512_RAW from txacme.util import ( - cert_cryptography_to_pyopenssl, csr_for_names, decode_csr, encode_csr, - generate_private_key, generate_tls_sni_01_cert, + cert_cryptography_to_pyopenssl, const, csr_for_names, decode_csr, + encode_csr, generate_private_key, generate_tls_sni_01_cert, key_cryptography_to_pyopenssl) @@ -160,4 +160,15 @@ def test_common_name_too_long(self): u'san.too.long.invalid')])))) -__all__ = ['GeneratePrivateKeyTests', 'GenerateCertTests', 'CSRTests'] +class ConstTests(TestCase): + """ + `~txacme.util.const` returns a function that always returns a constant + value. + """ + @given(s.integers()) + def test_const(self, x): + self.assertThat(const(x)(), Equals(x)) + + +__all__ = [ + 'GeneratePrivateKeyTests', 'GenerateCertTests', 'CSRTests', 'ConstTests'] diff --git a/src/txacme/util.py b/src/txacme/util.py index e3665e3..04ce19f 100644 --- a/src/txacme/util.py +++ b/src/txacme/util.py @@ -179,8 +179,15 @@ def check_directory_url_type(url): 'got {!r} instead'.format(url)) +def const(x): + """ + Return a constant function. + """ + return lambda: x + + __all__ = [ 'generate_private_key', 'generate_tls_sni_01_cert', 'cert_cryptography_to_pyopenssl', 'key_cryptography_to_pyopenssl', 'tap', 'encode_csr', 'decode_csr', 'csr_for_names', 'clock_now', - 'check_directory_url_type'] + 'check_directory_url_type', 'const'] diff --git a/tox.ini b/tox.ini index 2fcb66a..d5ffcf7 100644 --- a/tox.ini +++ b/tox.ini @@ -44,6 +44,7 @@ whitelist_externals = changedir = docs deps = -rrequirements-doc.txt + git+https://github.com/certbot/certbot.git@master#egg=acme&subdirectory=acme commands = rm -rf {toxinidir}/docs/api/ sphinx-build -W -n -b html -d {envtmpdir}/doctrees . {envtmpdir}/html