Certificate management has historically been fairly manual, costly and often related to trial and error (or long documentation). AWS ACM based certificates removed most of the pain.
ACM offers Email and DNS based validation. Email adds overhead in two ways. First, you need an Email address for the valid host (and you might not have an app.your-company.com Email address, forcing you into setting up AWS SES). Second, you need to regularly re-validate the certificates. DNS based validation removes those hassles and is the recommended way.
Remaining is still its setup. AWS CloudFormation (CF) offers creating a Certificate resource. Attaching DNS validation however isn’t straight forward, and the best way I could find so far was leveraging a Lambda function, which can be inlined in the CF template.
In short, the template creates the following resources:
- An IAM role to execute the AWS Lambda function.
- An AWS Lambda function that creates and deletes ACM certificates, and returns the created AWS Route53 RecordSet values that must be used for DNS validation.
- An AWS Route53 RecordSet matching the ACM certificate’s DNS validation settings.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
{ "AWSTemplateFormatVersion": "2010-09-09", "Parameters": { "hostedZoneName": { "Type": "String", "Description": "The name of the new Hosted Zone to create" } }, "Resources": { "CreateAndReturnAcmCertificateArnRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Service": ["lambda.amazonaws.com"] }, "Action": ["sts:AssumeRole"] }] }, "ManagedPolicyArns": [ "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", "arn:aws:iam::aws:policy/AWSCertificateManagerFullAccess" ] } }, "CreateAndReturnAcmCertificateArnLambdaFunction": { "Type": "AWS::Lambda::Function", "Properties": { "FunctionName": { "Fn::Join": [ "-", [ { "Fn::Join": [ "-", { "Fn::Split" : [ ".", { "Ref": "hostedZoneName" } ] } ] }, "CertResolver" ] ] }, "Code": { "ZipFile": { "Fn::Join": [ "\n", [ "// http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html", "const aws = require('aws-sdk');", "const cloudFormationResponseHandler = require('cfn-response');", "let acmClient = new aws.ACM();", "exports.handler = function(event, context) {", " let domainName = event.ResourceProperties.DomainName;", " if (event.RequestType === 'Delete') {", " return acmClient.listCertificates({}).promise()", " .then(certs => {", " let foundCert = certs.CertificateSummaryList.find(cert => cert.DomainName === domainName);", " if (foundCert) {", " console.log('Certificate Deleted', foundCert.CertificateArn);", " return acmClient.deleteCertificate({ CertificateArn: foundCert.CertificateArn }).promise();", " }", " return null;", " })", " .then(() => {", " return cloudFormationResponseHandler.send(event, context, cloudFormationResponseHandler.SUCCESS);", " })", " .catch(error => {", " return cloudFormationResponseHandler.send(event, context, cloudFormationResponseHandler.FAILED, { title: 'Failed to delete Certificate', error: error });", " });", " }", " ", " if (event.RequestType !== 'Create') {", " return cloudFormationResponseHandler.send(event, context, cloudFormationResponseHandler.SUCCESS);", " }", " ", " return acmClient.requestCertificate({ DomainName: domainName, SubjectAlternativeNames: [`*.${domainName}`], ValidationMethod: 'DNS' }).promise()", " .then(data => {", " return new Promise(resolve => setTimeout(resolve, 20000))", " .then(() => {", " return acmClient.describeCertificate({ CertificateArn: data.CertificateArn }).promise()", " .then(validationData => {", " let response = {", " CertificateArn: data.CertificateArn,", " VerificationRecordName: validationData.Certificate.DomainValidationOptions[0].ResourceRecord.Name,", " VerificationRecordValue: validationData.Certificate.DomainValidationOptions[0].ResourceRecord.Value", " };", " console.log('Certificate created', response);", " return cloudFormationResponseHandler.send(event, context, cloudFormationResponseHandler.SUCCESS, response);", " });", " });", " })", " .catch(error => {", " return cloudFormationResponseHandler.send(event, context, cloudFormationResponseHandler.FAILED, { title: 'Failed to created Certificate', error: error });", " });", "}" ]] } }, "Handler": "index.handler", "Runtime": "nodejs6.10", "Timeout": "30", "Role": { "Fn::GetAtt": [ "CreateAndReturnAcmCertificateArnRole", "Arn" ] } } }, "AcmCertificateForHostedZone": { "Type": "Custom::LambdaCallout", "Properties": { "ServiceToken": { "Fn::GetAtt": [ "CreateAndReturnAcmCertificateArnLambdaFunction", "Arn" ] }, "DomainName": { "Ref": "hostedZoneName" } } }, "AcmCertificateValidationForHostedZone": { "Type": "AWS::Route53::RecordSet", "Properties": { "HostedZoneName": { "Fn::Join": [ "", [ { "Ref": "hostedZoneName" }, "." ] ] }, "Name": { "Fn::GetAtt": [ "AcmCertificateForHostedZone", "VerificationRecordName" ] }, "ResourceRecords": [{ "Fn::GetAtt": [ "AcmCertificateForHostedZone", "VerificationRecordValue" ] }], "TTL": "300", "Type": "CNAME" }, "DependsOn": "AcmCertificateForHostedZone" } } } |