cf-custom-resources/lib/custom-domain.js (318 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 "use strict"; const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers"); const { Route53, waitUntilResourceRecordSetsChanged, ListHostedZonesByNameCommand, ChangeResourceRecordSetsCommand} = require("@aws-sdk/client-route-53"); const changeRecordAction = { Upsert: "UPSERT", Delete: "DELETE", } // These are used for test purposes only let defaultResponseURL; let defaultLogGroup; let defaultLogStream; let hostedZoneCache = new Map(); /** * 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(`Error ${res.statusCode}: ${res.statusMessage}`)); } else { resolve(); } }) .end(responseBody, "utf8"); }); }; /** * Upsert all alias records to the correct domain hosted zone. More specifically, * we'll add the record to the root hosted zone for aliases in format of `*.${domainName}`; * to the app hosted zone for aliases in format of `*.$appName}.${domainName}`; * and to the env hosted zone for aliases in format of `*.${envName}.${appName}.${domainName}`. * Also for the other aliases not matching any of the condition above, we'll skip since * it is corresponding hosted zone is not managable by Copilot. * * @param {string} aliases the custom domain aliases * @param {string} accessDNS DNS of the public access * @param {string} accessHostedZone Hosted Zone of the public access * @param {string} rootDnsRole the IAM role ARN that can manage domainName * @param {string} aliasTypes the alias type */ const writeCustomDomainRecord = async function ( appRoute53, envRoute53, aliases, accessDNS, accessHostedZone, aliasTypes, action ) { const actions = []; for (const alias of aliases) { const aliasType = await getAliasType(aliasTypes, alias); switch (aliasType) { case aliasTypes.EnvDomainZone: actions.push(writeARecord( envRoute53, alias, accessDNS, accessHostedZone, aliasType.domain, action )); break; case aliasTypes.AppDomainZone: actions.push(writeARecord( appRoute53, alias, accessDNS, accessHostedZone, aliasType.domain, action )); break; case aliasTypes.RootDomainZone: actions.push(writeARecord( appRoute53, alias, accessDNS, accessHostedZone, aliasType.domain, action )); break; // We'll skip if it is the other alias type since it will be in another account's route53. default: } } await Promise.all(actions); }; const writeARecord = async function ( route53, alias, accessDNS, accessHostedZone, domain, action ) { let hostedZoneId = hostedZoneCache.get(domain); if (!hostedZoneId) { const hostedZones = await route53 .send(new ListHostedZonesByNameCommand({ DNSName: domain, MaxItems: "1", })); if (!hostedZones.HostedZones || hostedZones.HostedZones.length == 0) { throw new Error(`Couldn't find any Hosted Zone with DNS name ${domain}.`); } hostedZoneId = hostedZones.HostedZones[0].Id.split("/").pop(); hostedZoneCache.set(domain, hostedZoneId); } console.log(`${action} A record into Hosted Zone ${hostedZoneId}`); try { const changeBatch = await updateRecords( route53, hostedZoneId, action, alias, accessDNS, accessHostedZone ); await exports.waitForRecordChange(route53, changeBatch.ChangeInfo.Id); } catch (err) { if (action === changeRecordAction.Delete && isRecordSetNotFoundErr(err)) { console.log(`${err.message}; Copilot is ignoring this record.`); return; } throw err; } }; // Example error message: "InvalidChangeBatch: [Tried to delete resource record set [name='a.domain.com.', type='A'] but it was not found]" const isRecordSetNotFoundErr = (err) => err.message.includes("Tried to delete resource record set") && err.message.includes("but it was not found") /** * Custom domain handler, invoked by Lambda. */ exports.handler = async function (event, context) { var responseData = {}; const physicalResourceId = event.LogicalResourceId; const props = event.ResourceProperties; const [app, env, domain] = [props.AppName, props.EnvName, props.DomainName]; var aliasTypes = { EnvDomainZone: { regex: new RegExp(`^([^\.]+\.)?${env}.${app}.${domain}`), domain: `${env}.${app}.${domain}`, }, AppDomainZone: { regex: new RegExp(`^([^\.]+\.)?${app}.${domain}`), domain: `${app}.${domain}`, }, RootDomainZone: { regex: new RegExp(`^([^\.]+\.)?${domain}`), domain: `${domain}`, }, OtherDomainZone: { regex: new RegExp(`.*`) }, }; const envRoute53 = new Route53(); const appRoute53 = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: props.AppDNSRole }, masterCredentials: fromEnv("AWS"), }), }); try { var aliases = await getAllAliases(props.Aliases); switch (event.RequestType) { case "Create": await writeCustomDomainRecord( appRoute53, envRoute53, aliases, props.PublicAccessDNS, props.PublicAccessHostedZone, aliasTypes, changeRecordAction.Upsert, ); break; case "Update": await writeCustomDomainRecord( appRoute53, envRoute53, aliases, props.PublicAccessDNS, props.PublicAccessHostedZone, aliasTypes, changeRecordAction.Upsert, ); // After upserting new aliases, delete unused ones. For example: previously we have ["foo.com", "bar.com"], // and now the aliases param is updated to just ["foo.com"] then we'll delete "bar.com". var prevAliases = await getAllAliases( event.OldResourceProperties.Aliases ); var aliasesToDelete = [...prevAliases].filter(function (itm) { return !aliases.has(itm); }); await writeCustomDomainRecord( appRoute53, envRoute53, aliasesToDelete, props.PublicAccessDNS, props.PublicAccessHostedZone, aliasTypes, changeRecordAction.Delete, ); break; case "Delete": await writeCustomDomainRecord( appRoute53, envRoute53, aliases, props.PublicAccessDNS, props.PublicAccessHostedZone, aliasTypes, changeRecordAction.Delete ); break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } 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 })` ); } }; // getAllAliases gets all aliases out from a string. For example: // {"frontend": ["test.foobar.com", "foobar.com"], "api": ["api.foobar.com"]} will return // ["test.foobar.com", "foobar.com", "api.foobar.com"]. const getAllAliases = function (aliases) { let obj; try { obj = JSON.parse(aliases || "{}"); } catch (error) { throw new Error(`Cannot parse ${aliases} into JSON format.`); } var aliasList = []; for (var m in obj) { aliasList.push(...obj[m]); } return new Set(aliasList); }; const getAliasType = function (aliasTypes, alias) { switch (true) { case aliasTypes.EnvDomainZone.regex.test(alias): return aliasTypes.EnvDomainZone; case aliasTypes.AppDomainZone.regex.test(alias): return aliasTypes.AppDomainZone; case aliasTypes.RootDomainZone.regex.test(alias): return aliasTypes.RootDomainZone; default: return aliasTypes.OtherDomainZone; } }; const waitForRecordChange = async function (route53, changeId) { // wait Upto 5 minutes. await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: 300, minDelay: 30, maxDelay: 30, },{ Id: changeId, }); }; const updateRecords = function ( route53, hostedZone, action, alias, accessDNS, accessHostedZone ) { return route53 .send(new ChangeResourceRecordSetsCommand({ ChangeBatch: { Changes: [ { Action: action, ResourceRecordSet: { Name: alias, Type: "A", AliasTarget: { HostedZoneId: accessHostedZone, DNSName: accessDNS, EvaluateTargetHealth: true, }, }, }, ], }, HostedZoneId: hostedZone, }, )); }; /** * @private */ exports.withDefaultResponseURL = function (url) { defaultResponseURL = url; }; /** * @private */ exports.withDefaultLogStream = function (logStream) { defaultLogStream = logStream; }; /** * @private */ exports.withDefaultLogGroup = function (logGroup) { defaultLogGroup = logGroup; }; /** * @private */ exports.waitForRecordChange = function (route53, changeId) { return waitForRecordChange(route53, changeId); };