Skip to content

Instantly share code, notes, and snippets.

@nh2 nh2/

forked from rmarchei/
Last active Dec 4, 2018
What would you like to do?
route53 hook for dehydrated - python2 / python3 + boto2 version. Tested on Ubuntu 16.04
#!/usr/bin/env python
# How to use:
# Ubuntu 16.04: apt install -y python-boto OR apt install -y python3-boto
# Specify the default profile on aws/boto profile files or use the optional AWS_PROFILE env var:
# AWS_PROFILE=example ./dehydrated -c -d -t dns-01 -k /etc/dehydrated/hooks/
# Manually specify hosted zone:
# AWS_PROFILE=example ./dehydrated -c -d -t dns-01 -k /etc/dehydrated/hooks/
# More info about dehaydrated and dns challenge:
# Using AWS Profiles:
# This hook also works with dehydrated's HOOK_CHAIN="yes", which passes all domains in one invocation.
# It is recommended to use this hook with HOOK_CHAIN="yes" because it is faster to challenge domains in batch.
# This hook also works with wildcard certificates.
import os
import sys
from boto.route53 import *
from time import sleep
def get_zone_id(conn, domain):
if 'HOSTED_ZONE' in os.environ:
hosted_zone = os.environ['HOSTED_ZONE']
if not domain.endswith(hosted_zone):
raise Exception("Incorrect hosted zone for domain {0}".format(domain))
zone = conn.get_hosted_zone_by_name("{0}.".format(hosted_zone))
zone_id = zone['GetHostedZoneResponse']['HostedZone']['Id'].replace('/hostedzone/', '')
zones = conn.get_all_hosted_zones()
candidate_zones = []
domain_dot = "{0}.".format(domain)
for zone in zones['ListHostedZonesResponse']['HostedZones']:
if domain_dot.endswith(zone['Name']):
candidate_zones.append((domain_dot.find(zone['Name']), zone['Id'].replace('/hostedzone/', '')))
if len(candidate_zones) == 0:
raise Exception("Hosted zone not found for domain {0}".format(domain))
zone_id = candidate_zones[0][1]
return zone_id
def wait_for_dns_update(conn, response, time_elapsed=0):
timeout = 300
sleep_time = 5
st = status.Status(conn, response['ChangeResourceRecordSetsResponse']['ChangeInfo'])
while st.update() != 'INSYNC' and time_elapsed <= timeout:
print("Waiting for DNS change to complete... ({0}; elapsed {1} seconds)".format(st, time_elapsed))
time_elapsed += sleep_time
if st.update() != 'INSYNC' and time_elapsed > timeout:
raise Exception("Timed out while waiting for DNS record to be ready. Waited {0} seconds but the last status was {1}".format(time_elapsed, st))
print("DNS change completed")
return time_elapsed
def route53_dns(domain_challenges_dict, action):
action = action.upper()
assert action in ['UPSERT', 'DELETE']
conn = connection.Route53Connection()
responses = []
for domain, txt_challenges in domain_challenges_dict.items():
print("domain: {0}".format(domain))
print("txt_challenges: {0}".format(txt_challenges))
zone_id = get_zone_id(conn, domain)
name = u'_acme-challenge.{0}.'.format(domain) # note u'' and trailing . are important here for the == below
# Get existing record set, so we can add our challenges to it.
# It's important that we add instead of override, to support dehydrated's HOOK_CHAIN="no",
# (in which case we as the hook can't see all changes to make upfront).
record_set = conn.get_all_rrsets(zone_id, name=name)
record_exists = False
existing_quoted_txt_challenges = [] # include "" quotes already; not a set because 'DELETE' may care about order
for record in record_set:
if == name and record.type == "TXT":
record_exists = True
existing_quoted_txt_challenges += record.resource_records
if action == 'UPSERT':
needed_quoted_txt_challenges = set('"{0}"'.format(c) for c in txt_challenges)
all_quoted_txt_challenges = set(existing_quoted_txt_challenges) | needed_quoted_txt_challenges
change = record_set.add_change('UPSERT', name, type='TXT', ttl=60)
for txt_challenge in all_quoted_txt_challenges:
response = record_set.commit()
elif action == 'DELETE':
if record_exists:
change = record_set.add_change('DELETE', name, type='TXT', ttl=60)
for txt_challenge in existing_quoted_txt_challenges:
response = record_set.commit()
# We don't block the hook to wait for deletion to complete.
# responses.append(response)
print("Challenge record " + name + " is already gone!")
if responses != []:
print("Waiting for all responses...")
time_elapsed = 0
for response in responses:
time_elapsed = wait_for_dns_update(conn, response, time_elapsed)
def deploy_hook_args_to_domain_challenge_dict(hook_args):
assert len(hook_args) % 3 == 0, "wrong number of arguments, hook arguments must be multiple of 3; " + USAGE_TEXT
domain_dict = {}
for i in xrange(0, len(hook_args), 3):
domain = hook_args[i]
txt_challenge = hook_args[i+2]
domain_dict.setdefault(domain, []).append(txt_challenge)
return domain_dict
if __name__ == "__main__":
assert len(sys.argv) >= 2, "wrong number of arguments, need at least 1; " + USAGE_TEXT
hook = sys.argv[1]
print("hook: {0}".format(hook))
if hook == "deploy_challenge":
hook_args = sys.argv[2:]
domain_challenges_dict = deploy_hook_args_to_domain_challenge_dict(hook_args)
route53_dns(domain_challenges_dict, action='upsert')
elif hook == "clean_challenge":
hook_args = sys.argv[2:]
domain_challenges_dict = deploy_hook_args_to_domain_challenge_dict(hook_args)
route53_dns(domain_challenges_dict, action='delete')
elif hook == "startup_hook":
print("Ignoring startup_hook")
elif hook == "exit_hook":
print("Ignoring exit_hook")
elif hook == "deploy_cert":
print("Ignoring deploy_cert hook")
elif hook == "unchanged_cert":
print("Ignoring unchanged_cert hook")
print("Ignoring unknown hook %s", hook)

This comment has been minimized.

Copy link

niall-byrne commented Jun 20, 2018

Recommend changing xrange to range, allowing python3 compatibility.


This comment has been minimized.

Copy link

patrakov commented Sep 7, 2018

I have forked this, to support the case when _acme-challenge is a CNAME. This is good for security reasons - you can put the CNAME target into a separate Route53 zone, and restrict the write permissions for the AWS account to that zone only. In other words, limit the damage if the credentials are stolen - the thieves will not be able to zero out your main zone.

The fork is at


This comment has been minimized.

Copy link

tomchiverton commented Dec 4, 2018

Fork here removes some of the output to keep it cleaner :

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.