This repository has been archived on 2026-04-30. You can view files and clone it, but cannot push or open issues or pull requests.
next-deploy/packages/aws-domain/src/utils.ts

455 lines
13 KiB
TypeScript

import { Route53, ACM, CloudFront } from 'aws-sdk';
import { utils } from '@serverless/core';
import { DomainType } from 'aws-component/types';
import { AwsDomainInputs, Credentials, SubDomain } from '../types';
const DEFAULT_MINIMUM_PROTOCOL_VERSION = 'TLSv1.2_2018';
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
*/
export const getClients = (credentials: Credentials, region = 'us-east-1') => {
const route53 = new Route53({
credentials,
region,
});
const acm = new ACM({
credentials,
region: 'us-east-1', // ACM must be in us-east-1
});
const cf = new CloudFront({
credentials,
region,
});
return {
route53,
acm,
cf,
};
};
/**
* Prepare Domains
* - Formats component domains & identifies cloud services they're using.
*/
export const prepareSubdomains = (inputs: AwsDomainInputs): SubDomain[] => {
const subdomains = [];
for (const subdomain in inputs.subdomains || {}) {
const domainObj: Partial<SubDomain> = {};
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 as SubDomain[];
};
/**
* 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.
*/
export const getDomainHostedZoneId = async (
route53: Route53,
domain: string,
privateZone: boolean
) => {
const hostedZonesRes = await route53.listHostedZonesByName().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
*/
export const describeCertificateByArn = async (acm: ACM, certificateArn: string) => {
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
*/
export const getCertificateArnByDomain = async (acm: ACM, domain: string) => {
const listRes = await acm.listCertificates().promise();
if (!listRes.CertificateSummaryList) {
throw new Error('Could not get a list of certificates');
}
for (const certificate of listRes.CertificateSummaryList) {
if (certificate.DomainName === domain && certificate.CertificateArn) {
if (domain.startsWith('www.')) {
const nakedDomain = domain.replace('wwww.', '');
// check whether certificate support naked domain
const certDetail = await describeCertificateByArn(acm, certificate.CertificateArn);
if (!certDetail?.DomainValidationOptions) {
throw new Error('Could not get a domain validation options');
}
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
*/
export const createCertificate = async (acm: ACM, domain: string) => {
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
*/
export const validateCertificate = async (
acm: ACM,
route53: Route53,
certificate: ACM.CertificateDetail,
domain: string,
domainHostedZoneId: string
) => {
let readinessCheckCount = 16;
let statusCheckCount = 16;
let validationResourceRecord: ACM.ResourceRecord;
/**
* 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 (): Promise<ACM.ResourceRecord> {
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 as string);
if (!cert?.DomainValidationOptions) {
throw new Error(`Could not get a certificate by ${certificate.CertificateArn}`);
}
// Find root domain validation option resource record
cert.DomainValidationOptions.forEach((option) => {
if (domain === option.DomainName && option.ResourceRecord) {
return option.ResourceRecord;
}
});
readinessCheckCount--;
await utils.sleep(5000);
return await checkReadiness();
};
validationResourceRecord = 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 (): Promise<void> {
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 as string);
if (cert?.Status !== 'ISSUED') {
statusCheckCount--;
await utils.sleep(10000);
return await checkStatus();
}
};
await checkStatus();
};
/**
* Configure DNS records for a distribution domain
*/
export const configureDnsForCloudFrontDistribution = async (
route53: Route53,
subdomain: SubDomain,
domainHostedZoneId: string,
distributionUrl: string,
domainType: DomainType
): Promise<Route53.ChangeResourceRecordSetsResponse> => {
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,
},
},
} as never);
}
// 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,
},
},
} as never);
}
return route53.changeResourceRecordSets(dnsRecordParams).promise();
};
/**
* Remove AWS CloudFront Website DNS Records
*/
export const removeCloudFrontDomainDnsRecords = async (
route53: Route53,
domain: string,
domainHostedZoneId: string,
distributionUrl: string
) => {
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 {
await route53.changeResourceRecordSets(params).promise();
} catch (e) {
if (e.code !== 'InvalidChangeBatch') {
throw e;
}
}
};
export const addDomainToCloudfrontDistribution = async (
cf: CloudFront,
subdomain: SubDomain,
certificateArn: string,
domainType: DomainType,
defaultCloudfrontInputs: any
) => {
const distributionConfigResponse = await cf
.getDistributionConfig({ Id: subdomain.distributionId })
.promise();
if (!distributionConfigResponse.DistributionConfig) {
throw new Error('Could not get a distribution config');
}
const updateDistributionRequest = {
IfMatch: distributionConfigResponse.ETag,
Id: subdomain.distributionId,
DistributionConfig: {
CallerReference: distributionConfigResponse.DistributionConfig.CallerReference,
Comment: distributionConfigResponse.DistributionConfig.Comment,
DefaultCacheBehavior: distributionConfigResponse.DistributionConfig.DefaultCacheBehavior,
Enabled: distributionConfigResponse.DistributionConfig.Enabled,
Origins: distributionConfigResponse.DistributionConfig.Origins,
Aliases: {
Quantity: 1,
Items: [subdomain.domain],
},
ViewerCertificate: {
ACMCertificateArn: certificateArn,
SSLSupportMethod: 'sni-only',
MinimumProtocolVersion: DEFAULT_MINIMUM_PROTOCOL_VERSION,
Certificate: certificateArn,
CertificateSource: 'acm',
...defaultCloudfrontInputs.viewerCertificate,
},
},
};
if (subdomain.domain.startsWith('www.')) {
if (domainType === 'apex') {
updateDistributionRequest.DistributionConfig.Aliases.Items = [
`${subdomain.domain.replace('www.', '')}`,
];
} else if (domainType !== 'www') {
updateDistributionRequest.DistributionConfig.Aliases.Quantity = 2;
updateDistributionRequest.DistributionConfig.Aliases.Items.push(
`${subdomain.domain.replace('www.', '')}`
);
}
}
const res = await cf.updateDistribution(updateDistributionRequest).promise();
return {
id: res?.Distribution?.Id,
arn: res?.Distribution?.ARN,
url: res?.Distribution?.DomainName,
};
};
export const removeDomainFromCloudFrontDistribution = async (
cf: CloudFront,
subdomain: SubDomain
) => {
const distributionConfigResponse = await cf
.getDistributionConfig({ Id: subdomain.distributionId })
.promise();
if (!distributionConfigResponse.DistributionConfig) {
throw new Error('Could not get a distribution config');
}
const updateDistributionRequest = {
Id: subdomain.distributionId,
IfMatch: distributionConfigResponse.ETag,
DistributionConfig: {
CallerReference: distributionConfigResponse.DistributionConfig.CallerReference,
Comment: distributionConfigResponse.DistributionConfig.Comment,
DefaultCacheBehavior: distributionConfigResponse.DistributionConfig.DefaultCacheBehavior,
Enabled: distributionConfigResponse.DistributionConfig.Enabled,
Origins: distributionConfigResponse.DistributionConfig.Origins,
Aliases: {
Quantity: 0,
Items: [],
},
ViewerCertificate: {
SSLSupportMethod: 'sni-only',
MinimumProtocolVersion: DEFAULT_MINIMUM_PROTOCOL_VERSION,
},
},
};
const res = await cf.updateDistribution(updateDistributionRequest).promise();
return {
id: res?.Distribution?.Id,
arn: res?.Distribution?.ARN,
url: res?.Distribution?.DomainName,
};
};