@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.
package/src/record.ts ADDED
@@ -0,0 +1,433 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import { Annotations, Duration, RemovalPolicy } from 'aws-cdk-lib';
3
+ import { Construct } from 'constructs';
4
+ import { ICloudflareZone } from './zone';
5
+ import { CloudflareDnsProvider } from './provider';
6
+
7
+ /**
8
+ * The DNS record types supported by this library.
9
+ */
10
+ export enum CloudflareRecordType {
11
+ /** An IPv4 address record. */
12
+ A = 'A',
13
+ /** An IPv6 address record. */
14
+ AAAA = 'AAAA',
15
+ /** A canonical name record. */
16
+ CNAME = 'CNAME',
17
+ /** A text record. */
18
+ TXT = 'TXT',
19
+ /** A mail exchanger record. */
20
+ MX = 'MX',
21
+ /** A name server record. */
22
+ NS = 'NS',
23
+ /** A service locator record. */
24
+ SRV = 'SRV',
25
+ /** A certification authority authorization record. */
26
+ CAA = 'CAA',
27
+ /** A pointer record. */
28
+ PTR = 'PTR',
29
+ /** A uniform resource identifier record. */
30
+ URI = 'URI',
31
+ }
32
+
33
+ /**
34
+ * TTL helpers for Cloudflare records.
35
+ */
36
+ export class CloudflareTtl {
37
+ /**
38
+ * Cloudflare's automatic TTL (a value of `1` on the wire).
39
+ */
40
+ public static readonly AUTO: Duration = Duration.seconds(1);
41
+ }
42
+
43
+ /**
44
+ * The set of record types that may be proxied through Cloudflare.
45
+ */
46
+ const PROXIED_TYPES = new Set([CloudflareRecordType.A, CloudflareRecordType.AAAA, CloudflareRecordType.CNAME]);
47
+
48
+ /**
49
+ * The set of record types that require a priority.
50
+ */
51
+ const PRIORITY_TYPES = new Set([CloudflareRecordType.MX, CloudflareRecordType.SRV, CloudflareRecordType.URI]);
52
+
53
+ /**
54
+ * The minimum TTL accepted by Cloudflare.
55
+ */
56
+ const MIN_TTL_SECONDS = 60;
57
+
58
+ /**
59
+ * The maximum TTL accepted by Cloudflare.
60
+ */
61
+ const MAX_TTL_SECONDS = 86400;
62
+
63
+ /**
64
+ * Properties for a Cloudflare DNS record.
65
+ */
66
+ export interface CloudflareRecordProps {
67
+ /**
68
+ * The Cloudflare zone the record belongs to.
69
+ */
70
+ readonly zone: ICloudflareZone;
71
+
72
+ /**
73
+ * Record name. If it does not end in the zone name and `zone.zoneName` is set,
74
+ * it is treated as relative and the zone name is appended.
75
+ * Use `'@'` or omit for the zone apex.
76
+ *
77
+ * @default - the zone apex
78
+ */
79
+ readonly recordName?: string;
80
+
81
+ /**
82
+ * The record type.
83
+ */
84
+ readonly type: CloudflareRecordType;
85
+
86
+ /**
87
+ * Record value. Mutually exclusive with `data`.
88
+ */
89
+ readonly content?: string;
90
+
91
+ /**
92
+ * Structured value for SRV/CAA/URI records. Mutually exclusive with `content`.
93
+ */
94
+ readonly data?: Record<string, unknown>;
95
+
96
+ /**
97
+ * The time-to-live for the record.
98
+ *
99
+ * @default Duration.minutes(5) — pass `CloudflareTtl.AUTO` for Cloudflare's automatic TTL (1)
100
+ */
101
+ readonly ttl?: Duration;
102
+
103
+ /**
104
+ * Whether to proxy the record through Cloudflare. Only valid for A, AAAA and CNAME.
105
+ *
106
+ * @default false
107
+ */
108
+ readonly proxied?: boolean;
109
+
110
+ /**
111
+ * Record priority. Required for MX, SRV and URI.
112
+ */
113
+ readonly priority?: number;
114
+
115
+ /**
116
+ * A free-form comment attached to the record.
117
+ *
118
+ * @default - no comment
119
+ */
120
+ readonly comment?: string;
121
+
122
+ /**
123
+ * Tags attached to the record.
124
+ *
125
+ * @default - no tags
126
+ */
127
+ readonly tags?: string[];
128
+
129
+ /**
130
+ * If a record with the same name+type already exists in Cloudflare, adopt and
131
+ * manage it instead of failing the deployment.
132
+ *
133
+ * @default false
134
+ */
135
+ readonly adoptExisting?: boolean;
136
+
137
+ /**
138
+ * If RETAIN, the record is left in Cloudflare when the stack resource is deleted.
139
+ *
140
+ * @default RemovalPolicy.DESTROY
141
+ */
142
+ readonly removalPolicy?: RemovalPolicy;
143
+ }
144
+
145
+ /**
146
+ * A Cloudflare DNS record managed as a CloudFormation resource.
147
+ *
148
+ * The construct synthesises a `Custom::CloudflareDnsRecord` custom resource whose
149
+ * Lambda handler calls the Cloudflare API. The API token is resolved at runtime
150
+ * from Secrets Manager; only the secret ARN ever appears in the template.
151
+ */
152
+ export class CloudflareRecord extends Construct {
153
+ /**
154
+ * Cloudflare's record ID, from `GetAtt`. This is the value you can use to
155
+ * locate the record in the Cloudflare dashboard.
156
+ */
157
+ public readonly recordId: string;
158
+
159
+ /**
160
+ * The fully-qualified name actually written to Cloudflare (e.g. `app.example.com`).
161
+ */
162
+ public readonly domainName: string;
163
+
164
+ /**
165
+ * The underlying custom resource.
166
+ */
167
+ private readonly resource: cdk.CustomResource;
168
+
169
+ public constructor(scope: Construct, id: string, props: CloudflareRecordProps) {
170
+ super(scope, id);
171
+
172
+ validateRecordProps(props);
173
+
174
+ const provider = CloudflareDnsProvider.getOrCreate(this);
175
+ provider.grantSecretRead(props.zone.apiToken);
176
+
177
+ const fqdn = resolveRecordName(props.recordName, props.zone.zoneName);
178
+ const ttl = resolveTtl(this, props);
179
+ const content = props.content === undefined
180
+ ? undefined
181
+ : chunkTxt(props.type, props.content);
182
+
183
+ const record: Record<string, unknown> = {
184
+ name: fqdn,
185
+ type: props.type,
186
+ ttl: ttl.toSeconds(),
187
+ };
188
+
189
+ if (props.proxied) {
190
+ record.proxied = true;
191
+ }
192
+ if (content !== undefined) {
193
+ record.content = content;
194
+ }
195
+ if (props.data !== undefined) {
196
+ record.data = props.data;
197
+ }
198
+ if (props.priority !== undefined) {
199
+ record.priority = props.priority;
200
+ }
201
+ if (props.comment !== undefined) {
202
+ record.comment = props.comment;
203
+ }
204
+ if (props.tags !== undefined) {
205
+ record.tags = props.tags;
206
+ }
207
+
208
+ const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY;
209
+
210
+ this.resource = new cdk.CustomResource(this, 'Resource', {
211
+ serviceToken: provider.serviceToken,
212
+ resourceType: 'Custom::CloudflareDnsRecord',
213
+ removalPolicy,
214
+ properties: {
215
+ zoneId: props.zone.zoneId,
216
+ apiTokenSecretArn: props.zone.apiToken.secretArn,
217
+ adoptExisting: props.adoptExisting ?? false,
218
+ retainOnDelete: removalPolicy === RemovalPolicy.RETAIN,
219
+ record,
220
+ },
221
+ });
222
+
223
+ this.recordId = this.resource.getAttString('RecordId');
224
+ this.domainName = this.resource.getAttString('DomainName');
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Validates the record props at synth time, throwing a helpful error rather than
230
+ * deferring the failure to deployment.
231
+ */
232
+ function validateRecordProps(props: CloudflareRecordProps): void {
233
+ const hasContent = props.content !== undefined;
234
+ const hasData = props.data !== undefined;
235
+
236
+ if (hasContent === hasData) {
237
+ throw new Error(
238
+ `CloudflareRecord must specify exactly one of "content" or "data"; got content=${props.content === undefined ? 'undefined' : JSON.stringify(props.content)}, data=${props.data === undefined ? 'undefined' : 'object'}`,
239
+ );
240
+ }
241
+
242
+ if (props.proxied && !PROXIED_TYPES.has(props.type)) {
243
+ throw new Error(`CloudflareRecord "proxied" is only valid for A, AAAA and CNAME records, got ${props.type}`);
244
+ }
245
+
246
+ if (props.priority !== undefined && !PRIORITY_TYPES.has(props.type)) {
247
+ throw new Error(`CloudflareRecord "priority" is only valid for MX, SRV and URI records, got ${props.type}`);
248
+ }
249
+ if (props.priority === undefined && PRIORITY_TYPES.has(props.type)) {
250
+ throw new Error(`CloudflareRecord "priority" is required for ${props.type} records`);
251
+ }
252
+
253
+ if (props.ttl !== undefined) {
254
+ const seconds = props.ttl.toSeconds();
255
+ const isAuto = seconds === CloudflareTtl.AUTO.toSeconds();
256
+ if (!isAuto && (seconds < MIN_TTL_SECONDS || seconds > MAX_TTL_SECONDS)) {
257
+ throw new Error(
258
+ `CloudflareRecord ttl must be between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS} seconds, or CloudflareTtl.AUTO (1); got ${seconds}`,
259
+ );
260
+ }
261
+ }
262
+
263
+ if (props.type === CloudflareRecordType.TXT && hasContent && props.content !== undefined && props.content.length === 0) {
264
+ throw new Error('CloudflareRecord TXT records require non-empty content');
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Resolves the effective TTL for the record. Cloudflare rejects any TTL other
270
+ * than `1` when `proxied` is true, so the TTL is silently forced to automatic
271
+ * and an informational annotation is emitted.
272
+ */
273
+ function resolveTtl(scope: Construct, props: CloudflareRecordProps): Duration {
274
+ const ttl = props.ttl ?? Duration.minutes(5);
275
+
276
+ if (props.proxied && ttl.toSeconds() !== CloudflareTtl.AUTO.toSeconds()) {
277
+ Annotations.of(scope).addInfo(
278
+ `Cloudflare requires an automatic TTL (1) for proxied records; forcing ttl to CloudflareTtl.AUTO for ${props.type} record`,
279
+ );
280
+ return CloudflareTtl.AUTO;
281
+ }
282
+
283
+ return ttl;
284
+ }
285
+
286
+ /**
287
+ * Resolves a relative record name against the zone name, matching the
288
+ * `aws-cdk-lib/aws-route53` ergonomics.
289
+ *
290
+ * - `undefined` or `'@'` resolves to the zone apex.
291
+ * - A name that already ends in the zone name is used verbatim.
292
+ * - Anything else is treated as relative and the zone name is appended.
293
+ */
294
+ export function resolveRecordName(recordName: string | undefined, zoneName: string | undefined): string {
295
+ if (recordName === undefined || recordName === '@') {
296
+ return zoneName ?? '@';
297
+ }
298
+
299
+ if (zoneName !== undefined && (recordName === zoneName || recordName.endsWith(`.${zoneName}`))) {
300
+ return recordName;
301
+ }
302
+
303
+ if (zoneName !== undefined) {
304
+ return `${recordName}.${zoneName}`;
305
+ }
306
+
307
+ return recordName;
308
+ }
309
+
310
+ /**
311
+ * Chunks a TXT record value longer than 255 characters into a series of quoted
312
+ * segments, which is how multiple strings are encoded in a single TXT record.
313
+ */
314
+ function chunkTxt(type: CloudflareRecordType, content: string): string {
315
+ if (type !== CloudflareRecordType.TXT || content.length <= 255) {
316
+ return content;
317
+ }
318
+ const chunks: string[] = [];
319
+ for (let i = 0; i < content.length; i += 255) {
320
+ chunks.push(content.slice(i, i + 255));
321
+ }
322
+ return chunks.map((chunk) => `"${chunk}"`).join(' ');
323
+ }
324
+
325
+ /**
326
+ * Properties for an A record.
327
+ */
328
+ export interface CloudflareARecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'priority'> {
329
+ /** The IPv4 address. */
330
+ readonly content: string;
331
+ /** Whether to proxy the record through Cloudflare. @default false */
332
+ readonly proxied?: boolean;
333
+ }
334
+
335
+ /**
336
+ * An A record.
337
+ */
338
+ export class CloudflareARecord extends CloudflareRecord {
339
+ public constructor(scope: Construct, id: string, props: CloudflareARecordProps) {
340
+ super(scope, id, { ...props, type: CloudflareRecordType.A });
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Properties for an AAAA record.
346
+ */
347
+ export interface CloudflareAaaaRecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'priority'> {
348
+ /** The IPv6 address. */
349
+ readonly content: string;
350
+ /** Whether to proxy the record through Cloudflare. @default false */
351
+ readonly proxied?: boolean;
352
+ }
353
+
354
+ /**
355
+ * An AAAA record.
356
+ */
357
+ export class CloudflareAaaaRecord extends CloudflareRecord {
358
+ public constructor(scope: Construct, id: string, props: CloudflareAaaaRecordProps) {
359
+ super(scope, id, { ...props, type: CloudflareRecordType.AAAA });
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Properties for a CNAME record.
365
+ */
366
+ export interface CloudflareCnameRecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'priority'> {
367
+ /** The canonical name. */
368
+ readonly content: string;
369
+ /** Whether to proxy the record through Cloudflare. @default false */
370
+ readonly proxied?: boolean;
371
+ }
372
+
373
+ /**
374
+ * A CNAME record.
375
+ */
376
+ export class CloudflareCnameRecord extends CloudflareRecord {
377
+ public constructor(scope: Construct, id: string, props: CloudflareCnameRecordProps) {
378
+ super(scope, id, { ...props, type: CloudflareRecordType.CNAME });
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Properties for a TXT record.
384
+ */
385
+ export interface CloudflareTxtRecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'proxied' | 'priority'> {
386
+ /** The text value. Values longer than 255 characters are automatically chunked. */
387
+ readonly content: string;
388
+ }
389
+
390
+ /**
391
+ * A TXT record.
392
+ */
393
+ export class CloudflareTxtRecord extends CloudflareRecord {
394
+ public constructor(scope: Construct, id: string, props: CloudflareTxtRecordProps) {
395
+ super(scope, id, { ...props, type: CloudflareRecordType.TXT });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Properties for an MX record.
401
+ */
402
+ export interface CloudflareMxRecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'proxied'> {
403
+ /** The mail exchanger host. */
404
+ readonly content: string;
405
+ /** The MX priority. */
406
+ readonly priority: number;
407
+ }
408
+
409
+ /**
410
+ * An MX record.
411
+ */
412
+ export class CloudflareMxRecord extends CloudflareRecord {
413
+ public constructor(scope: Construct, id: string, props: CloudflareMxRecordProps) {
414
+ super(scope, id, { ...props, type: CloudflareRecordType.MX });
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Properties for a CAA record.
420
+ */
421
+ export interface CloudflareCaaRecordProps extends Omit<CloudflareRecordProps, 'type' | 'data' | 'proxied' | 'priority'> {
422
+ /** The CAA value. */
423
+ readonly content: string;
424
+ }
425
+
426
+ /**
427
+ * A CAA record.
428
+ */
429
+ export class CloudflareCaaRecord extends CloudflareRecord {
430
+ public constructor(scope: Construct, id: string, props: CloudflareCaaRecordProps) {
431
+ super(scope, id, { ...props, type: CloudflareRecordType.CAA });
432
+ }
433
+ }
package/src/zone.ts ADDED
@@ -0,0 +1,115 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
3
+ import { Construct } from 'constructs';
4
+
5
+ /**
6
+ * A reference to a Cloudflare zone that already exists in the Cloudflare account.
7
+ *
8
+ * `CloudflareZone.fromZoneId()` is the only supported way to obtain one; this
9
+ * library deliberately does not create zones. Zones are assumed to be managed in
10
+ * the Cloudflare dashboard (or elsewhere) and referenced here by their Zone ID.
11
+ */
12
+ export interface ICloudflareZone {
13
+ /**
14
+ * The Cloudflare Zone ID, e.g. `"abc123..."`. This may be a plain string or a
15
+ * CDK token (for example resolved from SSM at deploy time).
16
+ */
17
+ readonly zoneId: string;
18
+
19
+ /**
20
+ * The apex domain, e.g. `"example.com"`. When set, `recordName` values that do
21
+ * not already end in the zone name are treated as relative and this suffix is
22
+ * appended, matching `aws-cdk-lib/aws-route53` ergonomics.
23
+ *
24
+ * @default - record names are used verbatim
25
+ */
26
+ readonly zoneName?: string;
27
+
28
+ /**
29
+ * The Secrets Manager secret holding the Cloudflare API token. The secret may
30
+ * contain the token as a raw string or as a JSON blob with an `apiToken` key.
31
+ * Only the secret ARN ever appears in the CloudFormation template.
32
+ */
33
+ readonly apiToken: secretsmanager.ISecret;
34
+ }
35
+
36
+ /**
37
+ * Properties for referencing an existing Cloudflare zone.
38
+ */
39
+ export interface CloudflareZoneAttributes {
40
+ /**
41
+ * The Cloudflare Zone ID, e.g. `"abc123..."`. This may be a plain string or a
42
+ * CDK token (for example resolved from SSM at deploy time).
43
+ */
44
+ readonly zoneId: string;
45
+
46
+ /**
47
+ * The Secrets Manager secret holding the Cloudflare API token. The secret may
48
+ * contain the token as a raw string or as a JSON blob with an `apiToken` key.
49
+ * Only the secret ARN ever appears in the CloudFormation template.
50
+ */
51
+ readonly apiToken: secretsmanager.ISecret;
52
+
53
+ /**
54
+ * The apex domain, e.g. `"example.com"`. Enables relative record names.
55
+ *
56
+ * @default - record names are used verbatim
57
+ */
58
+ readonly zoneName?: string;
59
+ }
60
+
61
+ /**
62
+ * A reference to an existing Cloudflare zone.
63
+ *
64
+ * Zones are not created by this library; they must already exist in the
65
+ * Cloudflare account. Use `CloudflareZone.fromZoneId()` to reference one.
66
+ */
67
+ export class CloudflareZone extends Construct implements ICloudflareZone {
68
+ /**
69
+ * Reference an existing Cloudflare zone by its Zone ID.
70
+ *
71
+ * @param scope The scope in which to define this construct.
72
+ * @param id The scoped construct ID.
73
+ * @param attrs The zone attributes.
74
+ */
75
+ public static fromZoneId(scope: Construct, id: string, attrs: CloudflareZoneAttributes): ICloudflareZone {
76
+ return new CloudflareZone(scope, id, attrs);
77
+ }
78
+
79
+ public readonly zoneId: string;
80
+ public readonly zoneName?: string;
81
+ public readonly apiToken: secretsmanager.ISecret;
82
+
83
+ private constructor(scope: Construct, id: string, attrs: CloudflareZoneAttributes) {
84
+ super(scope, id);
85
+
86
+ if (attrs.zoneId === undefined || attrs.zoneId === '') {
87
+ throw new Error('CloudflareZone requires a zoneId');
88
+ }
89
+ if (attrs.apiToken === undefined) {
90
+ throw new Error('CloudflareZone requires an apiToken secret');
91
+ }
92
+
93
+ this.zoneId = validateNoToken(attrs.zoneId, 'zoneId');
94
+ this.zoneName = attrs.zoneName === undefined ? undefined : validateNoToken(attrs.zoneName, 'zoneName');
95
+ this.apiToken = attrs.apiToken;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Validates a string value that may be a CDK token. Tokens are never validated
101
+ * with patterns (a regex would break on `${Token[...]}`); only the syntactic
102
+ * sanity of resolved values is checked.
103
+ */
104
+ function validateNoToken(value: string, field: string): string {
105
+ if (cdk.Token.isUnresolved(value)) {
106
+ return value;
107
+ }
108
+ if (value.length === 0) {
109
+ throw new Error(`CloudflareZone ${field} must not be empty`);
110
+ }
111
+ if (/[^A-Za-z0-9._-]/.test(value)) {
112
+ throw new Error(`CloudflareZone ${field} contains invalid characters: ${value}`);
113
+ }
114
+ return value;
115
+ }