mirror of
https://github.com/certbot/certbot.git
synced 2026-06-07 15:52:08 -04:00
Documentation and efficiency changes.
These are from certbot/certbot#4174 Add more documentation, and help for NoCredentialsError. Allow multiple DNS records to be provisioned at once and waited for together. Fix doc strings to use "Certbot" instead of "Let's Encrypt." Set TTL to 0. Create a single boto3 session rather than one per API call. Use pagination in Route53 API in case there are many domains. Add a maximum wait time for update to propagate (10 minutes).
This commit is contained in:
parent
df158a717d
commit
1143ab7446
2 changed files with 103 additions and 85 deletions
|
|
@ -1 +1 @@
|
|||
"""Let's Encrypt Route53 plugin."""
|
||||
"""Certbot Route53 plugin."""
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Reference in a new issue