Skip to content
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

Merged
merged 15 commits into from
Oct 5, 2016
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 57 additions & 6 deletions src/integration/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Copy link
Member

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? ;-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More seriously: ImmediateClock or ReentrantClock would be closer to what you mean here, I think.

Copy link
Contributor Author

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.

"""
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):
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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')
Copy link
Member

Choose a reason for hiding this comment

The 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 TXACME_. This sort of cross-library bleed-over is how we got httpoxy, after all.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, these only influence the operation of the txacme integration tests.

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.
Expand All @@ -218,4 +269,4 @@ def _create_responder(self):
return succeed(NullResponder(u'tls-sni-01'))


__all__ = ['LetsEncryptStagingTests', 'FakeClientTests']
__all__ = ['LetsEncryptStagingTLSSNI01Tests', 'FakeClientTests']
4 changes: 3 additions & 1 deletion src/txacme/challenges/__init__.py
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']
151 changes: 151 additions & 0 deletions src/txacme/challenges/_libcloud.py
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']
1 change: 1 addition & 0 deletions src/txacme/newsfragments/59.feature
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

txacme[dns] maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Loading