Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion requirements-doc.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.[test,libcloud]
.[test,libcloud,txaws]
sphinx
sphinx_rtd_theme
repoze.sphinx.autointerface>=0.8
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def read(*parts):
'libcloud': [
'apache-libcloud',
],
'txaws': [
'txaws',
],
'test': [
'fixtures>=1.4.0',
'hypothesis>=3.1.0,<4.0.0',
Expand Down
10 changes: 9 additions & 1 deletion src/txacme/challenges/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
13 changes: 13 additions & 0 deletions src/txacme/challenges/_dnsutil.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 1 addition & 10 deletions src/txacme/challenges/_libcloud.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
189 changes: 189 additions & 0 deletions src/txacme/challenges/_route53.py
Original file line number Diff line number Diff line change
@@ -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']