From d733fd71e3bdcfc22b65bf843b544696e85cb5fd Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Fri, 8 Apr 2016 20:57:45 -0400 Subject: [PATCH 1/8] Fixed #41 -- allow updating cloudfront certificates --- README.md | 7 +++ letsencrypt-aws.py | 124 +++++++++++++++++++++++++++++++++------------ 2 files changed, 99 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 1ea7f1a..1e55226 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 f4b7dfe..9f8276a 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -54,6 +54,34 @@ def __init__(self, cert_location, dns_challenge_completer, hosts, self.key_type = key_type +def _expiration_date_for_iam_certificate(iam_client, certificate_id): + paginator = iam_client.get_paginator("list_server_certificates") + for page in paginator.paginate(): + for server_certificate in page["ServerCertificateMetadataList"]: + if server_certificate["Arn"] == certificate_id: + return server_certificate["Expiration"].date() + + +def _upload_iam_certificate(iam_client, hosts, private_key, pem_certificate, + pem_certificate_chain): + 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, + ) + return response["ServerCertificateMetadata"]["Arn"] + + class ELBCertificate(object): def __init__(self, elb_client, iam_client, elb_name, elb_port): self.elb_client = elb_client @@ -72,11 +100,9 @@ def get_expiration_date(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: - return server_certificate["Expiration"].date() + return _expiration_date_for_iam_certificate( + self.iam_client, certificate_id + ) def update_certificate(self, logger, hosts, private_key, pem_certificate, pem_certificate_chain): @@ -84,22 +110,10 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, "updating-elb.upload-iam-certificate", elb_name=self.elb_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 = _upload_iam_certificate( + self.iam_client, + hosts, private_key, pem_certificate, pem_certificate_chain ) - new_cert_arn = response["ServerCertificateMetadata"]["Arn"] # Sleep before trying to set the certificate, it appears to sometimes # fail without this. @@ -112,6 +126,46 @@ 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.distribution_id = distribution_id + + def get_expiration_date(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. + assert cert.get("IAMCertificateId") + return _expiration_date_for_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_arn = _upload_iam_certificate( + self.iam_client, + hosts, private_key, pem_certificate, pem_certificate_chain + ) + + # Sleep before trying to set the certificate, it appears to sometimes + # fail without this. + time.sleep(15) + + config = self.cloudfront_client.get_distribution_config( + Id=self.distribution_id + ) + cert = config["DistributionConfig"]["ViewerCertificate"] + cert["IAMCertificateId"] = new_cert_arn + + logger.emit("set-cloudfront-distribution-certificate") + self.cloudfront_client.update_distribution(DistributionConfig=config) + + class Route53ChallengeCompleter(object): def __init__(self, route53_client): self.route53_client = route53_client @@ -445,18 +499,12 @@ 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") - # Structure: { - # "domains": [ - # {"elb": {"name" "...", "port" 443}, hosts: ["..."]} - # ], - # "acme_account_key": "s3://bucket/object", - # "acme_directory_url": "(optional)" - # } config = json.loads(os.environ["LETSENCRYPT_AWS_CONFIG"]) domains = config["domains"] acme_directory_url = config.get( @@ -469,11 +517,23 @@ def update_certificates(persistent=False, force_issue=False): certificate_requests = [] for domain in domains: - certificate_requests.append(CertificateRequest( - ELBCertificate( + if "elb" in domain: + cert_location = ELBCertificate( 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) + ) + + certificate_requests.append(CertificateRequest( + cert_location, Route53ChallengeCompleter(route53_client), domain["hosts"], domain.get("key_type", "rsa"), From bd59c7a4df31159e827f94817f993376cf5ca8c5 Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Sat, 16 Apr 2016 08:44:21 -0400 Subject: [PATCH 2/8] remove self --- letsencrypt-aws.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 6b567a6..8807096 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -55,12 +55,12 @@ def __init__(self, cert_location, dns_challenge_completer, hosts, def _get_iam_certificate(iam_client, certificate_id): - paginator = self.iam_client.get_paginator("list_server_certificates") + paginator = 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( + response = iam_client.get_server_certificate( ServerCertificateName=cert_name, ) return x509.load_pem_x509_certificate( From 5d5639199458f7b3656ffa78748b8ea96564aa0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Fri, 13 May 2016 02:37:07 +0200 Subject: [PATCH 3/8] Replace references to elb_name with source.name --- letsencrypt-aws.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 5262ba9..42589cf 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -93,6 +93,7 @@ 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 @@ -112,7 +113,7 @@ def get_current_certificate(self): 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 ) new_cert_arn = _upload_iam_certificate( @@ -123,7 +124,7 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, # 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, @@ -135,6 +136,7 @@ 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): @@ -303,9 +305,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 @@ -314,7 +316,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), @@ -330,10 +332,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) @@ -341,7 +343,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, @@ -353,13 +355,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( @@ -380,12 +382,12 @@ 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, + source=cert_request.cert_location.name, expiration_date=current_cert.not_valid_after ) days_until_expiration = ( @@ -427,18 +429,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 ) @@ -450,7 +452,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 From f1810ae1c770471be55ed7910ee377119c835cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Fri, 13 May 2016 02:41:18 +0200 Subject: [PATCH 4/8] Proceed when no certificate is configured. Allows adding a certificate to new CloudFront distributions that didn't have any certificate yet. --- letsencrypt-aws.py | 66 +++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 42589cf..bb176cb 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -146,10 +146,12 @@ def get_current_certificate(self): cert = response["DistributionConfig"]["ViewerCertificate"] # If the cert is of a different type then we don't have code to check # for it, annoying. - assert cert.get("IAMCertificateId") - return _get_iam_certificate( - self.iam_client, cert["IAMCertificateId"] - ) + 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): @@ -385,34 +387,38 @@ def update_elb(logger, acme_client, force_issue, cert_request): 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", - source=cert_request.cert_location.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) - - 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 + 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 ( + 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() From a718796e8a7769e42c230bf285bcada83deb5656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Fri, 13 May 2016 02:44:00 +0200 Subject: [PATCH 5/8] Fix update_distribution parameters. --- letsencrypt-aws.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index bb176cb..fdc3f59 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -86,7 +86,7 @@ def _upload_iam_certificate(iam_client, hosts, private_key, pem_certificate, CertificateBody=pem_certificate, CertificateChain=pem_certificate_chain, ) - return response["ServerCertificateMetadata"]["Arn"] + return response["ServerCertificateMetadata"] class ELBCertificate(object): @@ -119,7 +119,7 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, 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. @@ -156,23 +156,41 @@ def get_current_certificate(self): def update_certificate(self, logger, hosts, private_key, pem_certificate, pem_certificate_chain): logger.emit("upload-iam-certificate") - new_cert_arn = _upload_iam_certificate( + new_cert_id = _upload_iam_certificate( self.iam_client, hosts, private_key, pem_certificate, pem_certificate_chain - ) + )["ServerCertificateId"] # Sleep before trying to set the certificate, it appears to sometimes # fail without this. time.sleep(15) - config = self.cloudfront_client.get_distribution_config( + response = self.cloudfront_client.get_distribution_config( Id=self.distribution_id ) - cert = config["DistributionConfig"]["ViewerCertificate"] - cert["IAMCertificateId"] = new_cert_arn + 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(DistributionConfig=config) + self.cloudfront_client.update_distribution( + Id=self.distribution_id, + DistributionConfig=config, + IfMatch=etag + ) class Route53ChallengeCompleter(object): From 6148e7824e60393de1c25f0f3b9c7a10f7e25a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Mon, 16 May 2016 21:21:54 +0200 Subject: [PATCH 6/8] CloudFront looks for certs at /cloudfront/ path. --- letsencrypt-aws.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index fdc3f59..f20ecb9 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -70,7 +70,7 @@ def _get_iam_certificate(iam_client, certificate_id): def _upload_iam_certificate(iam_client, hosts, private_key, pem_certificate, - pem_certificate_chain): + pem_certificate_chain, path="/"): response = iam_client.upload_server_certificate( ServerCertificateName=generate_certificate_name( hosts, @@ -85,6 +85,7 @@ def _upload_iam_certificate(iam_client, hosts, private_key, pem_certificate, ), CertificateBody=pem_certificate, CertificateChain=pem_certificate_chain, + Path=path, ) return response["ServerCertificateMetadata"] @@ -158,7 +159,8 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, logger.emit("upload-iam-certificate") new_cert_id = _upload_iam_certificate( self.iam_client, - hosts, private_key, pem_certificate, pem_certificate_chain + hosts, private_key, pem_certificate, pem_certificate_chain, + path="/cloudfront/" )["ServerCertificateId"] # Sleep before trying to set the certificate, it appears to sometimes From 386e6a8a9a58589277dbc95d17f74d499739a50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Tue, 17 May 2016 16:02:22 +0200 Subject: [PATCH 7/8] Fix fetching of existing CloudFront cert. --- letsencrypt-aws.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index f20ecb9..7b94c1a 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -54,11 +54,15 @@ def __init__(self, cert_location, dns_challenge_completer, hosts, self.key_type = key_type -def _get_iam_certificate(iam_client, certificate_id): +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"] == certificate_id: + 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, From 463418f8e95fb0cdabe31a5a48fe8af2c88d566b Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Mon, 1 Aug 2016 19:04:14 -0400 Subject: [PATCH 8/8] No longer point at the PR --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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