UNPKG

@collaborne/custom-cloudformation-resources

Version:
635 lines (585 loc) 18.7 kB
import { ACM, Route53 } from 'aws-sdk'; import type { SchemaType } from '@collaborne/json-schema-to-type'; import { ContinuationRequired, CustomResource, Response, } from '../custom-resource'; import { Logger } from '../logger'; import { isDefined } from '../utils'; const EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA = { type: 'object' as const, properties: { DomainName: { type: 'string' as const, }, ValidationDomain: { type: 'string' as const, }, }, required: ['DomainName' as const, 'ValidationDomain' as const], }; type EmailDomainValidationOption = SchemaType< typeof EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA >; const DNS_DOMAIN_VALIDATION_OPTION_SCHEMA = { type: 'object' as const, properties: { DomainName: { type: 'string' as const, }, HostedZoneId: { type: 'string' as const, }, }, required: ['DomainName' as const, 'HostedZoneId' as const], }; type DNSDomainValidationOption = SchemaType< typeof DNS_DOMAIN_VALIDATION_OPTION_SCHEMA >; const SCHEMA = { type: 'object' as const, properties: { DomainName: { type: 'string' as const, }, CertificateTransparencyLoggingPreference: { type: 'string' as const, enum: ['ENABLED', 'DISABLED'], }, DomainValidationOptions: { type: 'array' as const, items: { oneOf: [ EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA, DNS_DOMAIN_VALIDATION_OPTION_SCHEMA, ] as const, }, }, Tags: { type: 'array' as const, items: { type: 'object' as const, properties: { Key: { type: 'string' as const, }, Value: { type: 'string' as const, }, }, required: ['Key' as const], }, }, }, additionalProperties: true as const, required: ['DomainName' as const], }; interface ResourceAttributes { Arn: string; CertificateId: string; } function isEmailDomainValidationOption( option: EmailDomainValidationOption | DNSDomainValidationOption, ): option is EmailDomainValidationOption { return 'ValidationDomain' in option; } function isDNSDomainValidationOption( option: EmailDomainValidationOption | DNSDomainValidationOption, ): option is DNSDomainValidationOption { return 'HostedZoneId' in option; } function getCertificateId(certificateArn: string): string { 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) export class ACMCertificate extends CustomResource< ResourceAttributes, typeof SCHEMA, Response<ResourceAttributes> > { private acm = new ACM({ region: 'us-east-1' }); private route53 = new Route53(); constructor(logicalResourceId: string, logger: Logger) { super(SCHEMA, logicalResourceId, logger); } public async createResource( physicalResourceId: string, params: SchemaType<typeof SCHEMA>, continuationAttributes: Response<ResourceAttributes>, ): Promise< | Response<ResourceAttributes> | ContinuationRequired<Response<ResourceAttributes>> > { let response: Response<ResourceAttributes>; if (continuationAttributes) { response = continuationAttributes; } else { const attributes = await this.createCertificate(params); response = { physicalResourceId: `${physicalResourceId}/${attributes.CertificateId}`, attributes, }; } return this.maybeContinuation(response); } public async deleteResource( physicalResourceId: string, params: SchemaType<typeof SCHEMA>, ): Promise<Response<ResourceAttributes>> { // 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), }, }; } public async updateResource( physicalResourceId: string, params: SchemaType<typeof SCHEMA>, oldParams: unknown, continuationAttributes: Response<ResourceAttributes>, ): Promise< | Response<ResourceAttributes> | ContinuationRequired<Response<ResourceAttributes>> > { 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: ACM.Tag[] = []; 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: ACM.Tag[] = []; 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, }); } } private async findCertificateArn( physicalResourceId: string, domainName: string, ): Promise<string | undefined> { async function listCertificates( acm: ACM, params: Pick< ACM.ListCertificatesRequest, 'CertificateStatuses' | 'Includes' > = {}, ): Promise<ACM.CertificateSummary[]> { async function collect( knownCertificates: ACM.CertificateSummary[], token?: string, ): Promise<ACM.CertificateSummary[]> { 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?.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. */ private async getValidationResourceRecords( certificateArn: string, ): Promise<ACM.ResourceRecord[]> { let interval: NodeJS.Timeout; let retries = 0; this.logger.log(`getValidationResourceRecords retries: ${retries}`); return new Promise<ACM.ResourceRecord[]>((resolve, reject) => { interval = setInterval(async () => { 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 = ( certificate.DomainValidationOptions ?? [] ).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(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); }); } private async createCertificate( params: SchemaType<typeof SCHEMA>, rawIdempotencyToken = `${this.logicalResourceId}-${Date.now()}`, ): Promise<ResourceAttributes> { 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: EmailDomainValidationOption[] = domainValidationOptions.filter(isEmailDomainValidationOption); const dnsDomainValidationOptions: DNSDomainValidationOption[] = 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: ACM.RequestCertificateRequest = { ...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; } } private findChangedAttributes( params: SchemaType<typeof SCHEMA>, oldParams: unknown, ): string[] { function isChanged(to: any, from: any): boolean { 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 as Record<any, any>)[key]), ); } protected async maybeContinuation( response: Response<ResourceAttributes>, ): Promise< | Response<ResourceAttributes> | ContinuationRequired<Response<ResourceAttributes>> > { // 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 = response.attributes?.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(process.env.RETRY_INTERVAL_SECONDS ?? '300'), continuationAttributes: response, }; } else { throw new Error(`Certificate is in invalid status ${certificate.Status}`); } } }