cf-custom-resources/lib/dns-cert-validator.js (577 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/* jshint node: true */
/* jshint esversion: 8 */
"use strict";
const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers");
const { ACM, RequestCertificateCommand, DescribeCertificateCommand, ListCertificatesCommand,
DeleteCertificateCommand, waitUntilCertificateValidated } = require("@aws-sdk/client-acm");
const { Route53, ListHostedZonesByNameCommand, ChangeResourceRecordSetsCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53");
const defaultSleep = function (ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
};
// These are used for test purposes only
let defaultResponseURL;
let defaultLogGroup;
let defaultLogStream;
let sleep = defaultSleep;
let random = Math.random;
let maxAttempts = 10;
let domainTypes;
/**
* Upload a CloudFormation response object to S3.
*
* @param {object} event the Lambda event payload received by the handler function
* @param {object} context the Lambda context received by the handler function
* @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED'
* @param {string} physicalResourceId CloudFormation physical resource ID
* @param {object} [responseData] arbitrary response data object
* @param {string} [reason] reason for failure, if any, to convey to the user
* @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response
*/
let report = function (
event,
context,
responseStatus,
physicalResourceId,
responseData,
reason
) {
return new Promise((resolve, reject) => {
const https = require("https");
const { URL } = require("url");
let responseBody = JSON.stringify({
Status: responseStatus,
Reason: reason,
PhysicalResourceId: physicalResourceId || context.logStreamName,
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
Data: responseData,
});
const parsedUrl = new URL(event.ResponseURL || defaultResponseURL);
const options = {
hostname: parsedUrl.hostname,
port: 443,
path: parsedUrl.pathname + parsedUrl.search,
method: "PUT",
headers: {
"Content-Type": "",
"Content-Length": responseBody.length,
},
};
https
.request(options)
.on("error", reject)
.on("response", (res) => {
res.resume();
if (res.statusCode >= 400) {
reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`));
} else {
resolve();
}
})
.end(responseBody, "utf8");
});
};
const aliasesChanged = function (
oldResourceProperties,
aliases,
) {
if (!oldResourceProperties) {
return true;
}
let prevAliases = getAllAliases(
oldResourceProperties.Aliases
);
let aliasesToDelete = [...prevAliases,].filter(function (itm) {
return !aliases.has(itm);
});
let aliasesToAdd = [...aliases,].filter(function (itm) {
return !prevAliases.has(itm);
});
return aliasesToAdd.length + aliasesToDelete.length;
};
/**
* Requests a public certificate from AWS Certificate Manager, using DNS validation
* (see https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html).
*
* @param {string} requestId the CloudFormation request ID
* @param {string} appName the application name
* @param {string} envName the environment name
* @param {string} certDomain the domain of the certificate
* @param {string[]} aliases the custom domain aliases
* @param {string[]} sansToUse the subject alternative name to add to the certificate
* @param {object} acm the AWS ACM client to use for sending requests
* @returns {string} ARN of the requested certificate
*/
const requestCertificate = async function (
requestId,
appName,
envName,
certDomain,
aliases,
sansToUse,
acm
) {
const crypto = require("crypto");
return acm
.send(new RequestCertificateCommand({
DomainName: certDomain,
SubjectAlternativeNames: sansToUse,
IdempotencyToken: crypto
.createHash("sha256")
.update(requestId)
.digest("hex")
.substr(0, 32),
ValidationMethod: "DNS",
Tags: [
{
Key: "copilot-application",
Value: appName,
},
{
Key: "copilot-environment",
Value: envName,
},
],
}));
};
/**
* Wait until the validation options are ready
*
* @param certificateARN the requested certificate for which we are waiting for the validation options to be ready
* @param sansToUse the subject alternative names added to the certificate
* @param acm the client to use
* @returns the validation options
*/
const waitForValidationOptionsToBeReady = async function(
certificateARN,
sansToUse,
acm,
){
let options;
let attempt;
const expectedValidationOptionsNum = sansToUse.length;
for (attempt = 0; attempt < maxAttempts; attempt++) {
const { Certificate } = await acm
.send(new DescribeCertificateCommand({
CertificateArn: certificateARN,
}));
options = Certificate.DomainValidationOptions || [];
let readyRecordsNum = 0;
for (const option of options) {
if (option.ResourceRecord) {
readyRecordsNum++;
}
}
if (readyRecordsNum === expectedValidationOptionsNum) {
break;
}
// Exponential backoff with jitter based on 200ms base
// component of backoff fixed to ensure minimum total wait time on
// slow targets.
const base = Math.pow(2, attempt);
await sleep(random() * base * 50 + base * 150);
}
if (attempt === maxAttempts) {
throw new Error(
`DescribeCertificate did not contain DomainValidationOptions after ${maxAttempts} tries.`
);
}
return options;
};
/**
* Validate DNS in the root, app, and env hosted zones in parallel, and wait until the certificate is validated.
* The root hosted zone is created when the user purchases example.com in route53 in their app account.
* We create the app hosted zone "app.example.com" when running "app init" part of the application stack.
* The env hosted zone "env.app.example.com" is created when running "env init" part of the env stack.
*
* @param options
* @param envRoute53
* @param appRoute53
* @param envHostedZoneId
* @param certificateARN
* @param acm
* @returns {Promise<>} promise for whether the wait succeeds
*/
const validateCertificate = async function(
options,
envRoute53,
appRoute53,
envHostedZoneId,
certificateARN,
acm
){
await updateHostedZoneRecords(
"UPSERT",
options,
envRoute53,
appRoute53,
envHostedZoneId
);
await exports.waitForCertificateValidation(certificateARN,acm);
};
const updateHostedZoneRecords = async function (
action,
options,
envRoute53,
appRoute53,
envHostedZoneId
) {
const promises = [];
for (const option of options) {
const domainType = await getDomainType(option.DomainName);
switch (domainType) {
case domainTypes.EnvDomainZone:
promises.push(
validateDomain({
route53: envRoute53,
record: option.ResourceRecord,
action: action,
domainName: "",
hostedZoneId: envHostedZoneId,
})
);
break;
case domainTypes.AppDomainZone:
promises.push(
validateDomain({
route53: appRoute53,
record: option.ResourceRecord,
action: action,
domainName: domainType.domain,
})
);
break;
case domainTypes.RootDomainZone:
promises.push(
validateDomain({
route53: appRoute53,
record: option.ResourceRecord,
action: action,
domainName: domainType.domain,
})
);
break;
}
}
return Promise.all(promises);
};
// deleteHostedZoneRecords deletes the validation records associated with the certificate.
// We don't want to delete a validation record if it's used by another certificate because
// the validation records are used to renew the certificate.
const deleteHostedZoneRecords = async function (
oldCertOptions,
oldCertArn,
defaultDomain,
envRoute53,
appRoute53,
acm,
envHostedZoneId
) {
let listCertificatesInput = {};
let newCertOptions = [];
// Look for the new certificate, and note down its validation records.
// This is to make sure if there is a new certificate, we don't delete any DNS validation records that are used by the new certificate.
let isNewCertFound = false;
while (!isNewCertFound) {
const listCertResp = await acm
.send(new ListCertificatesCommand(listCertificatesInput));
for (const certSummary of listCertResp.CertificateSummaryList || []) {
if (
certSummary.DomainName !== defaultDomain ||
certSummary.CertificateArn === oldCertArn
) {
// Skip if it is not the new certificate for the domain.
continue;
}
// There exists another certificate created by Copilot which has the updated alias fields as SANs.
// We don't want to delete any validation records associated with the new certificate.
const { Certificate } = await acm
.send(new DescribeCertificateCommand({
CertificateArn: certSummary.CertificateArn,
}));
newCertOptions = Certificate.DomainValidationOptions || [];
isNewCertFound = true;
break;
}
if (!listCertResp.NextToken) {
break;
}
listCertificatesInput.NextToken = listCertResp.NextToken;
}
const newCertSANs = new Set(newCertOptions.map((item) => item.DomainName));
const recordOptionsToDelete = [];
for (const oldCertOption of oldCertOptions) {
if (!newCertSANs.has(oldCertOption.DomainName)) {
// This alias field is no longer in use, we can safely delete its validation.
recordOptionsToDelete.push(oldCertOption);
}
}
// Make sure DNS validation records are unique. For example: "example.com" and "*.example.com"
// might have the same DNS validation record.
const filteredRecordOption = [];
let uniqueValidateRecordNames = new Set();
for (const option of recordOptionsToDelete) {
let id = `${option.ResourceRecord.Name} ${option.ResourceRecord.Value}`;
if (uniqueValidateRecordNames.has(id)) {
continue;
}
uniqueValidateRecordNames.add(id);
filteredRecordOption.push(option);
}
try {
await updateHostedZoneRecords(
"DELETE",
filteredRecordOption,
envRoute53,
appRoute53,
envHostedZoneId
);
} catch (e) {
let recordSetNotFoundErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but it was not found.*");
if (recordSetNotFoundErrMessageRegex.test(e.message)) {
return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting.
}
throw new Error(`delete record ${option.ResourceRecord.Name}: ` + e.message);
}
};
const validateDomain = async function ({
route53,
record,
action,
domainName,
hostedZoneId,
}) {
if (!hostedZoneId) {
const hostedZones = await route53
.send(new ListHostedZonesByNameCommand({
DNSName: domainName,
MaxItems: "1",
}));
if (!hostedZones.HostedZones || hostedZones.HostedZones.length === 0) {
throw new Error(
`Couldn't find any Hosted Zone with DNS name ${domainName}.`
);
}
hostedZoneId = hostedZones.HostedZones[0].Id.split("/").pop();
}
console.log(
`${action} DNS record into Hosted Zone ${hostedZoneId}: ${record.Name} ${record.Type} ${record.Value}`
);
const changeBatch = await updateRecords(
route53,
hostedZoneId,
action,
record.Name,
record.Type,
record.Value
);
await exports.waitForRecordChange(route53, changeBatch.ChangeInfo.Id);
};
/**
* Deletes a certificate from AWS Certificate Manager (ACM) by its ARN.
* Specifically, if it is the last certificate attaching to the listener, it will also remove the CNAME records
* for validation in all the root, app, and env hosted zones in parallel.
* If the certificate does not exist, the function will return normally.
*
* @param {string} arn The certificate ARN
* @param {string} certDomain the domain of the certificate
* @param {string} envHostedZoneId the environment Route53 Hosted Zone ID
* @param {string} rootDnsRole the IAM role ARN that can manage domainName
* @param {string} region the environment region
*/
const deleteCertificate = async function (
arn,
certDomain,
region,
envHostedZoneId,
rootDnsRole
) {
const [acm, envRoute53, appRoute53] = clients(region, rootDnsRole);
try {
console.log(`Waiting for certificate ${arn} to become unused`);
let inUseByResources;
let options;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const { Certificate } = await acm
.send(new DescribeCertificateCommand({
CertificateArn: arn,
}));
inUseByResources = Certificate.InUseBy || [];
options = Certificate.DomainValidationOptions || [];
let ok = false;
for (const option of options) {
if (!option.ResourceRecord) {
ok = false;
break;
}
ok = true;
}
if (!ok || inUseByResources.length) {
// Deleting resources can be quite slow - so just sleep 30 seconds between checks.
await sleep(30000);
} else {
break;
}
}
if (inUseByResources.length) {
throw new Error(
`Certificate still in use after checking for ${maxAttempts} attempts.`
);
}
await deleteHostedZoneRecords(
options,
arn,
certDomain,
envRoute53,
appRoute53,
acm,
envHostedZoneId
);
await acm
.send(new DeleteCertificateCommand({
CertificateArn: arn,
}));
} catch (err) {
if (err.name !== "ResourceNotFoundException") {
throw err;
}
}
};
const waitForRecordChange = async function (route53, changeId) {
// wait upto 5 minutes.
await waitUntilResourceRecordSetsChanged({
client: route53,
maxWaitTime: 300,
minDelay: 30,
maxDelay: 30,
},{
Id: changeId,
});
};
const waitForCertificateValidation = async function (certificateARN, acm) {
// Wait up to 9 minutes and 30 seconds
await waitUntilCertificateValidated({
client:acm,
maxWaitTime: 570,
minDelay: 30,
maxDelay: 30,
},{
CertificateArn: certificateARN
});
};
const updateRecords = function (
route53,
hostedZone,
action,
recordName,
recordType,
recordValue
) {
return route53
.send(new ChangeResourceRecordSetsCommand({
ChangeBatch: {
Changes: [
{
Action: action,
ResourceRecordSet: {
Name: recordName,
Type: recordType,
TTL: 60,
ResourceRecords: [
{
Value: recordValue,
},
],
},
},
],
},
HostedZoneId: hostedZone,
}));
};
// getAllAliases gets all aliases out from a string. For example:
// {"frontend": ["test.foobar.com", "foobar.com"], "api": ["api.foobar.com"]} will return
// ["test.foobar.com", "foobar.com", "api.foobar.com"].
const getAllAliases = function (aliases) {
let obj;
try {
obj = JSON.parse(aliases || "{}");
} catch (error) {
throw new Error(`Cannot parse ${aliases} into JSON format.`);
}
let aliasList = [];
for (const [_, aliases] of Object.entries(obj)) {
aliasList.push(...aliases);
}
return new Set(
aliasList.filter(function (itm) {
return getDomainType(itm) !== domainTypes.OtherDomainZone;
})
);
};
const getDomainType = function (alias) {
if (domainTypes.EnvDomainZone.regex.test(alias)) {
return domainTypes.EnvDomainZone;
}
if (domainTypes.AppDomainZone.regex.test(alias)) {
return domainTypes.AppDomainZone;
}
if (domainTypes.RootDomainZone.regex.test(alias)) {
return domainTypes.RootDomainZone;
}
return domainTypes.OtherDomainZone;
};
const clients = function (region, rootDnsRole) {
const acm = new ACM({
region,
});
const envRoute53 = new Route53();
const appRoute53 = new Route53({
credentials: fromTemporaryCredentials({
params: { RoleArn: rootDnsRole, },
masterCredentials: fromEnv("AWS"),
}),
});
return [acm, envRoute53, appRoute53, ];
};
/**
* Main certificate manager handler, invoked by Lambda
*/
exports.certificateRequestHandler = async function (event, context) {
let responseData = {};
let physicalResourceId = event.PhysicalResourceId;
const props = event.ResourceProperties;
const [app, env, domain] = [props.AppName, props.EnvName, props.DomainName, ];
domainTypes = {
EnvDomainZone: {
regex: new RegExp(`^([^\.]+\.)?${env}.${app}.${domain}`),
domain: `${env}.${app}.${domain}`,
},
AppDomainZone: {
regex: new RegExp(`^([^\.]+\.)?${app}.${domain}`),
domain: `${app}.${domain}`,
},
RootDomainZone: {
regex: new RegExp(`^([^\.]+\.)?${domain}`),
domain: `${domain}`,
},
OtherDomainZone: {},
};
let certDomain = `${props.EnvName}.${props.AppName}.${props.DomainName}`;
let aliases = getAllAliases(props.Aliases);
const uniqueSansToUse = new Set([certDomain, `*.${certDomain}`, ]);
for (const alias of aliases) {
uniqueSansToUse.add(alias);
}
const sansToUse = [...uniqueSansToUse, ];
const [acm, envRoute53, appRoute53] = clients(props.Region, props.RootDNSRole);
try {
let response = {};
let options = {};
switch (event.RequestType) {
case "Create":
response = await requestCertificate(
event.RequestId,
props.AppName,
props.EnvName,
certDomain,
aliases,
sansToUse,
acm
);
responseData.Arn = physicalResourceId = response.CertificateArn; // Set physicalResourceId as soon as we can.
options = await waitForValidationOptionsToBeReady(response.CertificateArn, sansToUse, acm);
await validateCertificate(options, envRoute53, appRoute53, props.EnvHostedZoneId, response.CertificateArn, acm);
break;
case "Update":
// Exit early if cert doesn't change.
if (!aliasesChanged(event.OldResourceProperties, aliases)) {
break;
}
response = await requestCertificate(
event.RequestId,
props.AppName,
props.EnvName,
certDomain,
aliases,
sansToUse,
acm
);
responseData.Arn = physicalResourceId = response.CertificateArn;
options = await waitForValidationOptionsToBeReady(response.CertificateArn, sansToUse, acm);
await validateCertificate(options, envRoute53, appRoute53, props.EnvHostedZoneId, response.CertificateArn, acm);
break;
case "Delete":
// If the resource didn't create correctly, the physical resource ID won't be the
// certificate ARN, so don't try to delete it in that case.
if (physicalResourceId.startsWith("arn:")) {
await deleteCertificate(
physicalResourceId,
certDomain,
props.Region,
props.EnvHostedZoneId,
props.RootDNSRole
);
}
break;
default:
throw new Error(`Unsupported request type ${event.RequestType}`);
}
await report(event, context, "SUCCESS", physicalResourceId, responseData);
} catch (err) {
console.log(`Caught error ${err}.`);
await report(
event,
context,
"FAILED",
physicalResourceId,
null,
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
defaultLogStream || context.logStreamName
})`
);
}
};
/**
* @private
*/
exports.withDefaultResponseURL = function (url) {
defaultResponseURL = url;
};
/**
* @private
*/
exports.withSleep = function (s) {
sleep = s;
};
/**
* @private
*/
exports.reset = function () {
sleep = defaultSleep;
random = Math.random;
maxAttempts = 10;
};
/**
* @private
*/
exports.withRandom = function (r) {
random = r;
};
/**
* @private
*/
exports.withMaxAttempts = function (ma) {
maxAttempts = ma;
};
/**
* @private
*/
exports.withDefaultLogStream = function (logStream) {
defaultLogStream = logStream;
};
/**
* @private
*/
exports.withDefaultLogGroup = function (logGroup) {
defaultLogGroup = logGroup;
};
/**
* @private
*/
exports.waitForCertificateValidation = function (certificateARN, acm) {
return waitForCertificateValidation(certificateARN, acm);
}
/**
* @private
*/
exports.waitForRecordChange = function (route53, changeId) {
return waitForRecordChange(route53, changeId);
}