CloudFormation to Build a CDN With (Free) Custom SSL

Get a Fast CDN With ACM SSL Certificates in 10 Minutes

Posted by Ryan S. Brown on Mon, Feb 1, 2016
In General
Tags: ssl, security, tls, cloudformation

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 Launch stack TestStack . 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: Launch stack TestStack

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.


Tweet this, send to Hackernews, or post on Reddit