455 lines
13 KiB
TypeScript
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,
|
|
};
|
|
};
|