-
-
Save nh2/f744ac591e95f0c25b501db00cf7c71a to your computer and use it in GitHub Desktop.
#!/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) |
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
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.
Recommend changing xrange to range, allowing python3 compatibility.