diff --git a/certbot_route53/__init__.py b/certbot_route53/__init__.py index 6f519023b..c91c79c22 100644 --- a/certbot_route53/__init__.py +++ b/certbot_route53/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Route53 plugin.""" +"""Certbot Route53 plugin.""" diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index d383044c0..c27d28204 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -1,10 +1,12 @@ -"""Route53 Let's Encrypt authenticator plugin.""" +"""Certbot Route53 authenticator plugin.""" import logging import time +import datetime import zope.interface import boto3 +from botocore.exceptions import NoCredentialsError from acme import challenges @@ -14,112 +16,128 @@ from certbot.plugins import common logger = logging.getLogger(__name__) -TTL = 30 +INSTRUCTIONS = ( + "To use, create an IAM user and attach the AmazonRoute53FullAccess policy, then store " + "the access key ID and secret key in ~/.aws/credentials or in " + "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, as described at " + "https://boto3.readthedocs.io/en/latest/guide/configuration.html") @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) class Authenticator(common.Plugin): + """Route53 Authenticator - description = "Route53 Authenticator" + This authenticator solves a DNS01 challenge by uploading the answer to AWS + Route53. + """ + + description = ("Authenticate domain names using the DNS challenge type, " + "by automatically updating TXT records using AWS Route53. Works only " + "if you use AWS Route53 to host DNS for your domains. " + + INSTRUCTIONS) def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) - self._httpd = None + session = boto3.Session() + self.route53_client = session.client("route53") + # A list of (dns name, TXT value) tuples, for cleanup. + self.txt_records = [] def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover def more_info(self): # pylint: disable=missing-docstring,no-self-use - return ("") + return "Solve a DNS01 challenge using AWS Route53" def get_chall_pref(self, domain): # pylint: disable=missing-docstring,no-self-use,unused-argument return [challenges.DNS01] def perform(self, achalls): # pylint: disable=missing-docstring - responses = [] - for achall in achalls: - responses.append(self._perform_single(achall)) - return responses - - def _find_zone(self, r53, domain): - return max( - ( - zone for zone in r53.list_hosted_zones()["HostedZones"] - if (domain+".").endswith("."+zone["Name"]) or (domain+".") == (zone["Name"]) - ), - key=lambda zone: len(zone["Name"]), - ) - - - def _perform_single(self, achall): - # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge - r53 = boto3.client('route53') - logger.info("Doing validation for " + achall.domain) - try: - zone = self._find_zone(r53, achall.domain) - except ValueError as e: - logger.error("Unable to find matching Route53 zone for domain " + achall.domain) - return None + change_ids = [self._create_single(achall) for achall in achalls] + for change_id in change_ids: + self._wait_for_change(change_id) + return [achall.response(achall.account_key) for achall in achalls] + except NoCredentialsError: + raise Exception("No AWS Route53 credentials found. " + INSTRUCTIONS) - response, validation = achall.response_and_validation() - self._excute_r53_action(r53, achall, zone, validation, 'UPSERT', wait_for_change=True) + def cleanup(self, achalls): # pylint: disable=missing-docstring + for name, value in self.txt_records: + self._delete_txt_record(name, value) - for _ in range(TTL*2): - if response.simple_verify( - achall.chall, - achall.domain, - achall.account_key.public_key(), - ): - break - logger.info("Waiting for DNS propagation...") - time.sleep(1) - else: - logger.error("Unable to verify domain " + achall.domain) - return None + def _create_single(self, achall): + """Create a TXT record, return a change_id""" + name, value = (achall.validation_domain_name(achall.domain), + achall.validation(achall.account_key)) + change_id = self._create_txt_record(name, value) + self.txt_records.append((name, value)) + return change_id - return response + def _find_zone_id_for_domain(self, domain): + paginator = self.route53_client.get_paginator("list_hosted_zones") + zones = [] + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if ( + domain.endswith(zone["Name"]) or + (domain + ".").endswith(zone["Name"]) + ) and not zone["Config"]["PrivateZone"]: + zones.append((zone["Name"], zone["Id"])) - def cleanup(self, achalls): - # pylint: disable=missing-docstring - r53 = boto3.client('route53') - for achall in achalls: - try: - zone = self._find_zone(r53, achall.domain) - except ValueError: - logger.warn("Unable to find zone for " + achall.domain + ". Skipping cleanup.") - continue - - _, validation = achall.response_and_validation() - self._excute_r53_action(r53, achall, zone, validation, 'DELETE') - return None - - - def _excute_r53_action(self, r53, achall, zone, validation, action, wait_for_change=False): - response = r53.change_resource_record_sets( - HostedZoneId=zone["Id"], - ChangeBatch={ - 'Comment': 'Let\'s Encrypt ' + action, - 'Changes': [ - { - 'Action': action, - 'ResourceRecordSet': { - 'Name': achall.validation_domain_name(achall.domain), - 'Type': 'TXT', - 'TTL': TTL, - 'ResourceRecords': [ - { - 'Value': '"' + validation + '"', - }, - ], - }, - }, - ], - }, + if not zones: + raise ValueError( + "Unable to find a Route53 hosted zone for {}".format(domain) ) - if wait_for_change: - while r53.get_change(Id=response["ChangeInfo"]["Id"])["ChangeInfo"]["Status"] == "PENDING": - logger.info("Waiting for " + action + " to propagate...") - time.sleep(1) + # Order the zones that are suffixes for our desired to domain by + # length, this puts them in an order like: + # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"] + # And then we choose the first one, which will be the most specific. + zones.sort(key=lambda z: len(z[0]), reverse=True) + return zones[0][1] + + def _change_txt_record(self, action, zone_id, domain, value): + response = self.route53_client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Comment": "certbot-route53 certificate validation " + action, + "Changes": [ + { + "Action": action, + "ResourceRecordSet": { + "Name": domain, + "Type": "TXT", + "TTL": 0, + "ResourceRecords": [ + # For some reason TXT records need to be + # manually quoted. + {"Value": '"{}"'.format(value)} + ], + } + } + ] + } + ) + return response["ChangeInfo"]["Id"] + + def _create_txt_record(self, host, value): + zone_id = self._find_zone_id_for_domain(host) + change_id = self._change_txt_record("UPSERT", zone_id, host, value) + return change_id + + def _delete_txt_record(self, host, value): + zone_id = self._find_zone_id_for_domain(host) + change_id = self._change_txt_record("DELETE", zone_id, host, value) + return change_id + + def _wait_for_change(self, change_id): + deadline = datetime.datetime.now() + datetime.timedelta(minutes=10) + while datetime.datetime.now() < deadline: + response = self.route53_client.get_change(Id=change_id) + if response["ChangeInfo"]["Status"] == "INSYNC": + return + time.sleep(5) + raise Exception( + "Timed out waiting for Route53 change. Current status: %s" % + response["ChangeInfo"]["Status"])