cf-custom-resources/lib/dns-delegation.js (241 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 "use strict"; const { Route53, ListHostedZonesByNameCommand, ListResourceRecordSetsCommand, ChangeResourceRecordSetsCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53"); const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers"); // These are used for test purposes only let defaultResponseURL; let defaultLogGroup; let defaultLogStream; /** * 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"); }); }; /** * Creates a NS recordset in the domain's hosted zone using the rootDnsRole for the subDomain. * This essentially delegates authority for subdomain to the subdomain's hostedzone. * * The rootDnsRole has to have access to the hostedzone for domainName. * * @param {string} requestId the CloudFormation request ID * @param {string} domainName the DNS name to add to the subDomain to (ecs-cli.aws). * @param {string} subDomain the full subdomain to add to the domain above (test.ecs-cli.aws). * @param {string[]} nameServers the subdomain nameservers to add to the domain's hostedzone. * @param {string} rootDnsRole the IAM role ARN that can manage domainName */ const createSubdomainInRoot = async function ( requestId, domainName, subDomain, nameServers, rootDnsRole ) { const route53 = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: rootDnsRole }, masterCredentials: fromEnv("AWS"), }), }); const hostedZones = await route53 .send(new ListHostedZonesByNameCommand({ DNSName: domainName })); if (!hostedZones.HostedZones || hostedZones.HostedZones.length == 0) { throw new Error( `Couldn't find any hostedzones with DNS name ${domainName}. Request ${requestId}` ); } const domainHostedZone = hostedZones.HostedZones[0]; // HostedZoneIDs are of the form /hostedzone/1234455, but the actual // ID is after the last slash. const hostedZoneId = domainHostedZone.Id.split("/").pop(); const changeBatch = await route53 .send(new ChangeResourceRecordSetsCommand({ ChangeBatch: { Changes: [ recordChangeAction( "UPSERT", subDomain, "NS", nameServers.map((ns) => { return { Value: ns, }; }) ), ], }, HostedZoneId: hostedZoneId, })); console.log( `Created recordset in hostedzone ${hostedZoneId} for ${subDomain}` ); await exports.waitForRecordSetChange(route53, changeBatch.ChangeInfo.Id); }; /** * Deletes the NameServer record sets from a hostedzone using a cross * account role. If the subdomain doesn't exist, this fast succeeds. * * @param {string} requestId the CloudFormation request ID * @param {string} domainName the DNS name to add to the subDomain to (ecs-cli.aws). * @param {string} subDomain the full subdomain to add to the domain above (test.ecs-cli.aws). * @param {string} rootDnsRole the IAM role ARN that can manage domainName * @returns {string} the deleted subdomain */ const deleteSubdomainInRoot = async function ( requestId, domainName, subDomain, rootDnsRole ) { const route53 = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: rootDnsRole }, masterCredentials: fromEnv("AWS"), }), }); const hostedZones = await route53 .send(new ListHostedZonesByNameCommand({ DNSName: domainName, })); if (!hostedZones.HostedZones || hostedZones.HostedZones.length == 0) { throw new Error( `Couldn't find any hostedzones with DNS name ${domainName}. Request ${requestId}` ); } const domainHostedZone = hostedZones.HostedZones[0]; // HostedZoneIDs are of the form /hostedzone/1234455, but the actual // ID is after the last slash. const hostedZoneId = domainHostedZone.Id.split("/").pop(); // Find the recordsets for this subdomain, and then remove it // from the hosted zone. const recordSets = await route53 .send(new ListResourceRecordSetsCommand({ HostedZoneId: hostedZoneId, MaxItems: "1", StartRecordName: subDomain, StartRecordType: "NS", })); // If the records have already been deleted, return early. if (!recordSets.ResourceRecordSets || recordSets.ResourceRecordSets == 0) { return subDomain; } const subDomainRecordSet = recordSets.ResourceRecordSets[0]; // If the our subdomain doesn't exactly match the recordset, // or the type isn't NS, we'll skip deleting it - since it isn't our record. if ( subDomainRecordSet.Name !== `${subDomain}.` || subDomainRecordSet.Type !== "NS" ) { return subDomain; } console.log(`Deleting recordset ${subDomainRecordSet.Name}`); const changeBatch = await route53 .send(new ChangeResourceRecordSetsCommand({ ChangeBatch: { Changes: [ recordChangeAction( "DELETE", subDomain, "NS", subDomainRecordSet.ResourceRecords ), ], }, HostedZoneId: hostedZoneId, })); await exports.waitForRecordSetChange(route53, changeBatch.ChangeInfo.Id); return subDomain; }; const recordChangeAction = function ( action, recordName, recordType, recordValues ) { return { Action: action, ResourceRecordSet: { Name: recordName, Type: recordType, TTL: 60, ResourceRecords: recordValues, }, }; }; const waitForRecordSetChange = async function (route53, changeId) { // wait upto 5 minutes. await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: 300, minDelay: 30, maxDelay: 30, },{ Id: changeId, }); }; exports.domainDelegationHandler = async function (event, context) { var responseData = {}; const props = event.ResourceProperties; const physicalResourceId = props.SubdomainName; try { switch (event.RequestType) { case "Create": case "Update": await createSubdomainInRoot( event.RequestId, props.DomainName, props.SubdomainName, props.NameServers, props.RootDNSRole ); break; case "Delete": await deleteSubdomainInRoot( event.RequestId, props.DomainName, props.SubdomainName, props.RootDNSRole ); break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } await report(event, context, "SUCCESS", physicalResourceId, responseData); } catch (err) { console.log(`Caught error ${err}.`); console.log(err); await report( event, context, "FAILED", physicalResourceId, null, `${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${ defaultLogStream || context.logStreamName })` ); } }; /** * @private */ exports.withDefaultResponseURL = function (url) { defaultResponseURL = url; }; /** * @private */ exports.withDefaultLogStream = function (logStream) { defaultLogStream = logStream; }; /** * @private */ exports.withDefaultLogGroup = function (logGroup) { defaultLogGroup = logGroup; }; /** * @private */ exports.waitForRecordSetChange = function (route53, changeId) { return waitForRecordSetChange(route53, changeId); };