@collaborne/custom-cloudformation-resources
Version:
Custom CloudFormation resources
432 lines • 20.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ACMCertificate = void 0;
const aws_sdk_1 = require("aws-sdk");
const custom_resource_1 = require("../custom-resource");
const utils_1 = require("../utils");
const EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA = {
type: 'object',
properties: {
DomainName: {
type: 'string',
},
ValidationDomain: {
type: 'string',
},
},
required: ['DomainName', 'ValidationDomain'],
};
const DNS_DOMAIN_VALIDATION_OPTION_SCHEMA = {
type: 'object',
properties: {
DomainName: {
type: 'string',
},
HostedZoneId: {
type: 'string',
},
},
required: ['DomainName', 'HostedZoneId'],
};
const SCHEMA = {
type: 'object',
properties: {
DomainName: {
type: 'string',
},
CertificateTransparencyLoggingPreference: {
type: 'string',
enum: ['ENABLED', 'DISABLED'],
},
DomainValidationOptions: {
type: 'array',
items: {
oneOf: [
EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA,
DNS_DOMAIN_VALIDATION_OPTION_SCHEMA,
],
},
},
Tags: {
type: 'array',
items: {
type: 'object',
properties: {
Key: {
type: 'string',
},
Value: {
type: 'string',
},
},
required: ['Key'],
},
},
},
additionalProperties: true,
required: ['DomainName'],
};
function isEmailDomainValidationOption(option) {
return 'ValidationDomain' in option;
}
function isDNSDomainValidationOption(option) {
return 'HostedZoneId' in option;
}
function getCertificateId(certificateArn) {
const lastSlashIndex = certificateArn.lastIndexOf('/');
return certificateArn.substring(lastSlashIndex + 1);
}
// XXX: The AWS::CertificateManager::Certificate uses the ARN as Ref, not an id (and then also doesn't have a .Arn attribute)
class ACMCertificate extends custom_resource_1.CustomResource {
constructor(logicalResourceId, logger) {
super(SCHEMA, logicalResourceId, logger);
this.acm = new aws_sdk_1.ACM({ region: 'us-east-1' });
this.route53 = new aws_sdk_1.Route53();
}
async createResource(physicalResourceId, params, continuationAttributes) {
let response;
if (continuationAttributes) {
response = continuationAttributes;
}
else {
const attributes = await this.createCertificate(params);
response = {
physicalResourceId: `${physicalResourceId}/${attributes.CertificateId}`,
attributes,
};
}
return this.maybeContinuation(response);
}
async deleteResource(physicalResourceId, params) {
// XXX: If there are multiple, what would happen? How could we identify "the one"?
const certificateArn = await this.findCertificateArn(physicalResourceId, params.DomainName);
if (!certificateArn) {
throw new Error(`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`);
}
// Delete the certificate itself
//
// Note that we're not going to delete the Route53 entries now: If the deletion was "temporary" (for example due
// to other stack failures that led to cleanups), the DNS records will be reusable for the next attempt.
// This matches the behavior of the original AWS::ACM::Certificate resource; if needed we could make the deletion
// optional as the information should be available in the certificate data.
await this.acm
.deleteCertificate({ CertificateArn: certificateArn })
.promise();
return {
physicalResourceId,
attributes: {
Arn: certificateArn,
CertificateId: getCertificateId(certificateArn),
},
};
}
async updateResource(physicalResourceId, params, oldParams, continuationAttributes) {
if (continuationAttributes) {
return this.maybeContinuation(continuationAttributes);
}
const { CertificateTransparencyLoggingPreference: ctLoggingPreference, Tags: tags = [], } = params;
const changedAttributes = this.findChangedAttributes(params, oldParams);
if (changedAttributes.length === 1 &&
changedAttributes[0] === 'CertificateTransparencyLoggingPreference') {
// Update the preference
const certificateArn = await this.findCertificateArn(physicalResourceId, params.DomainName);
if (!certificateArn) {
throw new Error(`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`);
}
await this.acm
.updateCertificateOptions({
CertificateArn: certificateArn,
Options: {
CertificateTransparencyLoggingPreference: ctLoggingPreference,
},
})
.promise();
return {
physicalResourceId,
attributes: {
Arn: certificateArn,
CertificateId: getCertificateId(certificateArn),
},
};
}
else if (changedAttributes.length === 1 &&
changedAttributes[0] === 'Tags') {
// Update tags
const certificateArn = await this.findCertificateArn(physicalResourceId, params.DomainName);
if (!certificateArn) {
throw new Error(`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`);
}
const { Tags: existingTags = [] } = await this.acm
.listTagsForCertificate({ CertificateArn: certificateArn })
.promise();
const tagsToAdd = [];
for (const tag of tags) {
if (!existingTags.find(({ Key: k, Value: v }) => k === tag.Key && v === tag.Value)) {
tagsToAdd.push(tag);
}
}
await this.acm
.addTagsToCertificate({
CertificateArn: certificateArn,
Tags: tagsToAdd,
})
.promise();
const tagsToRemove = [];
for (const tag of existingTags) {
if (!tags.find(({ Key: k, Value: v }) => k === tag.Key && v === tag.Value)) {
tagsToRemove.push(tag);
}
}
await this.acm
.removeTagsFromCertificate({
CertificateArn: certificateArn,
Tags: tagsToRemove,
})
.promise();
return {
physicalResourceId,
attributes: {
Arn: certificateArn,
CertificateId: getCertificateId(certificateArn),
},
};
}
else {
// Replace the whole thing, and return a new resource id.
// If the old physical resource id contains the old certificate id, replace that, otherwise it's a legacy
// resource and we need to _add_ the id.
const certificateArn = await this.findCertificateArn(physicalResourceId, params.DomainName);
if (!certificateArn) {
throw new Error(`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`);
}
const newAttributes = await this.createCertificate(params, physicalResourceId);
// Note that we just return the new id, and CloudFormation will call us at the end to clean up.
let physicalResourceIdPrefix;
if (physicalResourceId.endsWith(`/${getCertificateId(certificateArn)}`)) {
const lastSlashIndex = physicalResourceId.lastIndexOf('/');
physicalResourceIdPrefix = physicalResourceId.substring(lastSlashIndex + 1);
}
else {
physicalResourceIdPrefix = physicalResourceId;
}
const newPhysicalResourceId = `${physicalResourceIdPrefix}/${newAttributes.CertificateId}`;
this.logger.log(`Replaced certificate ${physicalResourceId} with ${newPhysicalResourceId}`);
return this.maybeContinuation({
physicalResourceId: newPhysicalResourceId,
attributes: newAttributes,
});
}
}
async findCertificateArn(physicalResourceId, domainName) {
async function listCertificates(acm, params = {}) {
async function collect(knownCertificates, token) {
const { CertificateSummaryList: newCertificates = [], NextToken: nextToken, } = await acm
.listCertificates({
...params,
NextToken: token,
})
.promise();
const resources = [...knownCertificates, ...newCertificates];
if (nextToken) {
return collect(resources, nextToken);
}
return resources;
}
return collect([]);
}
const certificates = await listCertificates(this.acm);
// Find the certificate that matches the domain, and ideally contains the id in the resource id.
// For legacy certificate this isn't true, so we will accept any certificate (with a warning!)
let certificate = certificates.find(c => {
const certificateId = getCertificateId(c.CertificateArn);
// In theory the check for the endsWith is enough, and we don't need to look things up
// by domain name.
if (physicalResourceId.endsWith(`/${certificateId}`)) {
if (c.DomainName !== domainName) {
this.logger.warn(`Certificate ${c.CertificateArn} contains unexpected domain: ${c.DomainName} (should be ${domainName})`);
}
return true;
}
return false;
});
if (!certificate) {
this.logger.warn(`Cannot find certificate by id, falling back to search by domain name`);
certificate = certificates.find(c => c.DomainName === domainName);
}
return certificate === null || certificate === void 0 ? void 0 : certificate.CertificateArn;
}
/**
* Get the required resource records for validation
*/
/* Implementation note: This method is based on the observation that sometimes the resource record information seems
* to take a while to appear, so it will retry a couple of times if there are DNS validation options without resource
* records attached to it.
*/
async getValidationResourceRecords(certificateArn) {
let interval;
let retries = 0;
this.logger.log(`getValidationResourceRecords retries: ${retries}`);
return new Promise((resolve, reject) => {
interval = setInterval(async () => {
var _a;
const { Certificate: certificate } = await this.acm
.describeCertificate({ CertificateArn: certificateArn })
.promise();
if (!certificate) {
reject(new Error(`Cannot find certificate ${certificateArn}`));
return;
}
this.logger.log(`getValidationResourceRecords certificate: ${JSON.stringify(certificate)}`);
const dnsDomainValidationOptions = ((_a = certificate.DomainValidationOptions) !== null && _a !== void 0 ? _a : []).filter(options => options.ValidationMethod === 'DNS');
if (dnsDomainValidationOptions.length === 0) {
// Consider this "fatal": There's no indication that this would ever appear on its own so far in the docs.
reject(new Error(`Cannot find DNS domain validation option in certificate ${certificateArn}: ${JSON.stringify(certificate)}`));
return;
}
this.logger.log(`getValidationResourceRecords dnsDomainValidationOptions: ${JSON.stringify(dnsDomainValidationOptions)}`);
const resourceRecords = dnsDomainValidationOptions
.map(option => option.ResourceRecord)
.filter(utils_1.isDefined);
this.logger.log(`getValidationResourceRecords resourceRecords: ${JSON.stringify(resourceRecords)}`);
const missingResourceRecords = dnsDomainValidationOptions.length - resourceRecords.length;
if (missingResourceRecords > 0) {
this.logger.error(`Missing resource records in ${missingResourceRecords} DNS validation options in certificate ${certificateArn} after ${++retries} attempts: ${JSON.stringify(certificate)}`);
return;
}
this.logger.log(`Found all validation resource records in certificate ${certificateArn}: ${JSON.stringify(resourceRecords)}`);
resolve(resourceRecords);
}, Number(process.env.SLS_AWS_MONITORING_FREQUENCY || 5000));
}).finally(() => {
clearInterval(interval);
});
}
async createCertificate(params, rawIdempotencyToken = `${this.logicalResourceId}-${Date.now()}`) {
const { CertificateTransparencyLoggingPreference: ctLoggingPreference, DomainValidationOptions: domainValidationOptions = [], ...baseRequest } = params;
// Remove all DNS validation options: These aren't supported by the ACM class itself, but rather we need to implement them manually.
const emailDomainValidationOptions = domainValidationOptions.filter(isEmailDomainValidationOption);
const dnsDomainValidationOptions = domainValidationOptions.filter(isDNSDomainValidationOption);
// There must be at most one of these, and that one option must be using the same domain name
if (dnsDomainValidationOptions.length > 1 ||
(dnsDomainValidationOptions.length === 1 &&
dnsDomainValidationOptions[0].DomainName !== params.DomainName)) {
throw new Error('Invalid DNS domain validation options');
}
try {
// Request the certificate
const request = {
...baseRequest,
IdempotencyToken: rawIdempotencyToken
.replace(/[^\w]/g, '')
.slice(0, 32),
DomainValidationOptions: emailDomainValidationOptions.length === 0
? undefined
: emailDomainValidationOptions,
Options: {
CertificateTransparencyLoggingPreference: ctLoggingPreference,
},
};
const { CertificateArn: certificateArn } = await this.acm
.requestCertificate(request)
.promise();
if (!certificateArn) {
// Hopefully some hints are in Cloudtrail now ...
throw new Error('Failed to request certificate: No certificate ARN returned');
}
// We have an ARN, so at least all initial parameters were good enough. Proceed working on the validation ...
this.logger.log(`Certificate requested: ${certificateArn}`);
// If validation was supposed to happen via email, or there are no options provided for DNS validation, then we're
// done and the caller knows what to do.
// Otherwise we need to go to Route53 and upsert the needed RR there.
if (dnsDomainValidationOptions.length !== 0) {
this.logger.log('Validation is DNS Domain Validation');
const resourceRecords = await this.getValidationResourceRecords(certificateArn);
this.logger.log(`resourceRecords: ${JSON.stringify(resourceRecords)}`);
const hostedZoneId = dnsDomainValidationOptions[0].HostedZoneId;
this.logger.log(`hostedZoneId: ${hostedZoneId}`);
const result = await this.route53
.changeResourceRecordSets({
HostedZoneId: hostedZoneId,
ChangeBatch: {
Changes: resourceRecords.map(resourceRecord => ({
Action: 'UPSERT',
ResourceRecordSet: {
Name: resourceRecord.Name,
Type: resourceRecord.Type,
// The TTL can in theory be very large, but that would potentially hinder our ability
// to quickly revoke a certificate. As there shouldn't be many requests to this either, 300
// should be just fine.
TTL: 300,
ResourceRecords: [
{
Value: resourceRecord.Value,
},
],
},
})),
},
})
.promise();
this.logger.log(`Route53 change set: ${result.ChangeInfo.Id}`);
}
return {
Arn: certificateArn,
CertificateId: getCertificateId(certificateArn),
};
}
catch (error) {
this.logger.error(error);
throw error;
}
}
findChangedAttributes(params, oldParams) {
function isChanged(to, from) {
switch (typeof from) {
case 'undefined':
return typeof to !== 'undefined';
case 'object':
return (typeof to !== 'object' ||
!Object.entries(from).reduce((r, [k, v]) => r && !isChanged(to[k], v), true));
default:
if (Array.isArray(from)) {
return (!Array.isArray(to) ||
from.length !== to.length ||
!from.reduce((r, k, i) => r && to[i] === k, true));
}
return from !== to;
}
}
return Object.keys(params).filter(key => isChanged(params[key], oldParams[key]));
}
async maybeContinuation(response) {
var _a, _b;
// Initially wee have the id in the response, so we simply check now whether the certificate is valid.
// If it is, great, return the response. Otherwise stuff the whole response into continuationAttributes, and
// try again in a minute or two.
const certificateArn = (_a = response.attributes) === null || _a === void 0 ? void 0 : _a.Arn;
if (!certificateArn) {
throw new Error('Missing certificate ARN');
}
const { Certificate: certificate } = await this.acm
.describeCertificate({ CertificateArn: certificateArn })
.promise();
if (!certificate) {
throw new Error(`Cannot find certificate ${certificateArn}`);
}
if (certificate.Status === 'ISSUED') {
this.logger.log('Certificate is ISSUED, stopping continuations');
return response;
}
else if (certificate.Status === 'PENDING_VALIDATION') {
this.logger.log(`Certificate is PENDING_VALIDATION, requesting continuation`);
return {
continuationAfter: Number((_b = process.env.RETRY_INTERVAL_SECONDS) !== null && _b !== void 0 ? _b : '300'),
continuationAttributes: response,
};
}
else {
throw new Error(`Certificate is in invalid status ${certificate.Status}`);
}
}
}
exports.ACMCertificate = ACMCertificate;
//# sourceMappingURL=index.js.map