@@ -14,6 +14,7 @@ let waiter;
1414let sleep = defaultSleep ;
1515let random = Math . random ;
1616let maxAttempts = 10 ;
17+ let domainTypes ;
1718
1819/**
1920 * Upload a CloudFormation response object to S3.
@@ -85,36 +86,34 @@ let report = function (
8586 * Lastly, the function exits until the certificate is validated.
8687 *
8788 * @param {string } requestId the CloudFormation request ID
88- * @param {string } appName the name of the application
89- * @param {string } envName the name of the environment
90- * @param {string } domainName the Common Name (CN) field for the requested certificate
91- * @param {string } subjectAlternativeNames additional FQDNs to be included in the
92- * Subject Alternative Name extension of the requested certificate
89+ * @param {string } appName the application name
90+ * @param {string } envName the environment name
91+ * @param {string } certDomain the domain of the certificate
92+ * @param {string } aliases the custom domain aliases
9393 * @param {string } envHostedZoneId the environment Route53 Hosted Zone ID
9494 * @param {string } rootDnsRole the IAM role ARN that can manage domainName
95- * @param {string } isAliasEnabled whether alias is enabled
9695 * @returns {string } Validated certificate ARN
9796 */
9897const requestCertificate = async function (
9998 requestId ,
10099 appName ,
101100 envName ,
102- domainName ,
103- subjectAlternativeNames ,
101+ certDomain ,
102+ aliases ,
104103 envHostedZoneId ,
105104 rootDnsRole ,
106- isAliasEnabled ,
107105 region
108106) {
109107 const crypto = require ( "crypto" ) ;
110108 const [ acm , envRoute53 , appRoute53 ] = clients ( region , rootDnsRole ) ;
111- var sansToUse =
112- isAliasEnabled === "false"
113- ? [ `*.${ envName } .${ appName } .${ domainName } ` ]
114- : subjectAlternativeNames ;
109+ // For backward compatiblity.
110+ const sansToUse = [ `*.${ certDomain } ` ] ;
111+ for ( const alias of aliases ) {
112+ sansToUse . push ( alias ) ;
113+ }
115114 const reqCertResponse = await acm
116115 . requestCertificate ( {
117- DomainName : ` ${ envName } . ${ appName } . ${ domainName } ` ,
116+ DomainName : certDomain ,
118117 SubjectAlternativeNames : sansToUse ,
119118 IdempotencyToken : crypto
120119 . createHash ( "sha256" )
@@ -170,9 +169,6 @@ const requestCertificate = async function (
170169 await updateHostedZoneRecords (
171170 "UPSERT" ,
172171 options ,
173- envName ,
174- appName ,
175- domainName ,
176172 envRoute53 ,
177173 appRoute53 ,
178174 envHostedZoneId
@@ -195,17 +191,15 @@ const requestCertificate = async function (
195191const updateHostedZoneRecords = async function (
196192 action ,
197193 options ,
198- envName ,
199- appName ,
200- domainName ,
201194 envRoute53 ,
202195 appRoute53 ,
203196 envHostedZoneId
204197) {
205198 const promises = [ ] ;
206199 for ( const option of options ) {
207- switch ( option . DomainName ) {
208- case `${ envName } .${ appName } .${ domainName } ` :
200+ const domainType = await getDomainType ( option . DomainName ) ;
201+ switch ( domainType ) {
202+ case domainTypes . EnvDomainZone :
209203 promises . push (
210204 validateDomain ( {
211205 route53 : envRoute53 ,
@@ -216,23 +210,23 @@ const updateHostedZoneRecords = async function (
216210 } )
217211 ) ;
218212 break ;
219- case ` ${ appName } . ${ domainName } ` :
213+ case domainTypes . AppDomainZone :
220214 promises . push (
221215 validateDomain ( {
222216 route53 : appRoute53 ,
223217 record : option . ResourceRecord ,
224218 action : action ,
225- domainName : ` ${ appName } . ${ domainName } ` ,
219+ domainName : domainType . domain ,
226220 } )
227221 ) ;
228222 break ;
229- case domainName :
223+ case domainTypes . RootDomainZone :
230224 promises . push (
231225 validateDomain ( {
232226 route53 : appRoute53 ,
233227 record : option . ResourceRecord ,
234228 action : action ,
235- domainName : domainName ,
229+ domainName : domainType . domain ,
236230 } )
237231 ) ;
238232 break ;
@@ -250,9 +244,7 @@ const updateHostedZoneRecords = async function (
250244// if there is no other certificate using the record.
251245const deleteHostedZoneRecords = async function (
252246 options ,
253- envName ,
254- appName ,
255- domainName ,
247+ certDomain ,
256248 envRoute53 ,
257249 appRoute53 ,
258250 acm ,
@@ -265,10 +257,7 @@ const deleteHostedZoneRecords = async function (
265257 isLegacyCert = true ;
266258 }
267259
268- const certsWithEnvDomain = await numOfGeneratedCertificates (
269- acm ,
270- `${ envName } .${ appName } .${ domainName } `
271- ) ;
260+ const certsWithEnvDomain = await numOfGeneratedCertificates ( acm , certDomain ) ;
272261 const isLastOne = certsWithEnvDomain === 1 ;
273262
274263 const newOptions = [ ] ;
@@ -293,19 +282,28 @@ const deleteHostedZoneRecords = async function (
293282 // we'll remove validation CNAME records only for app and root hosted zone,
294283 // since the legacy cert still needs the validation record in the env hosted zone.
295284 for ( const option of options ) {
296- if ( option . DomainName === ` ${ envName } .${ appName } . ${ domainName } `) {
285+ if ( option . DomainName === certDomain || option . DomainName === `* .${ certDomain } `) {
297286 continue ;
298287 }
299288 newOptions . push ( option ) ;
300289 }
301290 break ;
302291 }
292+ // Make sure DNS validation records are unique. For example: "example.com" and "*.example.com"
293+ // might have the same DNS validation record.
294+ const filteredOption = [ ] ;
295+ var uniqueValidateRecordNames = new Set ( ) ;
296+ for ( const option of newOptions ) {
297+ var id = `${ option . ResourceRecord . Name } ${ option . ResourceRecord . Value } ` ;
298+ if ( uniqueValidateRecordNames . has ( id ) ) {
299+ continue ;
300+ }
301+ uniqueValidateRecordNames . add ( id ) ;
302+ filteredOption . push ( option ) ;
303+ }
303304 await updateHostedZoneRecords (
304305 "DELETE" ,
305- newOptions ,
306- envName ,
307- appName ,
308- domainName ,
306+ filteredOption ,
309307 envRoute53 ,
310308 appRoute53 ,
311309 envHostedZoneId
@@ -380,12 +378,13 @@ const validateDomain = async function ({
380378 * If the certificate does not exist, the function will return normally.
381379 *
382380 * @param {string } arn The certificate ARN
381+ * @param {string } certDomain the domain of the certificate
382+ * @param {string } envHostedZoneId the environment Route53 Hosted Zone ID
383+ * @param {string } rootDnsRole the IAM role ARN that can manage domainName
383384 */
384385const deleteCertificate = async function (
385386 arn ,
386- appName ,
387- envName ,
388- domainName ,
387+ certDomain ,
389388 region ,
390389 envHostedZoneId ,
391390 rootDnsRole
@@ -421,7 +420,6 @@ const deleteCertificate = async function (
421420 break ;
422421 }
423422 }
424-
425423 if ( inUseByResources . length ) {
426424 throw new Error (
427425 `Certificate still in use after checking for ${ maxAttempts } attempts.`
@@ -430,9 +428,7 @@ const deleteCertificate = async function (
430428
431429 await deleteHostedZoneRecords (
432430 options ,
433- envName ,
434- appName ,
435- domainName ,
431+ certDomain ,
436432 envRoute53 ,
437433 appRoute53 ,
438434 acm ,
@@ -496,6 +492,38 @@ const updateRecords = function (
496492 . promise ( ) ;
497493} ;
498494
495+ // getAllAliases gets all aliases out from a string. For example:
496+ // {"frontend": ["test.foobar.com", "foobar.com"], "api": ["api.foobar.com"]} will return
497+ // ["test.foobar.com", "foobar.com", "api.foobar.com"].
498+ const getAllAliases = function ( aliases ) {
499+ let obj ;
500+ try {
501+ obj = JSON . parse ( aliases || "{}" ) ;
502+ } catch ( error ) {
503+ throw new Error ( `Cannot parse ${ aliases } into JSON format.` ) ;
504+ }
505+ var aliasList = [ ] ;
506+ for ( var m in obj ) {
507+ aliasList . push ( ...obj [ m ] ) ;
508+ }
509+ return new Set ( aliasList . filter ( function ( itm ) {
510+ return getDomainType ( itm ) != domainTypes . OtherDomainZone ;
511+ } ) ) ;
512+ } ;
513+
514+ const getDomainType = function ( alias ) {
515+ if ( domainTypes . EnvDomainZone . regex . test ( alias ) ) {
516+ return domainTypes . EnvDomainZone ;
517+ }
518+ if ( domainTypes . AppDomainZone . regex . test ( alias ) ) {
519+ return domainTypes . AppDomainZone ;
520+ }
521+ if ( domainTypes . RootDomainZone . regex . test ( alias ) ) {
522+ return domainTypes . RootDomainZone ;
523+ }
524+ return domainTypes . OtherDomainZone ;
525+ } ;
526+
499527const clients = function ( region , rootDnsRole ) {
500528 const acm = new aws . ACM ( {
501529 region,
@@ -522,20 +550,64 @@ exports.certificateRequestHandler = async function (event, context) {
522550 var physicalResourceId ;
523551 var certificateArn ;
524552 const props = event . ResourceProperties ;
553+ const [ app , env , domain ] = [ props . AppName , props . EnvName , props . DomainName ] ;
554+ domainTypes = {
555+ EnvDomainZone : {
556+ regex : new RegExp ( `^([^\.]+\.)?${ env } .${ app } .${ domain } ` ) ,
557+ domain : `${ env } .${ app } .${ domain } ` ,
558+ } ,
559+ AppDomainZone : {
560+ regex : new RegExp ( `^([^\.]+\.)?${ app } .${ domain } ` ) ,
561+ domain : `${ app } .${ domain } ` ,
562+ } ,
563+ RootDomainZone : {
564+ regex : new RegExp ( `^([^\.]+\.)?${ domain } ` ) ,
565+ domain : `${ domain } ` ,
566+ } ,
567+ OtherDomainZone : { } ,
568+ } ;
525569
526570 try {
571+ var certDomain = `${ props . EnvName } .${ props . AppName } .${ props . DomainName } ` ;
572+ var aliases = await getAllAliases ( props . Aliases ) ;
527573 switch ( event . RequestType ) {
528574 case "Create" :
575+ certificateArn = await requestCertificate (
576+ event . RequestId ,
577+ props . AppName ,
578+ props . EnvName ,
579+ certDomain ,
580+ aliases ,
581+ props . EnvHostedZoneId ,
582+ props . RootDNSRole ,
583+ props . Region
584+ ) ;
585+ responseData . Arn = physicalResourceId = certificateArn ;
586+ break ;
529587 case "Update" :
588+ // Exit early if cert doesn't change.
589+ if ( event . OldResourceProperties ) {
590+ var prevAliases = await getAllAliases (
591+ event . OldResourceProperties . Aliases
592+ ) ;
593+ var aliasesToDelete = [ ...prevAliases ] . filter ( function ( itm ) {
594+ return ! aliases . has ( itm ) ;
595+ } ) ;
596+ var aliasesToAdd = [ ...aliases ] . filter ( function ( itm ) {
597+ return ! prevAliases . has ( itm ) ;
598+ } ) ;
599+ if ( aliasesToAdd . length + aliasesToDelete . length === 0 ) {
600+ break ;
601+ }
602+ }
530603 certificateArn = await requestCertificate (
531604 event . RequestId ,
532605 props . AppName ,
533606 props . EnvName ,
534- props . DomainName ,
535- props . SubjectAlternativeNames ,
607+ certDomain ,
608+ aliases ,
536609 props . EnvHostedZoneId ,
537610 props . RootDNSRole ,
538- props . IsAliasEnabled ,
539611 props . Region
540612 ) ;
541613 responseData . Arn = physicalResourceId = certificateArn ;
@@ -547,9 +619,7 @@ exports.certificateRequestHandler = async function (event, context) {
547619 if ( physicalResourceId . startsWith ( "arn:" ) ) {
548620 await deleteCertificate (
549621 physicalResourceId ,
550- props . AppName ,
551- props . EnvName ,
552- props . DomainName ,
622+ certDomain ,
553623 props . Region ,
554624 props . EnvHostedZoneId ,
555625 props . RootDNSRole
@@ -559,7 +629,6 @@ exports.certificateRequestHandler = async function (event, context) {
559629 default :
560630 throw new Error ( `Unsupported request type ${ event . RequestType } ` ) ;
561631 }
562-
563632 await report ( event , context , "SUCCESS" , physicalResourceId , responseData ) ;
564633 } catch ( err ) {
565634 console . log ( `Caught error ${ err } .` ) ;
0 commit comments