diff --git a/CHANGELOG.md b/CHANGELOG.md index 739bab4..59922a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add `route53` config parameter to manage domains and certifiacte automatically using route53 ## [0.8.0] - 2021-1-28 Thanks @pecirep, @miguel-a-calles-mba, @superandrew213 @@ -98,4 +99,4 @@ Better support for generating client code on Windows [0.5.4]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.3...v0.5.4 [0.5.3]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.2...v0.5.3 [0.5.2]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.1...v0.5.2 -[0.5.1]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.0...v0.5.1 \ No newline at end of file +[0.5.1]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.0...v0.5.1 diff --git a/README.md b/README.md index 9f61b80..bd7a005 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ custom: fullstack: domain: my-custom-domain.com certificate: arn:aws:acm:us-east-1:... # The ARN for the SSL cert to use form AWS CertificateManager + route53: false # Use route53 to manage domains and certificate bucketName: webapp-deploy # Unique name for the S3 bucket to host the client assets distributionFolder: client/dist # Path to the client assets to be uploaded to S3 indexDocument: index.html # The index document to use @@ -259,6 +260,24 @@ The custom domain for your fullstack serverless app. --- +**route53** + +_optional_, default: `false` + +```yaml +custom: + fullstack: + ... + route53: true + ... +``` + +Use this parameter if you want the plugin to manage domains via route53, including automatic SSL certificate creation and validation through ACM (this means you can omit the `certificateArn` parameter). + +If one or more domains aren't managed by a Hosted Zone in your account, the required DNS entries will be written to the console. + +--- + **errorDocument** _optional_, default: `error.html` diff --git a/index.js b/index.js index b6a4d31..90c7d34 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ const bucketUtils = require('./lib/bucketUtils'); const uploadDirectory = require('./lib/upload'); const validateClient = require('./lib/validate'); const invalidateCloudfrontDistribution = require('./lib/cloudFront'); +const {groupDomainsByHostedZone} = require('./lib/route53'); class ServerlessFullstackPlugin { constructor(serverless, cliOptions) { @@ -256,8 +257,9 @@ class ServerlessFullstackPlugin { filename: filename }); - this.prepareResources(resources); - return _.merge(baseResources, resources); + return this.prepareResources(resources).then(() => { + return _.merge(baseResources, resources); + }); } checkForApiGataway() { @@ -335,9 +337,11 @@ class ServerlessFullstackPlugin { this.serverless.cli.consoleLog(` ${apiDistributionDomain.OutputValue} (CNAME: ${cnameDomain})`); } - prepareResources(resources) { + async prepareResources(resources) { const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig; + await this.prepareRoute53(resources.Resources); + this.prepareLogging(distributionConfig); this.prepareDomain(distributionConfig); this.preparePriceClass(distributionConfig); @@ -353,6 +357,55 @@ class ServerlessFullstackPlugin { } + async prepareRoute53(resources) { + if (this.options.domain) { + const certificate = this.getConfig("certificate", null); + const distributionCertificate = resources.ApiDistribution.Properties.DistributionConfig.ViewerCertificate; + + if (this.getConfig("route53", false) === true) { + const filename = path.resolve(__dirname, 'lib/resources/templates.yml'); + const content = fs.readFileSync(filename, 'utf-8'); + const templates = yaml.safeLoad(content, {filename}); + + const domains = Array.isArray(this.options.domain) ? this.options.domain : [this.options.domain]; + const domainsByHostedZones = await groupDomainsByHostedZone(this.serverless, domains); + const domainsWithoutHostedZone = domainsByHostedZones + .filter((hostedZone) => !hostedZone.Id) + .reduce((acc, hostedZone) => [...acc, ...hostedZone.domains], []); + const filteredDomainsByHostedZones = domainsByHostedZones + .filter(hostedZone => !!hostedZone.Id && hostedZone.domains.length); + + if (domainsWithoutHostedZone?.length > 0) + this.serverless.cli.log(`No hosted zones found for ${domainsWithoutHostedZone}, records pointing to` + +` the cloudfront domain will have to be added manually.`, "Route53", {color: "orange", underline: true}); + + const aliasTemplate = templates.Route53AliasTemplate; + const recordSetTemplate = aliasTemplate.Properties.RecordSets.pop(); + + for (const hostedZone of filteredDomainsByHostedZones) { + const recordSets = hostedZone.domains.map(domain => ({...recordSetTemplate, Name: domain})); + const alias = {...aliasTemplate, Properties: {...aliasTemplate.Properties, RecordSets: recordSets, HostedZoneId: hostedZone.Id}}; + resources["Route53AliasHZ" + hostedZone.Id] = alias; + } + + // only create and override if not specified + if (certificate === null) { + const certTemplate = templates.CertTemplate; + certTemplate.Properties.DomainName = domains[0]; + if (domains.length > 1) certTemplate.Properties.SubjectAlternativeNames = domains.slice(1); + + const route53domainValidations = filteredDomainsByHostedZones.flatMap(hz => hz.domains.map(DomainName => ({DomainName, HostedZoneId: hz.Id}))); + const manualValidations = domainsWithoutHostedZone.map(DomainName => ({DomainName, ValidationDomain: DomainName})); + certTemplate.Properties.DomainValidationOptions = [...route53domainValidations, ...manualValidations]; + + const certResourceName = "ApiDistributionCertificate"; + resources[certResourceName] = certTemplate; + distributionCertificate.AcmCertificateArn = {Ref: certResourceName}; + } + } + } + } + prepareLogging(distributionConfig) { const loggingBucket = this.getConfig('logging.bucket', null); @@ -428,7 +481,7 @@ class ServerlessFullstackPlugin { if (certificate !== null) { this.serverless.cli.log(`Configuring SSL certificate...`); distributionConfig.ViewerCertificate.AcmCertificateArn = certificate; - } else { + } else if (!distributionConfig.ViewerCertificate.AcmCertificateArn) { delete distributionConfig.ViewerCertificate; } } diff --git a/lib/resources/templates.yml b/lib/resources/templates.yml new file mode 100644 index 0000000..1cd9882 --- /dev/null +++ b/lib/resources/templates.yml @@ -0,0 +1,26 @@ +Route53AliasTemplate: + Type: AWS::Route53::RecordSetGroup + Properties: + HostedZoneId: hostedZoneId + RecordSets: + - Name: domain.tld + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 # The static CloudFront Hosted Zone ID + DNSName: + Fn::GetAtt: ["ApiDistribution", "DomainName"] + EvaluateTargetHealth: false + # ... + +CertTemplate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: domain.tld + DomainValidationOptions: + # - DomainName: domain.tld + # HostedZoneId: hostedZoneId + # - DomainName: example.com + # ValidationDomain: example.com + # ... + ValidationMethod: DNS + # SubjectAlternativeNames: diff --git a/lib/route53.js b/lib/route53.js new file mode 100644 index 0000000..263b174 --- /dev/null +++ b/lib/route53.js @@ -0,0 +1,18 @@ +// get hosted zones, group domains by HZ.Id using .reduce and extract values +const groupDomainsByHostedZone = async (serverless, domains) => { + const r53response = await serverless.getProvider('aws').request('Route53', 'listHostedZones', {}); + // we only want raw Ids + const hostedZones = r53response.HostedZones.map(hz => ({...hz, Id: hz.Id.split("/").reverse()[0]})); + + const hostedZoneMap = domains.reduce((accumulator, domain) => { + const hostedZone = hostedZones.find(hostedZone => `${domain}.`.includes(hostedZone.Name)); + if (accumulator[hostedZone?.Id]) accumulator[hostedZone?.Id].domains.push(domain); + else accumulator[hostedZone?.Id] = {...hostedZone, domains: [domain]}; + return accumulator; + }, {}); + return Object.values(hostedZoneMap); +} + +module.exports = { + groupDomainsByHostedZone +};