@jaggr2/cdk-cf-dns 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ {
2
+ "lockfileVersion": 3
3
+ }
@@ -0,0 +1,145 @@
1
+ import type { CloudFormationCustomResourceCreateEvent, CloudFormationCustomResourceDeleteEvent, CloudFormationCustomResourceEvent, CloudFormationCustomResourceUpdateEvent } from 'aws-lambda';
2
+ import {
3
+ assertSuccess,
4
+ buildRecordPayload,
5
+ createRecord,
6
+ findRecord,
7
+ getApiToken,
8
+ isAlreadyExists,
9
+ isRecordDoesNotExist,
10
+ log,
11
+ RECORD_ID_PATTERN,
12
+ request,
13
+ } from './cloudflare';
14
+
15
+ /**
16
+ * Handles the `Create` request type.
17
+ */
18
+ async function onCreate(event: CloudFormationCustomResourceCreateEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
19
+ const properties = event.ResourceProperties as Record<string, unknown>;
20
+ const zoneId = String(properties.zoneId);
21
+ const payload = buildRecordPayload(properties);
22
+ const token = await getApiToken(String(properties.apiTokenSecretArn));
23
+
24
+ log('create', { zoneId, name: payload.name, type: payload.type });
25
+
26
+ const response = await request(`/zones/${zoneId}/dns_records`, { method: 'POST', token, body: payload });
27
+
28
+ if (isAlreadyExists(response)) {
29
+ if (!properties.adoptExisting) {
30
+ throw new Error(
31
+ `DNS record ${String(payload.name)} (${String(payload.type)}) already exists in zone ${zoneId}. ` +
32
+ 'Either delete it in the Cloudflare dashboard, or set adoptExisting: true to adopt and manage it.',
33
+ );
34
+ }
35
+
36
+ log('adopt', { zoneId, name: payload.name, type: payload.type });
37
+ const existing = await findRecord(zoneId, String(payload.name), String(payload.type), token);
38
+ if (!existing) {
39
+ throw new Error(`Cloudflare reported ${String(payload.name)} already exists but the lookup found no matching record`);
40
+ }
41
+
42
+ await request(`/zones/${zoneId}/dns_records/${existing.id}`, { method: 'PATCH', token, body: payload });
43
+ return { PhysicalResourceId: existing.id, Data: { RecordId: existing.id, DomainName: existing.name } };
44
+ }
45
+
46
+ assertSuccess(response);
47
+ const result = response.body.result as { id: string; name: string };
48
+ return { PhysicalResourceId: result.id, Data: { RecordId: result.id, DomainName: result.name } };
49
+ }
50
+
51
+ /**
52
+ * Handles the `Update` request type.
53
+ */
54
+ async function onUpdate(event: CloudFormationCustomResourceUpdateEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
55
+ const properties = event.ResourceProperties as Record<string, unknown>;
56
+ const oldProperties = event.OldResourceProperties as Record<string, unknown>;
57
+ const zoneId = String(properties.zoneId);
58
+ const payload = buildRecordPayload(properties);
59
+ const token = await getApiToken(String(properties.apiTokenSecretArn));
60
+
61
+ log('update', { zoneId, name: payload.name, type: payload.type });
62
+
63
+ // If the zone changed, the record must be created in the new zone. Returning a
64
+ // new physical id tells CloudFormation to send a Delete for the old one.
65
+ if (String(oldProperties.zoneId) !== zoneId) {
66
+ log('update', { message: 'zone changed; creating in new zone', zoneId });
67
+ const created = await createRecord(zoneId, payload, token);
68
+ return { PhysicalResourceId: created.id, Data: { RecordId: created.id, DomainName: created.name } };
69
+ }
70
+
71
+ const physicalId = event.PhysicalResourceId;
72
+ const response = await request(`/zones/${zoneId}/dns_records/${physicalId}`, { method: 'PATCH', token, body: payload });
73
+
74
+ // The record was deleted out-of-band (e.g. in the dashboard); recreate it.
75
+ if (response.status === 404 || (response.body.success === false && isRecordDoesNotExist(response))) {
76
+ log('update', { message: 'record not found; recreating', zoneId, name: payload.name });
77
+ const created = await createRecord(zoneId, payload, token);
78
+ return { PhysicalResourceId: created.id, Data: { RecordId: created.id, DomainName: created.name } };
79
+ }
80
+
81
+ assertSuccess(response);
82
+ const result = response.body.result as { id: string; name: string };
83
+ return { PhysicalResourceId: physicalId, Data: { RecordId: result.id, DomainName: result.name } };
84
+ }
85
+
86
+ /**
87
+ * Handles the `Delete` request type. Deleting is idempotent: a missing record
88
+ * is treated as success so stacks never become undeletable.
89
+ */
90
+ async function onDelete(event: CloudFormationCustomResourceDeleteEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
91
+ const properties = event.ResourceProperties as Record<string, unknown>;
92
+ const zoneId = String(properties.zoneId);
93
+ const physicalId = event.PhysicalResourceId;
94
+
95
+ if (properties.retainOnDelete) {
96
+ log('delete', { message: 'retainOnDelete set; leaving record in Cloudflare', zoneId });
97
+ return { PhysicalResourceId: physicalId, Data: {} };
98
+ }
99
+
100
+ // A failed Create can leave CloudFormation's arn:... placeholder as the
101
+ // physical id; never throw in that case.
102
+ if (!RECORD_ID_PATTERN.test(physicalId)) {
103
+ log('delete', { message: 'physical id is not a Cloudflare record id; skipping delete', physicalId });
104
+ return { PhysicalResourceId: physicalId, Data: {} };
105
+ }
106
+
107
+ const token = await getApiToken(String(properties.apiTokenSecretArn));
108
+ log('delete', { zoneId, physicalId });
109
+
110
+ const response = await request(`/zones/${zoneId}/dns_records/${physicalId}`, { method: 'DELETE', token });
111
+
112
+ if (response.status === 404 || (response.body.success === false && isRecordDoesNotExist(response))) {
113
+ log('delete', { message: 'record already gone; treating delete as success', physicalId });
114
+ return { PhysicalResourceId: physicalId, Data: {} };
115
+ }
116
+
117
+ assertSuccess(response);
118
+ return { PhysicalResourceId: physicalId, Data: {} };
119
+ }
120
+
121
+ /**
122
+ * The Lambda handler for the `Custom::CloudflareDnsRecord` custom resource.
123
+ *
124
+ * @param event The CloudFormation custom resource event.
125
+ * @returns The custom resource result with `PhysicalResourceId` and `Data`.
126
+ */
127
+ export async function handler(event: CloudFormationCustomResourceEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
128
+ try {
129
+ log('event', { requestType: event.RequestType });
130
+
131
+ switch (event.RequestType) {
132
+ case 'Create':
133
+ return await onCreate(event);
134
+ case 'Update':
135
+ return await onUpdate(event);
136
+ case 'Delete':
137
+ return await onDelete(event);
138
+ default:
139
+ throw new Error(`Unsupported RequestType: ${(event as { RequestType?: string }).RequestType}`);
140
+ }
141
+ } catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ throw new Error(`Cloudflare DNS custom resource failed during ${event.RequestType}: ${message}`);
144
+ }
145
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './zone';
2
+ export * from './record';
3
+ export * from './provider';
4
+ export * from './certificate';
@@ -0,0 +1,223 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as cdk from 'aws-cdk-lib';
4
+ import * as iam from 'aws-cdk-lib/aws-iam';
5
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
6
+ import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
7
+ import * as logs from 'aws-cdk-lib/aws-logs';
8
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
9
+ import * as custom_resources from 'aws-cdk-lib/custom-resources';
10
+ import { Construct } from 'constructs';
11
+
12
+ /**
13
+ * The stable construct ID used for the shared DNS provider on each Stack.
14
+ */
15
+ const PROVIDER_ID = 'AcmeCloudflareDnsProvider';
16
+
17
+ /**
18
+ * The stable construct ID used for the shared ACM provider on each Stack.
19
+ */
20
+ const CERTIFICATE_PROVIDER_ID = 'AcmeCloudflareCertificateProvider';
21
+
22
+ /**
23
+ * Resolves a handler entry point. When the library is consumed from the
24
+ * compiled `lib/` output the source lives at `<package>/src/handler/<file>`;
25
+ * when tests run against `src/` directly it is at `src/handler/<file>`.
26
+ */
27
+ function handlerEntry(file: string): string {
28
+ const fromLib = path.join(__dirname, '..', 'src', 'handler', file);
29
+ if (fs.existsSync(fromLib)) {
30
+ return fromLib;
31
+ }
32
+ return path.join(__dirname, 'handler', file);
33
+ }
34
+
35
+ /**
36
+ * Creates the shared handler function with the provider's standard
37
+ * configuration: Node 22 on ARM, 256 MB, a one-month log group that is deleted
38
+ * with the stack, and an esbuild bundle with the AWS SDK kept external.
39
+ */
40
+ function createHandler(scope: Construct, id: string, file: string, timeout: cdk.Duration): { handler: NodejsFunction; role: iam.IRole } {
41
+ // `NodejsFunction` requires its entry (and lock file) to live under
42
+ // `projectRoot`. The library is consumed from a different project, so both
43
+ // are pointed at the package's own root. The handler has no runtime npm
44
+ // dependencies, so the shipped `deps.lock.json` marker is never actually
45
+ // read — it only satisfies the path validation.
46
+ const projectRoot = path.join(__dirname, '..');
47
+
48
+ const handler = new NodejsFunction(scope, id, {
49
+ entry: handlerEntry(file),
50
+ projectRoot,
51
+ depsLockFilePath: path.join(projectRoot, 'src', 'handler', 'deps.lock.json'),
52
+ runtime: lambda.Runtime.NODEJS_22_X,
53
+ architecture: lambda.Architecture.ARM_64,
54
+ timeout,
55
+ memorySize: 256,
56
+ bundling: {
57
+ minify: true,
58
+ sourceMap: true,
59
+ externalModules: ['@aws-sdk/*'],
60
+ },
61
+ });
62
+
63
+ const logGroup = new logs.LogGroup(scope, `${id}LogGroup`, {
64
+ logGroupName: `/aws/lambda/${handler.functionName}`,
65
+ retention: logs.RetentionDays.ONE_MONTH,
66
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
67
+ });
68
+ logGroup.node.addDependency(handler);
69
+
70
+ if (!handler.role) {
71
+ throw new Error(`Cloudflare provider handler ${id} requires an IAM role`);
72
+ }
73
+
74
+ return { handler, role: handler.role };
75
+ }
76
+
77
+ /**
78
+ * The shared custom-resource provider that performs the Cloudflare DNS API
79
+ * calls.
80
+ *
81
+ * One instance exists per stack (a singleton keyed by `Stack`), so a stack with
82
+ * many records still has exactly one Lambda handler. All records route their
83
+ * custom-resource events through this handler.
84
+ */
85
+ export class CloudflareDnsProvider extends Construct {
86
+ /**
87
+ * Gets (or lazily creates) the provider for the stack of `scope`.
88
+ *
89
+ * @param scope Any construct within the stack that should own the provider.
90
+ */
91
+ public static getOrCreate(scope: Construct): CloudflareDnsProvider {
92
+ const stack = cdk.Stack.of(scope);
93
+ const existing = stack.node.tryFindChild(PROVIDER_ID) as CloudflareDnsProvider | undefined;
94
+ if (existing) {
95
+ return existing;
96
+ }
97
+ return new CloudflareDnsProvider(stack, PROVIDER_ID);
98
+ }
99
+
100
+ /**
101
+ * The custom-resource service token that records use as their `serviceToken`.
102
+ */
103
+ public readonly serviceToken: string;
104
+
105
+ /**
106
+ * The IAM role used by the handler. Record constructs grant `GetSecretValue`
107
+ * on their zone's secret here.
108
+ */
109
+ private readonly handlerRole: iam.IRole;
110
+
111
+ /**
112
+ * Secrets that have already been granted, deduplicated by ARN.
113
+ */
114
+ private readonly grantedSecrets: Set<string> = new Set();
115
+
116
+ private constructor(scope: Construct, id: string) {
117
+ super(scope, id);
118
+
119
+ const { handler, role } = createHandler(this, 'Handler', 'index.ts', cdk.Duration.minutes(2));
120
+
121
+ const provider = new custom_resources.Provider(this, 'Provider', {
122
+ onEventHandler: handler,
123
+ });
124
+
125
+ this.serviceToken = provider.serviceToken;
126
+ this.handlerRole = role;
127
+ }
128
+
129
+ /**
130
+ * Grants the provider's handler `secretsmanager:GetSecretValue` on the given
131
+ * secret. Repeated grants of the same secret are deduplicated.
132
+ *
133
+ * @param secret The secret holding a Cloudflare API token.
134
+ */
135
+ public grantSecretRead(secret: secretsmanager.ISecret): void {
136
+ const arn = secret.secretArn;
137
+ if (this.grantedSecrets.has(arn)) {
138
+ return;
139
+ }
140
+ this.grantedSecrets.add(arn);
141
+ this.handlerRole.addToPrincipalPolicy(new iam.PolicyStatement({
142
+ actions: ['secretsmanager:GetSecretValue'],
143
+ resources: [arn],
144
+ }));
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The shared custom-resource provider that writes ACM DNS validation records
150
+ * into Cloudflare.
151
+ *
152
+ * Like `CloudflareDnsProvider`, one instance exists per stack. It additionally
153
+ * grants its handler `acm:DescribeCertificate` on all certificates.
154
+ */
155
+ export class CloudflareCertificateProvider extends Construct {
156
+ /**
157
+ * Gets (or lazily creates) the provider for the stack of `scope`.
158
+ *
159
+ * @param scope Any construct within the stack that should own the provider.
160
+ */
161
+ public static getOrCreate(scope: Construct): CloudflareCertificateProvider {
162
+ const stack = cdk.Stack.of(scope);
163
+ const existing = stack.node.tryFindChild(CERTIFICATE_PROVIDER_ID) as CloudflareCertificateProvider | undefined;
164
+ if (existing) {
165
+ return existing;
166
+ }
167
+ return new CloudflareCertificateProvider(stack, CERTIFICATE_PROVIDER_ID);
168
+ }
169
+
170
+ /**
171
+ * The custom-resource service token that the certificate construct uses as
172
+ * its `serviceToken`.
173
+ */
174
+ public readonly serviceToken: string;
175
+
176
+ /**
177
+ * The IAM role used by the handler.
178
+ */
179
+ private readonly handlerRole: iam.IRole;
180
+
181
+ /**
182
+ * Secrets that have already been granted, deduplicated by ARN.
183
+ */
184
+ private readonly grantedSecrets: Set<string> = new Set();
185
+
186
+ private constructor(scope: Construct, id: string) {
187
+ super(scope, id);
188
+
189
+ // ACM validation records can take a while to appear after certificate
190
+ // creation, so the handler gets a generous timeout to poll for them.
191
+ const { handler, role } = createHandler(this, 'Handler', 'acm.ts', cdk.Duration.minutes(5));
192
+
193
+ role.addToPrincipalPolicy(new iam.PolicyStatement({
194
+ actions: ['acm:DescribeCertificate'],
195
+ resources: ['*'],
196
+ }));
197
+
198
+ const provider = new custom_resources.Provider(this, 'Provider', {
199
+ onEventHandler: handler,
200
+ });
201
+
202
+ this.serviceToken = provider.serviceToken;
203
+ this.handlerRole = role;
204
+ }
205
+
206
+ /**
207
+ * Grants the provider's handler `secretsmanager:GetSecretValue` on the given
208
+ * secret. Repeated grants of the same secret are deduplicated.
209
+ *
210
+ * @param secret The secret holding a Cloudflare API token.
211
+ */
212
+ public grantSecretRead(secret: secretsmanager.ISecret): void {
213
+ const arn = secret.secretArn;
214
+ if (this.grantedSecrets.has(arn)) {
215
+ return;
216
+ }
217
+ this.grantedSecrets.add(arn);
218
+ this.handlerRole.addToPrincipalPolicy(new iam.PolicyStatement({
219
+ actions: ['secretsmanager:GetSecretValue'],
220
+ resources: [arn],
221
+ }));
222
+ }
223
+ }