diff --git a/README.md b/README.md index d70b266..9d3d3d1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ environment variable. This should be a JSON object with the following schema: }, "hosts": ["list of hosts you want on the certificate (strings)"], "key_type": "rsa or ecdsa, optional, defaults to rsa (string)" + }, + { + "cloudfront": { + "id": "CloudFront distribution ID (string)" + }, + "hosts": ["list of hosts you want on the certificate (strings)"], + "key_type": "rsa or ecdsa, optional, defaults to rsa (string)" } ], "acme_account_key": "location of the account private key (string)", diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 283eebb..7b94c1a 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -54,10 +54,51 @@ def __init__(self, cert_location, dns_challenge_completer, hosts, self.key_type = key_type +def _get_iam_certificate(iam_client, id_or_arn): + paginator = iam_client.get_paginator("list_server_certificates") + for page in paginator.paginate(): + for server_certificate in page["ServerCertificateMetadataList"]: + if ( + server_certificate["Arn"] == id_or_arn or + server_certificate["ServerCertificateId"] == id_or_arn + ): + + cert_name = server_certificate["ServerCertificateName"] + response = iam_client.get_server_certificate( + ServerCertificateName=cert_name, + ) + return x509.load_pem_x509_certificate( + response["ServerCertificate"]["CertificateBody"], + default_backend(), + ) + + +def _upload_iam_certificate(iam_client, hosts, private_key, pem_certificate, + pem_certificate_chain, path="/"): + response = iam_client.upload_server_certificate( + ServerCertificateName=generate_certificate_name( + hosts, + x509.load_pem_x509_certificate( + pem_certificate, default_backend() + ) + ), + PrivateKey=private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ), + CertificateBody=pem_certificate, + CertificateChain=pem_certificate_chain, + Path=path, + ) + return response["ServerCertificateMetadata"] + + class ELBCertificate(object): def __init__(self, elb_client, iam_client, elb_name, elb_port): self.elb_client = elb_client self.iam_client = iam_client + self.name = "ELB " + elb_name self.elb_name = elb_name self.elb_port = elb_port @@ -72,46 +113,23 @@ def get_current_certificate(self): if listener["Listener"]["LoadBalancerPort"] == self.elb_port ] - paginator = self.iam_client.get_paginator("list_server_certificates") - for page in paginator.paginate(): - for server_certificate in page["ServerCertificateMetadataList"]: - if server_certificate["Arn"] == certificate_id: - cert_name = server_certificate["ServerCertificateName"] - response = self.iam_client.get_server_certificate( - ServerCertificateName=cert_name, - ) - return x509.load_pem_x509_certificate( - response["ServerCertificate"]["CertificateBody"], - default_backend(), - ) + return _get_iam_certificate(self.iam_client, certificate_id) def update_certificate(self, logger, hosts, private_key, pem_certificate, pem_certificate_chain): logger.emit( - "updating-elb.upload-iam-certificate", elb_name=self.elb_name + "updating-elb.upload-iam-certificate", source=self.name ) - response = self.iam_client.upload_server_certificate( - ServerCertificateName=generate_certificate_name( - hosts, - x509.load_pem_x509_certificate( - pem_certificate, default_backend() - ) - ), - PrivateKey=private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ), - CertificateBody=pem_certificate, - CertificateChain=pem_certificate_chain, - ) - new_cert_arn = response["ServerCertificateMetadata"]["Arn"] + new_cert_arn = _upload_iam_certificate( + self.iam_client, + hosts, private_key, pem_certificate, pem_certificate_chain + )["Arn"] # Sleep before trying to set the certificate, it appears to sometimes # fail without this. time.sleep(15) - logger.emit("updating-elb.set-elb-certificate", elb_name=self.elb_name) + logger.emit("updating-elb.set-elb-certificate", source=self.name) self.elb_client.set_load_balancer_listener_ssl_certificate( LoadBalancerName=self.elb_name, SSLCertificateId=new_cert_arn, @@ -119,6 +137,68 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, ) +class CloudFrontCertificate(object): + def __init__(self, cloudfront_client, iam_client, distribution_id): + self.cloudfront_client = cloudfront_client + self.iam_client = iam_client + self.name = "Distribution " + distribution_id + self.distribution_id = distribution_id + + def get_current_certificate(self): + response = self.cloudfront_client.get_distribution_config( + Id=self.distribution_id + ) + cert = response["DistributionConfig"]["ViewerCertificate"] + # If the cert is of a different type then we don't have code to check + # for it, annoying. + if not cert.get("IAMCertificateId"): + return None + else: + return _get_iam_certificate( + self.iam_client, cert["IAMCertificateId"] + ) + + def update_certificate(self, logger, hosts, private_key, pem_certificate, + pem_certificate_chain): + logger.emit("upload-iam-certificate") + new_cert_id = _upload_iam_certificate( + self.iam_client, + hosts, private_key, pem_certificate, pem_certificate_chain, + path="/cloudfront/" + )["ServerCertificateId"] + + # Sleep before trying to set the certificate, it appears to sometimes + # fail without this. + time.sleep(15) + + response = self.cloudfront_client.get_distribution_config( + Id=self.distribution_id + ) + etag = response["ETag"] + config = response["DistributionConfig"] + protocol = config["ViewerCertificate"]["MinimumProtocolVersion"] + ssl_mode = config["ViewerCertificate"].get( + "SSLSupportMethod", "sni-only" + ) + + # SSLv3 can't be used with SNI + if ssl_mode == "sni-only": + protocol = "TLSv1" + + config["ViewerCertificate"] = { + "IAMCertificateId": new_cert_id, + "MinimumProtocolVersion": protocol, + "SSLSupportMethod": ssl_mode + } + + logger.emit("set-cloudfront-distribution-certificate") + self.cloudfront_client.update_distribution( + Id=self.distribution_id, + DistributionConfig=config, + IfMatch=etag + ) + + class Route53ChallengeCompleter(object): def __init__(self, route53_client): self.route53_client = route53_client @@ -251,9 +331,9 @@ def __init__(self, host, authz, dns_challenge, change_id): def start_dns_challenge(logger, acme_client, dns_challenge_completer, - elb_name, host): + source, host): logger.emit( - "updating-elb.request-acme-challenge", elb_name=elb_name, host=host + "updating-elb.request-acme-challenge", source=source, host=host ) authz = acme_client.request_domain_challenges( host, acme_client.directory.new_authz @@ -262,7 +342,7 @@ def start_dns_challenge(logger, acme_client, dns_challenge_completer, [dns_challenge] = find_dns_challenge(authz) logger.emit( - "updating-elb.create-txt-record", elb_name=elb_name, host=host + "updating-elb.create-txt-record", source=source, host=host ) change_id = dns_challenge_completer.create_txt_record( dns_challenge.validation_domain_name(host), @@ -278,10 +358,10 @@ def start_dns_challenge(logger, acme_client, dns_challenge_completer, def complete_dns_challenge(logger, acme_client, dns_challenge_completer, - elb_name, authz_record): + source, authz_record): logger.emit( "updating-elb.wait-for-route53", - elb_name=elb_name, host=authz_record.host + source=source, host=authz_record.host ) dns_challenge_completer.wait_for_change(authz_record.change_id) @@ -289,7 +369,7 @@ def complete_dns_challenge(logger, acme_client, dns_challenge_completer, logger.emit( "updating-elb.local-validation", - elb_name=elb_name, host=authz_record.host + source=source, host=authz_record.host ) verified = response.simple_verify( authz_record.dns_challenge.chall, @@ -301,13 +381,13 @@ def complete_dns_challenge(logger, acme_client, dns_challenge_completer, logger.emit( "updating-elb.answer-challenge", - elb_name=elb_name, host=authz_record.host + source=source, host=authz_record.host ) acme_client.answer_challenge(authz_record.dns_challenge, response) -def request_certificate(logger, acme_client, elb_name, authorizations, csr): - logger.emit("updating-elb.request-cert", elb_name=elb_name) +def request_certificate(logger, acme_client, source, authorizations, csr): + logger.emit("updating-elb.request-cert", source=source) cert_response, _ = acme_client.poll_and_request_issuance( acme.jose.util.ComparableX509( OpenSSL.crypto.load_certificate_request( @@ -328,37 +408,41 @@ def request_certificate(logger, acme_client, elb_name, authorizations, csr): def update_elb(logger, acme_client, force_issue, cert_request): - logger.emit("updating-elb", elb_name=cert_request.cert_location.elb_name) + logger.emit("updating-elb", source=cert_request.cert_location.name) current_cert = cert_request.cert_location.get_current_certificate() - logger.emit( - "updating-elb.certificate-expiration", - elb_name=cert_request.cert_location.elb_name, - expiration_date=current_cert.not_valid_after - ) - days_until_expiration = ( - current_cert.not_valid_after - datetime.datetime.today() - ) - try: - san_extension = current_cert.extensions.get_extension_for_class( - x509.SubjectAlternativeName + if current_cert: + logger.emit( + "updating-elb.certificate-expiration", + source=cert_request.cert_location.name, + expiration_date=current_cert.not_valid_after ) - except x509.ExtensionNotFound: - # Handle the case where an old certificate doesn't have a SAN extension - # and always reissue in that case. - current_domains = [] - else: - current_domains = san_extension.value.get_values_for_type(x509.DNSName) + days_until_expiration = ( + current_cert.not_valid_after - datetime.datetime.today() + ) + + try: + san_extension = current_cert.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ) + except x509.ExtensionNotFound: + # Handle the case where an old certificate doesn't have a SAN + # extension and always reissue in that case. + current_domains = [] + else: + current_domains = san_extension.value.get_values_for_type( + x509.DNSName + ) - if ( - days_until_expiration > CERTIFICATE_EXPIRATION_THRESHOLD and - # If the set of hosts we want for our certificate changes, we update - # even if the current certificate isn't expired. - sorted(current_domains) == sorted(cert_request.hosts) and - not force_issue - ): - return + if ( + not force_issue and + days_until_expiration > CERTIFICATE_EXPIRATION_THRESHOLD and + # If the set of hosts we want for our certificate changes, + # we update even if the current certificate isn't expired. + sorted(current_domains) == sorted(cert_request.hosts) + ): + return if cert_request.key_type == "rsa": private_key = generate_rsa_private_key() @@ -375,18 +459,18 @@ def update_elb(logger, acme_client, force_issue, cert_request): for host in cert_request.hosts: authz_record = start_dns_challenge( logger, acme_client, cert_request.dns_challenge_completer, - cert_request.cert_location.elb_name, host, + cert_request.cert_location.name, host, ) authorizations.append(authz_record) for authz_record in authorizations: complete_dns_challenge( logger, acme_client, cert_request.dns_challenge_completer, - cert_request.cert_location.elb_name, authz_record + cert_request.cert_location.name, authz_record ) pem_certificate, pem_certificate_chain = request_certificate( - logger, acme_client, cert_request.cert_location.elb_name, + logger, acme_client, cert_request.cert_location.name, authorizations, csr ) @@ -398,7 +482,7 @@ def update_elb(logger, acme_client, force_issue, cert_request): for authz_record in authorizations: logger.emit( "updating-elb.delete-txt-record", - elb_name=cert_request.cert_location.elb_name, + source=cert_request.cert_location.name, host=authz_record.host ) dns_challenge = authz_record.dns_challenge @@ -475,10 +559,11 @@ def update_certificates(persistent=False, force_issue=False): raise ValueError("Can't specify both --persistent and --force-issue") session = boto3.Session() - s3_client = session.client("s3") - elb_client = session.client("elb") route53_client = session.client("route53") + s3_client = session.client("s3") iam_client = session.client("iam") + elb_client = session.client("elb") + cloudfront_client = session.client("cloudfront") config = json.loads(os.environ["LETSENCRYPT_AWS_CONFIG"]) domains = config["domains"] @@ -497,6 +582,11 @@ def update_certificates(persistent=False, force_issue=False): elb_client, iam_client, domain["elb"]["name"], int(domain["elb"].get("port", 443)) ) + elif "cloudfront" in domain: + cert_location = CloudFrontCertificate( + cloudfront_client, iam_client, + domain["cloudfront"]["id"], + ) else: raise ValueError( "Unknown certificate location: {!r}".format(domain) diff --git a/requirements.txt b/requirements.txt index 4b74053..a8151ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -# Branch for https://github.com/letsencrypt/letsencrypt/pull/2061 --e git+https://github.com/wteiken/letsencrypt@add_dns01_challenge#subdirectory=acme&egg=acme[dns] +-e git+https://github.com/certbot/certbot#subdirectory=acme&egg=acme[dns] boto3>=1.2.3 click>=6.2 cryptography>=1.1.2