-
Notifications
You must be signed in to change notification settings - Fork 3.9k
/
index.ts
127 lines (112 loc) · 4.31 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// eslint-disable-next-line import/no-extraneous-dependencies
import { Route53 } from '@aws-sdk/client-route-53';
// eslint-disable-next-line import/no-extraneous-dependencies
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
export type CrossAccountZoneDelegationEvent = AWSLambda.CloudFormationCustomResourceEvent & {
ResourceProperties: ResourceProperties;
OldResourceProperties?: ResourceProperties;
}
interface ResourceProperties {
AssumeRoleArn: string,
ParentZoneName?: string,
ParentZoneId?: string,
DelegatedZoneName: string,
DelegatedZoneNameServers: string[],
TTL: number,
AssumeRoleRegion?: string,
}
export async function handler(event: CrossAccountZoneDelegationEvent) {
const resourceProps = event.ResourceProperties;
switch (event.RequestType) {
case 'Create':
return cfnEventHandler(resourceProps, false);
case 'Update':
return cfnUpdateEventHandler(resourceProps, event.OldResourceProperties);
case 'Delete':
return cfnEventHandler(resourceProps, true);
}
}
async function cfnUpdateEventHandler(props: ResourceProperties, oldProps: ResourceProperties | undefined) {
if (oldProps && props.DelegatedZoneName !== oldProps.DelegatedZoneName) {
await cfnEventHandler(oldProps, true);
}
await cfnEventHandler(props, false);
}
async function cfnEventHandler(props: ResourceProperties, isDeleteEvent: boolean) {
const { AssumeRoleArn, ParentZoneId, ParentZoneName, DelegatedZoneName, DelegatedZoneNameServers, TTL, AssumeRoleRegion } = props;
if (!ParentZoneId && !ParentZoneName) {
throw Error('One of ParentZoneId or ParentZoneName must be specified');
}
const timestamp = (new Date()).getTime();
const route53 = new Route53({
credentials: fromTemporaryCredentials({
clientConfig: {
region: AssumeRoleRegion ?? route53Region(process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? ''),
},
params: {
RoleArn: AssumeRoleArn,
RoleSessionName: `cross-account-zone-delegation-${timestamp}`,
},
}),
});
const parentZoneId = ParentZoneId ?? await getHostedZoneIdByName(ParentZoneName!, route53);
await route53.changeResourceRecordSets({
HostedZoneId: parentZoneId,
ChangeBatch: {
Changes: [{
Action: isDeleteEvent ? 'DELETE' : 'UPSERT',
ResourceRecordSet: {
Name: DelegatedZoneName,
Type: 'NS',
TTL,
ResourceRecords: DelegatedZoneNameServers.map(ns => ({ Value: ns })),
},
}],
},
});
}
async function getHostedZoneIdByName(name: string, route53: Route53): Promise<string> {
const zones = await route53.listHostedZonesByName({ DNSName: name });
const matchedZones = zones.HostedZones?.filter(zone => zone.Name === `${name}.`) ?? [];
if (matchedZones && matchedZones.length !== 1) {
throw Error(`Expected one hosted zone to match the given name but found ${matchedZones.length}`);
}
// will always be defined because we throw if length !==1
return matchedZones[0].Id!;
}
/**
* Return the region that hosts the Route53 endpoint
*
* Route53 is a partitional service: the control plane lives in one particular region,
* which is different for every partition.
*
* The SDK knows how to convert a "target region" to a "route53 endpoint", which
* equates to a (potentially different) region. However, when we use STS
* AssumeRole credentials, we must grab credentials that will work in that
* region.
*
* By default, STS AssumeRole will call the STS endpoint for the same region
* as the Lambda runs in. Normally, this is all good. However, when the AssumeRole
* is used to assume a role in a different account A, the AssumeRole will fail if the
* Lambda is executing in an an opt-in region R to which account A has not been opted in.
*
* To solve this, we will always AssumeRole in the same region as the Route53 call will
* resolve to.
*/
function route53Region(region: string) {
const partitions = {
'cn': 'cn-northwest-1',
'us-gov': 'us-gov-west-1',
'us-iso': 'us-iso-east-1',
'us-isob': 'us-isob-east-1',
'eu-isoe': 'eu-isoe-west-1',
'us-isof': 'us-isof-south-1',
};
for (const [prefix, mainRegion] of Object.entries(partitions)) {
if (region.startsWith(`${prefix}-`)) {
return mainRegion;
}
}
// Default for commercial partition
return 'us-east-1';
}