Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Finished CloudFront support #61

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
232 changes: 161 additions & 71 deletions letsencrypt-aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -72,53 +113,92 @@ 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,
LoadBalancerPort=self.elb_port,
)


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
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -278,18 +358,18 @@ 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)

response = authz_record.dns_challenge.response(acme_client.key)

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,
Expand All @@ -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(
Expand All @@ -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()
Expand All @@ -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
)

Expand All @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down