UNPKG

@aws-cdk/aws-certificatemanager

Version:

The CDK Construct Library for AWS::CertificateManager

394 lines (345 loc) 12 kB
'use strict'; const aws = require('aws-sdk'); const defaultSleep = function (ms) { return new Promise(resolve => setTimeout(resolve, ms)); }; // These are used for test purposes only let defaultResponseURL; let waiter; let sleep = defaultSleep; let random = Math.random; 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'); var 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(`Server returned error ${res.statusCode}: ${res.statusMessage}`)); } else { resolve(); } }) .end(responseBody, 'utf8'); }); }; /** * Requests a public certificate from AWS Certificate Manager, using DNS validation. * The hosted zone ID must refer to a **public** Route53-managed DNS zone that is authoritative * for the suffix of the certificate's Common Name (CN). For example, if the CN is * `*.example.com`, the hosted zone ID must point to a Route 53 zone authoritative * for `example.com`. * * @param {string} requestId the CloudFormation request ID * @param {string} domainName the Common Name (CN) field for the requested certificate * @param {string} hostedZoneId the Route53 Hosted Zone ID * @param {map} tags Tags to add to the requested certificate * @returns {string} Validated certificate ARN */ const requestCertificate = async function (requestId, domainName, subjectAlternativeNames, hostedZoneId, region, route53Endpoint, tags) { const crypto = require('crypto'); const acm = new aws.ACM({ region }); const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53(); if (waiter) { // Used by the test suite, since waiters aren't mockable yet route53.waitFor = acm.waitFor = waiter; } console.log(`Requesting certificate for ${domainName}`); const reqCertResponse = await acm.requestCertificate({ DomainName: domainName, SubjectAlternativeNames: subjectAlternativeNames, IdempotencyToken: crypto.createHash('sha256').update(requestId).digest('hex').slice(0, 32), ValidationMethod: 'DNS' }).promise(); console.log(`Certificate ARN: ${reqCertResponse.CertificateArn}`); if (!!tags) { const result = Array.from(Object.entries(tags)).map(([Key, Value]) => ({ Key, Value })) await acm.addTagsToCertificate({ CertificateArn: reqCertResponse.CertificateArn, Tags: result, }).promise(); } console.log('Waiting for ACM to provide DNS records for validation...'); let records = []; for (let attempt = 0; attempt < maxAttempts && !records.length; attempt++) { const { Certificate } = await acm.describeCertificate({ CertificateArn: reqCertResponse.CertificateArn }).promise(); records = getDomainValidationRecords(Certificate); if (!records.length) { // 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 (!records.length) { throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`) } console.log(`Upserting ${records.length} DNS records into zone ${hostedZoneId}:`); await commitRoute53Records(route53, records, hostedZoneId); console.log('Waiting for validation...'); await acm.waitFor('certificateValidated', { // Wait up to 9 minutes and 30 seconds $waiter: { delay: 30, maxAttempts: 19 }, CertificateArn: reqCertResponse.CertificateArn }).promise(); return reqCertResponse.CertificateArn; }; /** * 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 */ const deleteCertificate = async function (arn, region, hostedZoneId, route53Endpoint, cleanupRecords) { const acm = new aws.ACM({ region }); const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53(); if (waiter) { // Used by the test suite, since waiters aren't mockable yet route53.waitFor = acm.waitFor = waiter; } try { console.log(`Waiting for certificate ${arn} to become unused`); let inUseByResources; let records = []; for (let attempt = 0; attempt < maxAttempts; attempt++) { const { Certificate } = await acm.describeCertificate({ CertificateArn: arn }).promise(); if (cleanupRecords) { records = getDomainValidationRecords(Certificate); } inUseByResources = Certificate.InUseBy || []; if (inUseByResources.length || !records.length) { // 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); } else { break; } } if (inUseByResources.length) { throw new Error(`Response from describeCertificate did not contain an empty InUseBy list after ${maxAttempts} attempts.`) } if (cleanupRecords && !records.length) { throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`) } console.log(`Deleting certificate ${arn}`); await acm.deleteCertificate({ CertificateArn: arn }).promise(); if (cleanupRecords) { console.log(`Deleting ${records.length} DNS records from zone ${hostedZoneId}:`); await commitRoute53Records(route53, records, hostedZoneId, 'DELETE'); } } catch (err) { if (err.name !== 'ResourceNotFoundException') { throw err; } } }; /** * Retrieve the unique domain validation options as records to be upserted (or deleted) from Route53. * * Returns an empty array ([]) if the domain validation options is empty or the records are not yet ready. */ function getDomainValidationRecords(certificate) { const options = certificate.DomainValidationOptions || []; // Ensure all records are ready; there is (at least a theory there's) a chance of a partial response here in rare cases. if (options.length > 0 && options.every(opt => opt && !!opt.ResourceRecord)) { // some alternative names will produce the same validation record // as the main domain (eg. example.com + *.example.com) // filtering duplicates to avoid errors with adding the same record // to the route53 zone twice const unique = options .map((val) => val.ResourceRecord) .reduce((acc, cur) => { acc[cur.Name] = cur; return acc; }, {}); return Object.keys(unique).sort().map(key => unique[key]); } return []; } /** * Execute Route53 ChangeResourceRecordSets for a set of records within a Hosted Zone, * and wait for the records to commit. Defaults to an 'UPSERT' action. */ async function commitRoute53Records(route53, records, hostedZoneId, action = 'UPSERT') { const changeBatch = await route53.changeResourceRecordSets({ ChangeBatch: { Changes: records.map((record) => { console.log(`${record.Name} ${record.Type} ${record.Value}`); return { Action: action, ResourceRecordSet: { Name: record.Name, Type: record.Type, TTL: 60, ResourceRecords: [{ Value: record.Value }] } }; }), }, HostedZoneId: hostedZoneId }).promise(); console.log('Waiting for DNS records to commit...'); await route53.waitFor('resourceRecordSetsChanged', { // Wait up to 5 minutes $waiter: { delay: 30, maxAttempts: 10 }, Id: changeBatch.ChangeInfo.Id }).promise(); } /** * Main handler, invoked by Lambda */ exports.certificateRequestHandler = async function (event, context) { var responseData = {}; var physicalResourceId; var certificateArn; try { switch (event.RequestType) { case 'Create': case 'Update': certificateArn = await requestCertificate( event.RequestId, event.ResourceProperties.DomainName, event.ResourceProperties.SubjectAlternativeNames, event.ResourceProperties.HostedZoneId, event.ResourceProperties.Region, event.ResourceProperties.Route53Endpoint, event.ResourceProperties.Tags, ); responseData.Arn = physicalResourceId = certificateArn; break; case 'Delete': physicalResourceId = event.PhysicalResourceId; // 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, event.ResourceProperties.Region, event.ResourceProperties.HostedZoneId, event.ResourceProperties.Route53Endpoint, event.ResourceProperties.CleanupRecords === "true", ); } break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } console.log(`Uploading SUCCESS response to S3...`); await report(event, context, 'SUCCESS', physicalResourceId, responseData); console.log('Done.'); } catch (err) { console.log(`Caught error ${err}. Uploading FAILED message to S3.`); await report(event, context, 'FAILED', physicalResourceId, null, err.message); } }; /** * @private */ exports.withReporter = function (reporter) { report = reporter; }; /** * @private */ exports.withDefaultResponseURL = function (url) { defaultResponseURL = url; }; /** * @private */ exports.withWaiter = function (w) { waiter = w; }; /** * @private */ exports.resetWaiter = function () { waiter = undefined; }; /** * @private */ exports.withSleep = function (s) { sleep = s; } /** * @private */ exports.resetSleep = function () { sleep = defaultSleep; } /** * @private */ exports.withRandom = function (r) { random = r; } /** * @private */ exports.resetRandom = function () { random = Math.random; } /** * @private */ exports.withMaxAttempts = function (ma) { maxAttempts = ma; } /** * @private */ exports.resetMaxAttempts = function () { maxAttempts = 10; }