-
Notifications
You must be signed in to change notification settings - Fork 23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement libcloud-based dns-01 challenge responder. #63
Changes from 3 commits
2affbc4
fb25960
b2e028f
080d334
3939938
5a45d5a
3616e30
f55ee97
871c4b4
59f09fe
d660693
ec4aae7
d2d59da
78ead8c
c99da60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,15 +14,17 @@ | |
from twisted.internet import reactor | ||
from twisted.internet.defer import succeed | ||
from twisted.internet.endpoints import serverFromString | ||
from twisted.internet.interfaces import IReactorTime | ||
from twisted.python.compat import _PY3 | ||
from twisted.python.filepath import FilePath | ||
from twisted.trial.unittest import TestCase | ||
from twisted.web.resource import Resource | ||
from twisted.web.server import Site | ||
from txsni.snimap import SNIMap | ||
from txsni.tlsendpoint import TLSEndpoint | ||
from zope.interface import implementer | ||
|
||
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,7 +33,19 @@ | |
from txacme.util import csr_for_names, generate_private_key, tap | ||
|
||
|
||
HOST = u'acme-testing.mithrandi.net' | ||
@implementer(IReactorTime) | ||
class BrokenClock(object): | ||
""" | ||
A clock implementation that always runs everything immediately. | ||
""" | ||
def seconds(self): | ||
return 0 | ||
|
||
def callLater(self, delay, callable, *args, **kw): # noqa | ||
callable(*args, **kw) | ||
|
||
def getDelayedCalls(self): # noqa | ||
return () | ||
|
||
|
||
class ClientTestsMixin(object): | ||
|
@@ -145,7 +159,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,10 +179,10 @@ 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. | ||
|
@@ -205,6 +219,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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a potential namespacing issue here. In my opinion, all variables that influence the operation of txacme should be prefixed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh wait a second. I misunderstood. This is just the tests confirming that the library will be configured, but these values are in fact read by libcloud; carry on. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, these only influence the operation of the |
||
USERNAME = _getenv(u'LIBCLOUD_USERNAME') | ||
PASSWORD = _getenv(u'LIBCLOUD_PASSWORD') | ||
ZONE = _getenv(u'LIBCLOUD_ZONE') | ||
|
||
if None in (HOST, PROVIDER, USERNAME, PASSWORD, ZONE): | ||
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 +269,4 @@ def _create_responder(self): | |
return succeed(NullResponder(u'tls-sni-01')) | ||
|
||
|
||
__all__ = ['LetsEncryptStagingTests', 'FakeClientTests'] | ||
__all__ = ['LetsEncryptStagingTLSSNI01Tests', 'FakeClientTests'] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
from ._libcloud import LibcloudDNSResponder | ||
from ._tls import TLSSNI01Responder | ||
|
||
__all__ = ['TLSSNI01Responder'] | ||
|
||
__all__ = ['TLSSNI01Responder', 'LibcloudDNSResponder'] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
import hashlib | ||
import time | ||
|
||
import attr | ||
from acme import jose | ||
from libcloud.dns.providers import get_driver | ||
from twisted.internet.threads import deferToThreadPool | ||
from twisted.python.threadpool import ThreadPool | ||
from zope.interface import implementer | ||
|
||
from txacme.interfaces import IResponder | ||
|
||
|
||
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.endswith(zone_name): | ||
raise ValueError(server_name, zone_name) | ||
return server_name[:-len(zone_name)].rstrip(u'.') | ||
|
||
|
||
def _get_existing(driver, zone_name, subdomain, validation): | ||
""" | ||
Get existing validation records. | ||
""" | ||
zones = [ | ||
z for z | ||
in driver.list_zones() | ||
if z.domain == zone_name] | ||
if len(zones) == 0: | ||
raise ValueError('zone not found') | ||
else: | ||
zone = zones[0] | ||
existing = [ | ||
record for record | ||
in zone.list_records() | ||
if record.name == subdomain and | ||
record.type == 'TXT' and | ||
record.data == validation] | ||
return zone, existing | ||
|
||
|
||
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. | ||
|
||
.. 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, | ||
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. | ||
:param float settle_delay: The time, in seconds, to allow for the DNS | ||
provider to propagate record changes. | ||
""" | ||
return cls( | ||
reactor=reactor, | ||
thread_pool=ThreadPool(minthreads=1, maxthreads=5), | ||
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 deferToThreadPool(self._reactor, self._thread_pool, f) | ||
|
||
def _subdomain(self, server_name, challenge): | ||
""" | ||
Get the validation domain name for a challenge. | ||
""" | ||
return _split_zone( | ||
challenge.validation_domain_name(server_name), | ||
self.zone_name) | ||
|
||
def _ensure_thread_pool_started(self): | ||
""" | ||
Start the thread pool if it isn't already started. | ||
""" | ||
if not self._thread_pool.started: | ||
self._thread_pool.start() | ||
self._reactor.addSystemEventTrigger( | ||
'after', 'shutdown', self._thread_pool.stop) | ||
|
||
def start_responding(self, server_name, challenge, response): | ||
""" | ||
Install a TXT challenge response record. | ||
""" | ||
self._ensure_thread_pool_started() | ||
validation = _validation(response) | ||
subdomain = self._subdomain(server_name, challenge) | ||
_driver = self._driver | ||
|
||
def _go(): | ||
zone, existing = _get_existing( | ||
_driver, self.zone_name, subdomain, 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) | ||
subdomain = self._subdomain(server_name, challenge) | ||
_driver = self._driver | ||
|
||
def _go(): | ||
zone, existing = _get_existing( | ||
_driver, self.zone_name, subdomain, validation) | ||
for record in existing: | ||
record.delete() | ||
return self._defer(_go) | ||
|
||
|
||
__all__ = ['LibcloudDNSResponder'] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. txacme[dns] maybe? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm planning to have other backends in future (that are not libcloud), and I think having a single extra that pulls in everything needed for all of the backends wouldn't be great? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it right twice a day? ;-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More seriously:
ImmediateClock
orReentrantClock
would be closer to what you mean here, I think.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like
ImmediateClock
, I'll use that.