Recently, Amazon announced free SSL certs via AWS Certificate Manager, or ACM. This week, we’ll use CloudFormation to provision ACM certificates automatically. I built my own custom ACM resource in Python. With this resource, you can configure a CloudFront distribution for your static content and get a custom SSL for your domain.
In this post, we’ll use custom resources to issue an ACM certificate, then associate that certificate to a CloudFront distribution.
Issuing a Certificate
We’ll start with the template from this post that already issues SSL certificates with ACM and build up from there. For an more about that template, go to the post on making custom resources or launch it . It already creates the function and ACM certificate custom resource like so:
"AcmCertificate": {
"Type": "Custom::AcmCertificate",
"Properties": {
"Domains": {
"Ref": "Domains"
},
"ServiceToken": {
"Fn::GetAtt": [
"AcmRegistrationFunction",
"Arn"
]
},
"Await": true
}
}
We’ll have to add a CloudFront distribution, for testing we’ll just point it at the S3 bucket for my personal website. Of course, you’ll want to substitute in your own origin.
"SiteCDN": {
"Type": "AWS::CloudFront::Distribution",
"Description": "CDN for site content",
"Properties": {
"DistributionConfig": {
"DefaultCacheBehavior": {
"ViewerProtocolPolicy": "allow-all",
"ForwardedValues": {
"QueryString": true
},
"TargetOriginId": "static-site-origin",
"MinTTL": 300
},
"Origins": [
{
"DomainName": "rsb.io.s3-website-us-east-1.amazonaws.com",
"Id": "static-site-origin",
"CustomOriginConfig": {
"OriginProtocolPolicy": "http-only",
"HTTPPort": 80,
"HTTPSPort": 443
}
}
],
"PriceClass": "PriceClass_100",
"DefaultRootObject": "index.html",
"Enabled": true,
"Aliases": {
"Ref": "Domains"
}
}
}
}
You’ll notice that this resource doesn’t make use of the ACM certificate. The
reason it can’t is because the CloudFormation resource only allows IAM
certificates even though ACM certificates are available in the CloudFront API.
To get around this, I built the Custom::CloudFrontAcmAssociation
resource
that takes a distribution and an ACM certificate and sets it up with
CloudFront.
We can associate the two like so:
"DistributionCertificateSetting": {
"Type": "Custom::CloudFrontAcmAssociation",
"Properties": {
"DistributionId": {
"Ref": "SiteCDN"
},
"CertificateArn": {
"Ref": "AcmCertificate"
},
"ServiceToken": {
"Fn::GetAtt": [
"AcmAssociationFunction",
"Arn"
]
}
}
},
To launch this stack, click here:
Launching the stack will take some time - it can take up to 30 minutes for the CloudFront distribution to be created. You should receive an email to your domain administration email asking you to confirm the certificate, click through that as soon as possible. The full template is available here if you want to review it.
What’s Under the Hood
While we’re waiting, let’s look at how these resources work. The code for all
these resources is on Github at ryansb/acm-certs-cloudformation. For issuing ACM certificates, we need a CREATE
method that takes the
parameters we specified earlier.
@handler.create
def create_cert(event, context):
# get the properties set in the CloudFormation resource
props = event['ResourceProperties']
domains = props['Domains'] # list of domain names
# take a hash of the Stack & resource ID to make a request token
id_token = hashlib.md5('cfn-{StackId}-{LogicalResourceId}'.format(
**event)).hexdigest()
This part of the code is what generates the IdempotencyToken
needed by the
ACM API to deduplicate requests. This token is unique per
stack/resource combination, so if for some reason the request is sent twice for
the same resource, two certificates aren’t issued.
To make the token unique, we take the hash of the (globally unique) Stack ID and the (unique within a stack) logical ID of the resource. This way we know for a fact that there won’t be duplicates.
kwargs = {
'DomainName': domains[0],
# the idempotency token length limit is 31 characters
'IdempotencyToken': id_token[:30]
}
if len(domains) > 1:
# add alternative names if the user wants more names
# wildcards are allowed
kwargs['SubjectAlternativeNames'] = domains[1:]
if props.get('ValidationOptions'):
""" List of domain validation options. Looks like:
{
"DomainName": "test.foo.com",
"ValidationDomain": "foo.com",
}
"""
kwargs['DomainValidationOptions'] = props.get('ValidationOptions')
# here we actually make the request
response = acm.request_certificate(**kwargs)
if props.get('Await', False):
await_validation(domains[0], context)
return {
'Status': 'SUCCESS',
'Reason': 'Cert request created successfully',
'PhysicalResourceId': response['CertificateArn'],
'Data': {},
}
Full code here
With that, we’ve succeeded in creating a request for a certificate. To wait for
it, we’ll need to add an await_validation
method that checks to see if the
certificate is issued.
def await_validation(domain, context):
# as long as we have at least 10 seconds left
while context.get_remaining_time_in_millis() > 10000:
time.sleep(5)
resp = acm.list_certificates(CertificateStatuses=['ISSUED'])
if any(cert['DomainName'] == domain for cert in resp['CertificateSummaryList']):
cert_info = [cert for cert in resp['CertificateSummaryList']
if cert['DomainName'] == domain][0]
log.info("Certificate has been issued for domain %s, ARN: %s" %
(domain, cert_info['CertificateArn']))
return cert_info['CertificateArn']
log.info("Awaiting cert for domain %s" % domain)
log.warning("Timed out waiting for cert for domain %s" % domain)
This method uses the context object to see how much execution time
we have left. If there’s still time, it will wait a bit and check if the
certificate is ready yet. We only use this method if the user set "Await": true
, otherwise we just issue the request for the certificate and return
SUCCESS
.
Deletion is even simpler, because there’s nothing to check. When creating the
certificate we tell CloudFormation the ARN of the certificate, and it sends it
with the DELETE
request. We just need to use the PhysicalResourceId
where
the ARN is saved to delete the certificate.
@handler.delete
def delete_certificate(event, context):
resp = {'Status': 'SUCCESS',
'PhysicalResourceId': event['PhysicalResourceId'],
'Data': {},
}
try:
acm.delete_certificate(CertificateArn=event['PhysicalResourceId'])
except:
log.exception('Failure deleting cert with arn %s' % event['PhysicalResourceId'])
resp['Reason'] = 'Some exception was raised while deleting the cert'
return resp
That’s about it for the Python to handle the creating/deleting ACM certificates. What about associating them with CloudFront distributions?
def create_cert_association(event, context):
props = event['ResourceProperties']
cert_arn = props['CertificateArn']
dist_id = props['DistributionId']
response = cloudfront.get_distribution_config(Id=dist_id)
config = response['DistributionConfig']
# we need to send the ETag of the configuration back with our change
# request to make sure there isn't a config change we missed
etag = response['ETag']
reason = ''
if config.get('ViewerCertificate') is None:
return {
'Status': 'FAILED',
'Reason': 'No viewercert configuration',
'Data': {}
}
elif config['ViewerCertificate'].get('CertificateSource', '') == 'acm':
if config['ViewerCertificate']['Certificate'] == cert_arn:
log.debug('Already configured - nothing to do')
reason = 'Already connected, easy!'
else:
associate_cert(cert_arn, dist_id, config, etag)
reason = 'Changed ACM cert ID'
else:
associate_cert(cert_arn, dist_id, config, etag)
reason = 'Associated ACM cert'
return {
'Status': 'SUCCESS',
'Reason': reason,
'PhysicalResourceId': generate_phys_id(cert_arn, dist_id),
'Data': {},
}
Full code is here
Now that we’ve seen how the associations work, and how the cert is issued, let’s check back in the CloudFormation console to see if the stack is done yet.
Wrapping Up
Now we need to check if the certificate works. If you don’t want to set your
DNS to point to CloudFront, you can use the --resolve
option with curl
to
fake it locally for testing.
$ curl -v -s --resolve yourdomain.com:443:$(dig +short A [cloudfront name] | head -n1) https://yourdomain.com >/dev/null
You’ll need to fill in [cloudfront name]
to be the DNS name of the CloudFront
distribution. you can get this from the CloudFront web console. For me (using
serverlesscode.com
), the output looks like this:
* Trying 52.84.29.120...
* Connected to serverlesscode.com (52.84.29.120) port 443 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
CApath: none
* SSL connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate:
* subject: CN=serverlesscode.com
* start date: Jan 21 00:00:00 2016 GMT
* expire date: Feb 21 12:00:00 2017 GMT
* common name: serverlesscode.com
* issuer: CN=Amazon,OU=Server CA 1B,O=Amazon,C=US
As you can see, the certificate is the Amazon-issued ACM cert, and is accepted by just about every OS. Congratulations! Now you’re set up with free SSL and a great CDN, all from one CloudFormation stack.
Keep up with future posts via RSS. If you have suggestions, questions, or comments my email is ryan@serverlesscode.com.