cf-custom-resources/lib/cert-replicator.js (218 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 { ACM, waitUntilCertificateValidated, DescribeCertificateCommand, RequestCertificateCommand, DeleteCertificateCommand } = require("@aws-sdk/client-acm"); 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 maxAttempts = 10; /** * 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 || defaultLogStream || 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"); }); }; /** * Replicate a public certificate from AWS Certificate Manager in the target region. * * @param {string} requestId the CloudFormation request ID * @param {string} appName the application name * @param {string} envName the environment name * @param {string} certArn arn for the certificate to replicate from * @param {string} envRegionAcm acm client in environment region * @param {string} targetRegionAcm acm client in target region * @returns {string} ARN of the replicated certificate */ const replicateCertificate = async function ( requestId, appName, envName, certArn, envRegionAcm, targetRegionAcm ) { const { Certificate } = await envRegionAcm .send(new DescribeCertificateCommand({ CertificateArn: certArn, })); const domainName = Certificate.DomainName; const sans = Certificate.SubjectAlternativeNames; const crypto = require("crypto"); return await targetRegionAcm .send(new RequestCertificateCommand({ DomainName: domainName, SubjectAlternativeNames: sans, IdempotencyToken: crypto .createHash("sha256") .update(requestId) .digest("hex") .substr(0, 32), ValidationMethod: "DNS", Tags: [ { Key: "copilot-application", Value: appName, }, { Key: "copilot-environment", Value: envName, }, ], }) ); }; /** * Deletes a certificate from AWS Certificate Manager (ACM) by its ARN. * If the certificate does not exist, the function will return normally. * * @param {string} arn The certificate ARN * @param {string} targetRegionAcm acm client in target region */ const deleteCertificate = async function (arn, acm) { try { let inUseByResources = []; for (let attempt = 0; attempt < maxAttempts; attempt++) { const { Certificate } = await acm .send(new DescribeCertificateCommand({ CertificateArn: arn, })); inUseByResources = Certificate.InUseBy || []; if (inUseByResources.length === 0) { break; } // Deleting resources can be quite slow - so just sleep 30 seconds between checks. await sleep(30000); } if (inUseByResources.length) { throw new Error( `Certificate still in use by ${inUseByResources.join()} after checking for ${maxAttempts} attempts.` ); } await acm .send(new DeleteCertificateCommand({ CertificateArn: arn, })); } catch (err) { if (err.name !== "ResourceNotFoundException") { throw err; } } }; const validateCertificate = async function (certificateARN, acm) { // Wait up to 9 minutes and 30 seconds await waitUntilCertificateValidated({ client:acm, maxWaitTime: 570, minDelay: 30, maxDelay: 30, },{ CertificateArn: certificateARN }); }; /** * Main certificate replicator handler, invoked by Lambda */ exports.certificateReplicateHandler = async function (event, context) { let responseData = {}; let physicalResourceId = event.PhysicalResourceId; const props = event.ResourceProperties; const [targetRegion, envRegion, certArn] = [ props.TargetRegion, props.EnvRegion, props.CertificateArn, ]; let handler = async function () { // Configure clients. const envRegionAcm = new ACM({ region: envRegion }); const targetRegionAcm = new ACM({ region: targetRegion }); switch (event.RequestType) { case "Create": case "Update": const response = await replicateCertificate( event.RequestId, props.AppName, props.EnvName, certArn, envRegionAcm, targetRegionAcm ); responseData.Arn = physicalResourceId = response.CertificateArn; await exports.validateCertificate(response.CertificateArn, targetRegionAcm); 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, targetRegionAcm); } break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } }; try { await Promise.race([exports.deadlineExpired(), handler()]); 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 })` ); } }; exports.deadlineExpired = function () { return new Promise(function (resolve, reject) { setTimeout( reject, 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, new Error("Lambda took longer than 14.5 minutes to replicate certificate") ); }); }; /** * @private */ exports.withDefaultResponseURL = function (url) { defaultResponseURL = url; }; /** * @private */ exports.withSleep = function (s) { sleep = s; }; /** * @private */ exports.reset = function () { sleep = defaultSleep; maxAttempts = 10; }; /** * @private */ exports.withMaxAttempts = function (ma) { maxAttempts = ma; }; /** * @private */ exports.withDefaultLogStream = function (logStream) { defaultLogStream = logStream; }; /** * @private */ exports.withDefaultLogGroup = function (logGroup) { defaultLogGroup = logGroup; }; /** * @private */ exports.withDeadlineExpired = function (d) { exports.deadlineExpired = d; }; /** * @private */ exports.validateCertificate = function(certificateARN, acm) { return validateCertificate(certificateARN, acm); };