-
-
Save nh2/f744ac591e95f0c25b501db00cf7c71a to your computer and use it in GitHub Desktop.
route53 hook for dehydrated - python2 / python3 + boto2 version. Tested on Ubuntu 16.04
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# 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 example.com -t dns-01 -k /etc/dehydrated/hooks/route53.py | |
# | |
# Manually specify hosted zone: | |
# HOSTED_ZONE=example.com AWS_PROFILE=example ./dehydrated -c -d example.com -t dns-01 -k /etc/dehydrated/hooks/route53.py | |
# | |
# More info about dehaydrated and dns challenge: https://github.com/lukas2511/dehydrated/wiki/Examples-for-DNS-01-hooks | |
# Using AWS Profiles: http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-multiple-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 | |
USAGE_TEXT = "USAGE: route53.py CHALLENGE_TYPE DOMAIN TOKEN_FILENAME_IGNORED TOKEN_VALUE [DOMAIN TOKEN_FILENAME_IGNORED TOKEN_VALUE]..." | |
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/', '') | |
else: | |
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)) | |
candidate_zones.sort() | |
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)) | |
sleep(sleep_time) | |
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 record.name == 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: | |
change.add_value(txt_challenge) | |
response = record_set.commit() | |
responses.append(response) | |
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: | |
change.add_value(txt_challenge) | |
response = record_set.commit() | |
# We don't block the hook to wait for deletion to complete. | |
# responses.append(response) | |
else: | |
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 range(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") | |
exit(0) | |
elif hook == "exit_hook": | |
print("Ignoring exit_hook") | |
exit(0) | |
elif hook == "deploy_cert": | |
print("Ignoring deploy_cert hook") | |
exit(0) | |
elif hook == "unchanged_cert": | |
print("Ignoring unchanged_cert hook") | |
exit(0) | |
else: | |
print("Ignoring unknown hook %s", hook) | |
exit(0) |
Fork here removes some of the output to keep it cleaner : https://gist.github.com/tomchiverton/53ea2b2d584690959e83cd33a7e5475b
Recommend changing xrange to range, allowing python3 compatibility.
Fixed now.
We should really group up and put this thing into a standalone repo. Using multiple Gists makes maintenance and merging features difficult.
I don't use route53 anymore and thus cannot maintain my version. However, I would be happy to answer any questions about the setup that led to its creation.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 https://gist.github.com/patrakov/fbf0a09c027c0d32712c8703ab614868