UNPKG

@collaborne/custom-cloudformation-resources

Version:
432 lines 20.3 kB
"use strict"; 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