diff --git a/requirements-doc.txt b/requirements-doc.txt index 9b03fc8..ce08fbc 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1,4 +1,4 @@ -.[test,libcloud] +.[test,libcloud,txaws] sphinx sphinx_rtd_theme repoze.sphinx.autointerface>=0.8 diff --git a/setup.py b/setup.py index ea196f4..d9e4a28 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,9 @@ def read(*parts): 'libcloud': [ 'apache-libcloud', ], + 'txaws': [ + 'txaws', + ], 'test': [ 'fixtures>=1.4.0', 'hypothesis>=3.1.0,<4.0.0', diff --git a/src/txacme/challenges/__init__.py b/src/txacme/challenges/__init__.py index 48920da..8ff8b71 100644 --- a/src/txacme/challenges/__init__.py +++ b/src/txacme/challenges/__init__.py @@ -9,4 +9,12 @@ pass -__all__ = ['HTTP01Responder', 'LibcloudDNSResponder', 'TLSSNI01Responder'] +try: + from ._route53 import Route53DNSResponder +except ImportError: + # txaws may not be installed + pass + + +__all__ = ['HTTP01Responder', 'LibcloudDNSResponder', 'TLSSNI01Responder', + 'Route53DNSResponder'] diff --git a/src/txacme/challenges/_dnsutil.py b/src/txacme/challenges/_dnsutil.py new file mode 100644 index 0000000..639cb45 --- /dev/null +++ b/src/txacme/challenges/_dnsutil.py @@ -0,0 +1,13 @@ +import hashlib + +from acme import jose + + +def _validation(response): + """ + Get the validation value for a challenge response. + """ + # TODO: This is just duplicated directly from _libcloud.py. Should we hoist + # this out to a common utility module? + h = hashlib.sha256(response.key_authorization.encode("utf-8")) + return jose.b64encode(h.digest()).decode() diff --git a/src/txacme/challenges/_libcloud.py b/src/txacme/challenges/_libcloud.py index 4960b8a..abe7372 100644 --- a/src/txacme/challenges/_libcloud.py +++ b/src/txacme/challenges/_libcloud.py @@ -1,15 +1,14 @@ -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.challenges._dnsutil import _validation from txacme.errors import NotInZone, ZoneNotFound from txacme.interfaces import IResponder from txacme.util import const @@ -90,14 +89,6 @@ def _get_existing(driver, zone_name, server_name, 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): diff --git a/src/txacme/challenges/_route53.py b/src/txacme/challenges/_route53.py new file mode 100644 index 0000000..41ad153 --- /dev/null +++ b/src/txacme/challenges/_route53.py @@ -0,0 +1,189 @@ +import attr + +from twisted.internet.defer import succeed +from twisted.internet.task import deferLater +from txaws.service import AWSServiceRegion +from txaws.route53.model import ( + RRSetKey, RRSet, Name, TXT, upsert_rrset, delete_rrset +) +from zope.interface import implementer + +from txacme.challenges._dnsutil import _validation +from txacme.errors import ZoneNotFound +from txacme.interfaces import IResponder + + +def _sleep(rval, reactor, delay): + """ + Returns a Deferred that does not fire until delay seconds have passed. This + is used simply to encourage a Twisted application to wait for the DNS + propagation to settle. This is expected to be used from + Deferred.addCallback, so it propagates the value it was called with to the + rest of the defer chain. + """ + return deferLater(reactor, delay, lambda: rval) + + +def _add_txt_record(args, full_name, validation, client): + """ + Adds a TXT record for full_name to the appropriate resource record set in + Route 53, with the value set to validation. + + This is implemented by doing an UPSERT of the RR set if it already exists: + otherwise, we'll need to create a new one. + """ + zone_id, rr_sets = args + + # Right off the bat we can create the record we're going to insert. + resource_record = TXT(texts=(validation,)) + + # We're interested only in the TXT RR Set. If it exists, we're going to + # update ('upsert') it. If it does not exist, we're going to create it. + key = RRSetKey(label=Name(full_name), type=u'TXT') + + try: + rr_set = rr_sets[key] + except KeyError: + rr_set = RRSet( + label=Name(full_name), + type=u'TXT', + ttl=300, + records=set([resource_record]) + ) + + rr_set.records.add(resource_record) + rr_set_update = upsert_rrset(rr_set) + + return client.change_resource_record_sets(zone_id, [rr_set_update]) + + +def _delete_txt_record(args, full_name, validation, client): + """ + Deletes a TXT record for full_name from the appropriate resource record set + in Route 53, with the value set to validation. + + This is implemented by doing an UPSERT of any RR set with multiple values, + or by deleting a complete RR set if there are no other values. + """ + zone_id, rr_sets = args + + # Right off the bat we can create the record we're going to remove. + resource_record = TXT(texts=(validation,)) + + # We're interested only in the TXT RR Set. We expect this to exist: if it + # doesn't, we'll just quietly exit as we have no work to do. + key = RRSetKey(label=Name(full_name), type=u'TXT') + + try: + rr_set = rr_sets[key] + except KeyError: + return succeed(None) + + # Now we want to check that the record is in the RR set. If it isn't, we + # again quietly exit. + if resource_record not in rr_set.records: + return succeed(None) + + if len(rr_set.records) == 1: + rr_set_update = delete_rrset(rr_set) + else: + rr_set.records.remove(resource_record) + rr_set_update = upsert_rrset(rr_set) + + return client.change_resource_record_sets(zone_id, [rr_set_update]) + + +def _get_rr_sets_for_zone(zone_id, client): + """ + Given a single zone ID, returns a tuple of that zone ID and the RRSets for + that zone. + """ + d = client.list_resource_record_sets(zone_id) + d.addCallback(lambda rr_sets: (zone_id, rr_sets)) + return d + + +def _get_zone_id(zones, server_name): + """ + Given the collection of zones in Route53, returns the zone ID of the one + that is appropriate for use with this challenge. If no zones are, then this + raises a ZoneNotFound error. + """ + for zone in zones: + if server_name.endswith(zone.name): + return zone.identifier + + raise ZoneNotFound(u"Unable to find zone for %s" % server_name) + + +@attr.s(hash=False) +@implementer(IResponder) +class Route53DNSResponder(object): + """ + A ``dns-01`` challenge responder using txaws and Route53. + """ + challenge_type = u'dns-01' + + _reactor = attr.ib() + _client = attr.ib() + _settle_delay = attr.ib() + + @classmethod + def create(cls, reactor, access_key, secret_key): + """ + Create a responder. + + :param reactor: The Twisted reactor to use for delay support. + :param str access_key: The AWS IAM access key to use. + :param str secret_key: The AWS IAM secret key to use. + """ + # This isn't publicly exposed because we want to wait for txaws to + # support the DNS change status API: + # http://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html + # Until that time, we hard-code a settle delay. + settle_delay = 60.0 + region = AWSServiceRegion(access_key=access_key, secret_key=secret_key) + return cls( + reactor=reactor, + client=region.get_route53_client(), + settle_delay=settle_delay) + + 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) + u'.' + + d = self._client.list_hosted_zones() + d.addCallback(_get_zone_id, server_name=full_name) + d.addCallback(_get_rr_sets_for_zone, client=self._client) + d.addCallback( + _add_txt_record, + full_name=full_name, + validation=validation, + client=self._client + ) + d.addCallback(_sleep, reactor=self._reactor, delay=self._settle_delay) + + return d + + 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) + u'.' + + d = self._client.list_hosted_zones() + d.addCallback(_get_zone_id, server_name=full_name) + d.addCallback(_get_rr_sets_for_zone, client=self._client) + d.addCallback( + _delete_txt_record, + full_name=full_name, + validation=validation, + client=self._client + ) + + +__all__ = ['Route53DNSResponder']