@sls-next/domain
Version:
Easily provision custom domains for:
526 lines (449 loc) • 13.6 kB
text/typescript
import AWS from "aws-sdk";
import { utils } from "@serverless/core";
const HOSTED_ZONE_ID = "Z2FDTNDATAQYW2"; // this is a constant that you can get from here https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html
/**
* Get Clients
* - Gets AWS SDK clients to use within this Component
*/
const getClients = (credentials, region = "us-east-1") => {
if (AWS && AWS.config) {
AWS.config.update({
maxRetries: parseInt(process.env.SLS_NEXT_MAX_RETRIES || "10"),
retryDelayOptions: { base: 200 }
});
}
const route53 = new AWS.Route53({
credentials,
region
});
const acm = new AWS.ACM({
credentials,
region: "us-east-1" // ACM must be in us-east-1
});
const cf = new AWS.CloudFront({
credentials,
region
});
return {
route53,
acm,
cf
};
};
/**
* Prepare Domains
* - Formats component domains & identifies cloud services they're using.
*/
const prepareSubdomains = (inputs) => {
const subdomains = [];
for (const subdomain in inputs.subdomains || {}) {
const domainObj: any = {};
domainObj.domain = `${subdomain}.${inputs.domain}`;
if (inputs.subdomains[subdomain].url.includes("cloudfront")) {
domainObj.distributionId = inputs.subdomains[subdomain].id;
domainObj.url = inputs.subdomains[subdomain].url;
domainObj.type = "awsCloudFront";
}
subdomains.push(domainObj);
}
return subdomains;
};
const getOutdatedDomains = (inputs, state) => {
if (inputs.domain !== state.domain) {
return state;
}
const outdatedDomains = {
domain: state.domain,
subdomains: []
};
for (const domain of state.subdomains) {
if (!inputs.subdomains[domain.domain]) {
outdatedDomains.subdomains.push(domain);
}
}
return outdatedDomains;
};
/**
* Get Domain Hosted Zone ID
* - Every Domain on Route53 always has a Hosted Zone w/ 2 Record Sets.
* - These Record Sets are: "Name Servers (NS)" & "Start of Authority (SOA)"
* - These don't need to be created and SHOULD NOT be modified.
*/
const getDomainHostedZoneId = async (route53, domain, privateZone) => {
const params = {
DNSName: domain
};
const hostedZonesRes = await route53.listHostedZonesByName(params).promise();
const hostedZone = hostedZonesRes.HostedZones.find(
// Name has a period at the end, so we're using includes rather than equals
(zone) =>
zone.Config.PrivateZone === privateZone && zone.Name.includes(domain)
);
if (!hostedZone) {
throw Error(
`Domain ${domain} was not found in your AWS account. Please purchase it from Route53 first then try again.`
);
}
return hostedZone.Id.replace("/hostedzone/", ""); // hosted zone id is always prefixed with this :(
};
/**
* Describe Certificate By Arn
* - Describe an AWS ACM Certificate by its ARN
*/
const describeCertificateByArn = async (acm, certificateArn) => {
const certificate = await acm
.describeCertificate({ CertificateArn: certificateArn })
.promise();
return certificate && certificate.Certificate
? certificate.Certificate
: null;
};
/**
* Get Certificate Arn By Domain
* - Gets an AWS ACM Certificate by a specified domain or return null
*/
const getCertificateArnByDomain = async (acm, domain) => {
const listRes = await acm.listCertificates().promise();
for (const certificate of listRes.CertificateSummaryList) {
if (certificate.DomainName === domain && certificate.CertificateArn) {
if (domain.startsWith("www.")) {
const nakedDomain = domain.replace("www.", "");
// check whether certificate support naked domain
const certDetail = await describeCertificateByArn(
acm,
certificate.CertificateArn
);
const nakedDomainCert = certDetail.DomainValidationOptions.find(
({ DomainName }) => DomainName === nakedDomain
);
if (!nakedDomainCert) {
continue;
}
}
return certificate.CertificateArn;
}
}
return null;
};
/**
* Create Certificate
* - Creates an AWS ACM Certificate for the specified domain
*/
const createCertificate = async (acm, domain) => {
const wildcardSubDomain = `*.${domain}`;
const params = {
DomainName: domain,
SubjectAlternativeNames: [domain, wildcardSubDomain],
ValidationMethod: "DNS"
};
const res = await acm.requestCertificate(params).promise();
return res.CertificateArn;
};
/**
* Validate Certificate
* - Validate an AWS ACM Certificate via the "DNS" method
*/
const validateCertificate = async (
acm,
route53,
certificate,
domain,
domainHostedZoneId
) => {
let readinessCheckCount = 16;
let statusCheckCount = 16;
let validationResourceRecord;
/**
* Check Readiness
* - Newly Created AWS ACM Certificates may not yet have the info needed to validate it
* - Specifically, the "ResourceRecord" object in the Domain Validation Options
* - Ensure this exists.
*/
const checkReadiness = async function () {
if (readinessCheckCount < 1) {
throw new Error(
"Your newly created AWS ACM Certificate is taking a while to initialize. Please try running this component again in a few minutes."
);
}
const cert = await describeCertificateByArn(
acm,
certificate.CertificateArn
);
// Find root domain validation option resource record
cert.DomainValidationOptions.forEach((option) => {
if (domain === option.DomainName) {
validationResourceRecord = option.ResourceRecord;
}
});
if (!validationResourceRecord) {
readinessCheckCount--;
await utils.sleep(5000);
return await checkReadiness();
}
};
await checkReadiness();
const checkRecordsParams = {
HostedZoneId: domainHostedZoneId,
MaxItems: "10",
StartRecordName: validationResourceRecord.Name
};
// Check if the validation resource record sets already exist.
// This might be the case if the user is trying to deploy multiple times while validation is occurring.
const existingRecords = await route53
.listResourceRecordSets(checkRecordsParams)
.promise();
if (!existingRecords.ResourceRecordSets.length) {
// Create CNAME record for DNS validation check
// NOTE: It can take 30 minutes or longer for DNS propagation so validation can complete, just continue on and don't wait for this...
const recordParams = {
HostedZoneId: domainHostedZoneId,
ChangeBatch: {
Changes: [
{
Action: "UPSERT",
ResourceRecordSet: {
Name: validationResourceRecord.Name,
Type: validationResourceRecord.Type,
TTL: 300,
ResourceRecords: [
{
Value: validationResourceRecord.Value
}
]
}
}
]
}
};
await route53.changeResourceRecordSets(recordParams).promise();
}
/**
* Check Validated Status
* - Newly Validated AWS ACM Certificates may not yet show up as valid
* - This gives them some time to update their status.
*/
const checkStatus = async function () {
if (statusCheckCount < 1) {
throw new Error(
"Your newly validated AWS ACM Certificate is taking a while to register as valid. Please try running this component again in a few minutes."
);
}
const cert = await describeCertificateByArn(
acm,
certificate.CertificateArn
);
if (cert.Status !== "ISSUED") {
statusCheckCount--;
await utils.sleep(10000);
return await checkStatus();
}
};
await checkStatus();
};
/**
* Configure DNS records for a distribution domain
*/
const configureDnsForCloudFrontDistribution = (
route53,
subdomain,
domainHostedZoneId,
distributionUrl,
domainType,
context
) => {
const dnsRecordParams = {
HostedZoneId: domainHostedZoneId,
ChangeBatch: {
Changes: []
}
};
// don't create www records for apex mode
if (!subdomain.domain.startsWith("www.") || domainType !== "apex") {
dnsRecordParams.ChangeBatch.Changes.push({
Action: "UPSERT",
ResourceRecordSet: {
Name: subdomain.domain,
Type: "A",
AliasTarget: {
HostedZoneId: HOSTED_ZONE_ID,
DNSName: distributionUrl,
EvaluateTargetHealth: false
}
}
});
}
// don't create apex records for www mode
if (subdomain.domain.startsWith("www.") && domainType !== "www") {
dnsRecordParams.ChangeBatch.Changes.push({
Action: "UPSERT",
ResourceRecordSet: {
Name: subdomain.domain.replace("www.", ""),
Type: "A",
AliasTarget: {
HostedZoneId: HOSTED_ZONE_ID,
DNSName: distributionUrl,
EvaluateTargetHealth: false
}
}
});
}
context.debug(
"Updating Route53 DNS records with parameters:\n" +
JSON.stringify(dnsRecordParams, null, 2)
);
return route53.changeResourceRecordSets(dnsRecordParams).promise();
};
/**
* Remove AWS CloudFront Website DNS Records
*/
const removeCloudFrontDomainDnsRecords = async (
route53,
domain,
domainHostedZoneId,
distributionUrl,
context
) => {
const params = {
HostedZoneId: domainHostedZoneId,
ChangeBatch: {
Changes: [
{
Action: "DELETE",
ResourceRecordSet: {
Name: domain,
Type: "A",
AliasTarget: {
HostedZoneId: HOSTED_ZONE_ID,
DNSName: distributionUrl,
EvaluateTargetHealth: false
}
}
}
]
}
};
// TODO: should the CNAME records be removed too?
try {
context.debug(
"Updating Route53 with parameters:\n" + JSON.stringify(params, null, 2)
);
await route53.changeResourceRecordSets(params).promise();
} catch (e) {
if (e.code !== "InvalidChangeBatch") {
throw e;
}
}
};
const addDomainToCloudfrontDistribution = async (
cf,
subdomain,
certificateArn,
domainMinimumProtocolVersion,
domainType,
defaultCloudfrontInputs,
context
) => {
// Update logic is a bit weird...
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html#updateDistribution-property
// 1. we gotta get the config first...
const params = await cf
.getDistributionConfig({ Id: subdomain.distributionId })
.promise();
// 2. then add this property
params.IfMatch = params.ETag;
// 3. then delete this property
delete params.ETag;
// 4. then set this property
params.Id = subdomain.distributionId;
// 5. then make our changes
params.DistributionConfig.Aliases = {
Items: [subdomain.domain]
};
if (subdomain.domain.startsWith("www.")) {
if (domainType === "apex") {
params.DistributionConfig.Aliases.Items = [
`${subdomain.domain.replace("www.", "")}`
];
} else if (domainType !== "www") {
params.DistributionConfig.Aliases.Items.push(
`${subdomain.domain.replace("www.", "")}`
);
}
}
// Update aliases quantity to reflect actual number of items
params.DistributionConfig.Aliases.Quantity =
params.DistributionConfig.Aliases.Items.length;
params.DistributionConfig.ViewerCertificate = {
ACMCertificateArn: certificateArn,
SSLSupportMethod: "sni-only",
MinimumProtocolVersion: domainMinimumProtocolVersion,
Certificate: certificateArn,
CertificateSource: "acm",
...defaultCloudfrontInputs.viewerCertificate
};
context.debug(
"Updating CloudFront distribution with parameters:\n" +
JSON.stringify(params, null, 2)
);
// 6. then finally update!
const res = await cf.updateDistribution(params).promise();
return {
id: res.Distribution.Id,
arn: res.Distribution.ARN,
url: res.Distribution.DomainName
};
};
const removeDomainFromCloudFrontDistribution = async (
cf,
subdomain,
domainMinimumProtocolVersion,
context
) => {
const params = await cf
.getDistributionConfig({ Id: subdomain.distributionId })
.promise();
params.IfMatch = params.ETag;
delete params.ETag;
params.Id = subdomain.distributionId;
params.DistributionConfig.Aliases = {
Quantity: 0,
Items: []
};
params.DistributionConfig.ViewerCertificate = {
SSLSupportMethod: "sni-only",
MinimumProtocolVersion: domainMinimumProtocolVersion
};
context.debug(
"Updating CloudFront distribution with parameters:\n" +
JSON.stringify(params, null, 2)
);
const res = await cf.updateDistribution(params).promise();
return {
id: res.Distribution.Id,
arn: res.Distribution.ARN,
url: res.Distribution.DomainName
};
};
const isMinimumProtocolVersionValid = (minimumProtocolVersion) => {
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-viewercertificate.html
const validMinimumProtocolVersions =
/(^SSLv3$|^TLSv1$|^TLSv1.1_2016$|^TLSv1.2_2018$|^TLSv1.2_2019$|^TLSv1.2_2021$|^TLSv1_2016$)/g;
return (
minimumProtocolVersion.match(validMinimumProtocolVersions)?.length === 1
);
};
export {
getClients,
prepareSubdomains,
getOutdatedDomains,
describeCertificateByArn,
getCertificateArnByDomain,
createCertificate,
validateCertificate,
getDomainHostedZoneId,
configureDnsForCloudFrontDistribution,
removeCloudFrontDomainDnsRecords,
addDomainToCloudfrontDistribution,
removeDomainFromCloudFrontDistribution,
isMinimumProtocolVersionValid
};