@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/API.md +411 -0
- package/README.md +194 -0
- package/lib/certificate.d.ts +41 -0
- package/lib/certificate.js +83 -0
- package/lib/certificate.js.map +1 -0
- package/lib/handler/acm.d.ts +12 -0
- package/lib/handler/acm.js +131 -0
- package/lib/handler/acm.js.map +1 -0
- package/lib/handler/cloudflare.d.ts +133 -0
- package/lib/handler/cloudflare.js +264 -0
- package/lib/handler/cloudflare.js.map +1 -0
- package/lib/handler/index.d.ts +11 -0
- package/lib/handler/index.js +114 -0
- package/lib/handler/index.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +21 -0
- package/lib/index.js.map +1 -0
- package/lib/provider.d.ts +75 -0
- package/lib/provider.js +234 -0
- package/lib/provider.js.map +1 -0
- package/lib/record.d.ts +225 -0
- package/lib/record.js +297 -0
- package/lib/record.js.map +1 -0
- package/lib/zone.d.ts +72 -0
- package/lib/zone.js +90 -0
- package/lib/zone.js.map +1 -0
- package/package.json +61 -0
- package/src/certificate.ts +74 -0
- package/src/handler/acm.ts +160 -0
- package/src/handler/cloudflare.ts +305 -0
- package/src/handler/deps.lock.json +3 -0
- package/src/handler/index.ts +145 -0
- package/src/index.ts +4 -0
- package/src/provider.ts +223 -0
- package/src/record.ts +433 -0
- package/src/zone.ts +115 -0
package/lib/zone.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CloudflareZone = void 0;
|
|
37
|
+
const cdk = __importStar(require("aws-cdk-lib"));
|
|
38
|
+
const constructs_1 = require("constructs");
|
|
39
|
+
/**
|
|
40
|
+
* A reference to an existing Cloudflare zone.
|
|
41
|
+
*
|
|
42
|
+
* Zones are not created by this library; they must already exist in the
|
|
43
|
+
* Cloudflare account. Use `CloudflareZone.fromZoneId()` to reference one.
|
|
44
|
+
*/
|
|
45
|
+
class CloudflareZone extends constructs_1.Construct {
|
|
46
|
+
/**
|
|
47
|
+
* Reference an existing Cloudflare zone by its Zone ID.
|
|
48
|
+
*
|
|
49
|
+
* @param scope The scope in which to define this construct.
|
|
50
|
+
* @param id The scoped construct ID.
|
|
51
|
+
* @param attrs The zone attributes.
|
|
52
|
+
*/
|
|
53
|
+
static fromZoneId(scope, id, attrs) {
|
|
54
|
+
return new CloudflareZone(scope, id, attrs);
|
|
55
|
+
}
|
|
56
|
+
zoneId;
|
|
57
|
+
zoneName;
|
|
58
|
+
apiToken;
|
|
59
|
+
constructor(scope, id, attrs) {
|
|
60
|
+
super(scope, id);
|
|
61
|
+
if (attrs.zoneId === undefined || attrs.zoneId === '') {
|
|
62
|
+
throw new Error('CloudflareZone requires a zoneId');
|
|
63
|
+
}
|
|
64
|
+
if (attrs.apiToken === undefined) {
|
|
65
|
+
throw new Error('CloudflareZone requires an apiToken secret');
|
|
66
|
+
}
|
|
67
|
+
this.zoneId = validateNoToken(attrs.zoneId, 'zoneId');
|
|
68
|
+
this.zoneName = attrs.zoneName === undefined ? undefined : validateNoToken(attrs.zoneName, 'zoneName');
|
|
69
|
+
this.apiToken = attrs.apiToken;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.CloudflareZone = CloudflareZone;
|
|
73
|
+
/**
|
|
74
|
+
* Validates a string value that may be a CDK token. Tokens are never validated
|
|
75
|
+
* with patterns (a regex would break on `${Token[...]}`); only the syntactic
|
|
76
|
+
* sanity of resolved values is checked.
|
|
77
|
+
*/
|
|
78
|
+
function validateNoToken(value, field) {
|
|
79
|
+
if (cdk.Token.isUnresolved(value)) {
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
if (value.length === 0) {
|
|
83
|
+
throw new Error(`CloudflareZone ${field} must not be empty`);
|
|
84
|
+
}
|
|
85
|
+
if (/[^A-Za-z0-9._-]/.test(value)) {
|
|
86
|
+
throw new Error(`CloudflareZone ${field} contains invalid characters: ${value}`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=zone.js.map
|
package/lib/zone.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zone.js","sourceRoot":"","sources":["../src/zone.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,iDAAmC;AAEnC,2CAAuC;AA0DvC;;;;;GAKG;AACH,MAAa,cAAe,SAAQ,sBAAS;IAC3C;;;;;;OAMG;IACI,MAAM,CAAC,UAAU,CAAC,KAAgB,EAAE,EAAU,EAAE,KAA+B;QACpF,OAAO,IAAI,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;IAC9C,CAAC;IAEe,MAAM,CAAS;IACf,QAAQ,CAAU;IAClB,QAAQ,CAAyB;IAEjD,YAAoB,KAAgB,EAAE,EAAU,EAAE,KAA+B;QAC/E,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAEjB,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,IAAI,KAAK,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACtD,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;YACjC,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;QAChE,CAAC;QAED,IAAI,CAAC,MAAM,GAAG,eAAe,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QACtD,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;QACvG,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;IACjC,CAAC;CACF;AA9BD,wCA8BC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,KAAa,EAAE,KAAa;IACnD,IAAI,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;QAClC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,kBAAkB,KAAK,oBAAoB,CAAC,CAAC;IAC/D,CAAC;IACD,IAAI,iBAAiB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,kBAAkB,KAAK,iCAAiC,KAAK,EAAE,CAAC,CAAC;IACnF,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jaggr2/cdk-cf-dns",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage Cloudflare DNS records as CloudFormation resources from AWS CDK, so Route 53 hosted zones are no longer needed.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"src",
|
|
10
|
+
"API.md"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"aws",
|
|
15
|
+
"cdk",
|
|
16
|
+
"cloudformation",
|
|
17
|
+
"cloudflare",
|
|
18
|
+
"dns",
|
|
19
|
+
"route53"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/jaggr2/cdk-cf-dns.git"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"watch": "tsc -w",
|
|
34
|
+
"test": "jest",
|
|
35
|
+
"test:update": "jest -u",
|
|
36
|
+
"lint": "eslint . --ext .ts",
|
|
37
|
+
"docs": "ts-node scripts/generate-api-md.ts",
|
|
38
|
+
"package": "npm run build && npm pack"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"aws-cdk-lib": "^2.x",
|
|
42
|
+
"constructs": "^10.x"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@aws-sdk/client-acm": "^3.700.0",
|
|
46
|
+
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
|
47
|
+
"@types/aws-lambda": "^8.10.162",
|
|
48
|
+
"@types/jest": "^29.5.14",
|
|
49
|
+
"@types/node": "^22.10.5",
|
|
50
|
+
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
|
51
|
+
"@typescript-eslint/parser": "^8.20.0",
|
|
52
|
+
"aws-cdk-lib": "^2.180.0",
|
|
53
|
+
"constructs": "^10.8.1",
|
|
54
|
+
"esbuild": "^0.28.2",
|
|
55
|
+
"eslint": "^8.57.1",
|
|
56
|
+
"jest": "^29.7.0",
|
|
57
|
+
"ts-jest": "^29.2.5",
|
|
58
|
+
"ts-node": "^10.9.2",
|
|
59
|
+
"typescript": "^5.9.3"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import { Construct } from 'constructs';
|
|
4
|
+
import { CloudflareCertificateProvider } from './provider';
|
|
5
|
+
import { ICloudflareZone } from './zone';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Properties for a Cloudflare-validated ACM certificate.
|
|
9
|
+
*/
|
|
10
|
+
export interface CloudflareValidatedCertificateProps {
|
|
11
|
+
/**
|
|
12
|
+
* The primary domain name the certificate covers.
|
|
13
|
+
*/
|
|
14
|
+
readonly domainName: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Additional domain names the certificate should cover.
|
|
18
|
+
*
|
|
19
|
+
* @default - no additional names
|
|
20
|
+
*/
|
|
21
|
+
readonly subjectAlternativeNames?: string[];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Cloudflare zone where the DNS validation CNAMEs are written.
|
|
25
|
+
*/
|
|
26
|
+
readonly zone: ICloudflareZone;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An ACM certificate whose DNS validation records are written into Cloudflare
|
|
31
|
+
* automatically, removing the manual copy-paste step.
|
|
32
|
+
*
|
|
33
|
+
* CloudFormation does not expose the validation `ResourceRecord` as a
|
|
34
|
+
* certificate attribute, so a custom resource calls `acm:DescribeCertificate`,
|
|
35
|
+
* polls until the records are populated (they are absent for a few seconds
|
|
36
|
+
* after creation), and writes each unique CNAME into Cloudflare.
|
|
37
|
+
*
|
|
38
|
+
* Note: if the certificate is attached to CloudFront it must live in
|
|
39
|
+
* `us-east-1`; this construct does not solve cross-region certificates.
|
|
40
|
+
*/
|
|
41
|
+
export class CloudflareValidatedCertificate extends Construct {
|
|
42
|
+
/**
|
|
43
|
+
* The underlying ACM certificate.
|
|
44
|
+
*/
|
|
45
|
+
public readonly certificate: acm.Certificate;
|
|
46
|
+
|
|
47
|
+
public constructor(scope: Construct, id: string, props: CloudflareValidatedCertificateProps) {
|
|
48
|
+
super(scope, id);
|
|
49
|
+
|
|
50
|
+
// Create the certificate with DNS validation but no hosted zone: the
|
|
51
|
+
// validation records are written to Cloudflare by the custom resource.
|
|
52
|
+
this.certificate = new acm.Certificate(this, 'Certificate', {
|
|
53
|
+
domainName: props.domainName,
|
|
54
|
+
subjectAlternativeNames: props.subjectAlternativeNames,
|
|
55
|
+
validation: acm.CertificateValidation.fromDns(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const provider = CloudflareCertificateProvider.getOrCreate(this);
|
|
59
|
+
provider.grantSecretRead(props.zone.apiToken);
|
|
60
|
+
|
|
61
|
+
const validation = new cdk.CustomResource(this, 'DnsValidation', {
|
|
62
|
+
serviceToken: provider.serviceToken,
|
|
63
|
+
resourceType: 'Custom::CloudflareCertificateDnsValidation',
|
|
64
|
+
properties: {
|
|
65
|
+
certificateArn: this.certificate.certificateArn,
|
|
66
|
+
zoneId: props.zone.zoneId,
|
|
67
|
+
apiTokenSecretArn: props.zone.apiToken.secretArn,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The validation resource can only read records once the certificate exists.
|
|
72
|
+
validation.node.addDependency(this.certificate);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ACMClient, DescribeCertificateCommand } from '@aws-sdk/client-acm';
|
|
2
|
+
import type { CloudFormationCustomResourceCreateEvent, CloudFormationCustomResourceDeleteEvent, CloudFormationCustomResourceEvent, CloudFormationCustomResourceUpdateEvent } from 'aws-lambda';
|
|
3
|
+
import { assertSuccess, findRecord, getApiToken, isAlreadyExists, log, request } from './cloudflare';
|
|
4
|
+
|
|
5
|
+
const acmClient = new ACMClient({});
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single ACM DNS validation CNAME (name/value pair).
|
|
9
|
+
*/
|
|
10
|
+
interface ValidationRecord {
|
|
11
|
+
name: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sleeps for the given number of milliseconds.
|
|
17
|
+
*/
|
|
18
|
+
async function sleep(ms: number): Promise<void> {
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Describes the certificate and returns the deduplicated DNS validation records
|
|
24
|
+
* that ACM currently exposes. A wildcard SAN and its apex share one record, so
|
|
25
|
+
* records are deduplicated on the name/value pair.
|
|
26
|
+
*/
|
|
27
|
+
async function describeValidationRecords(certificateArn: string): Promise<ValidationRecord[]> {
|
|
28
|
+
const response = await acmClient.send(new DescribeCertificateCommand({ CertificateArn: certificateArn }));
|
|
29
|
+
const options = response.Certificate?.DomainValidationOptions ?? [];
|
|
30
|
+
const records: ValidationRecord[] = [];
|
|
31
|
+
|
|
32
|
+
for (const option of options) {
|
|
33
|
+
const resourceRecord = option.ResourceRecord;
|
|
34
|
+
if (resourceRecord?.Name && resourceRecord.Value) {
|
|
35
|
+
const name = resourceRecord.Name.replace(/\.$/, '');
|
|
36
|
+
const value = resourceRecord.Value.replace(/\.$/, '');
|
|
37
|
+
const duplicate = records.some((r) => r.name === name && r.value === value);
|
|
38
|
+
if (!duplicate) {
|
|
39
|
+
records.push({ name, value });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return records;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Polls ACM until the validation `ResourceRecord`s are populated. They are
|
|
49
|
+
* absent for a few seconds after the certificate is created, so the handler
|
|
50
|
+
* retries until they appear or the deadline is reached.
|
|
51
|
+
*/
|
|
52
|
+
async function pollValidationRecords(
|
|
53
|
+
certificateArn: string,
|
|
54
|
+
delaySeconds: number,
|
|
55
|
+
timeoutSeconds: number,
|
|
56
|
+
): Promise<ValidationRecord[]> {
|
|
57
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
58
|
+
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
const records = await describeValidationRecords(certificateArn);
|
|
61
|
+
if (records.length > 0) {
|
|
62
|
+
return records;
|
|
63
|
+
}
|
|
64
|
+
await sleep(delaySeconds * 1000);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw new Error(`Timed out waiting for ACM DNS validation records for ${certificateArn}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates (or adopts) a CNAME validation record in Cloudflare. Validation
|
|
72
|
+
* records must never be proxied and must use a plain TTL.
|
|
73
|
+
*/
|
|
74
|
+
async function upsertCname(zoneId: string, name: string, value: string, token: string): Promise<void> {
|
|
75
|
+
const payload: Record<string, unknown> = {
|
|
76
|
+
name,
|
|
77
|
+
type: 'CNAME',
|
|
78
|
+
content: value,
|
|
79
|
+
ttl: 60,
|
|
80
|
+
proxied: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const response = await request(`/zones/${zoneId}/dns_records`, { method: 'POST', token, body: payload });
|
|
84
|
+
|
|
85
|
+
if (isAlreadyExists(response)) {
|
|
86
|
+
log('validation', { message: 'validation record already exists; adopting', zoneId, name });
|
|
87
|
+
const existing = await findRecord(zoneId, name, 'CNAME', token);
|
|
88
|
+
if (!existing) {
|
|
89
|
+
throw new Error(`Cloudflare reported ${name} already exists but the lookup found no matching record`);
|
|
90
|
+
}
|
|
91
|
+
await request(`/zones/${zoneId}/dns_records/${existing.id}`, { method: 'PATCH', token, body: payload });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assertSuccess(response);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handles both `Create` and `Update`: reads the certificate's validation
|
|
100
|
+
* records from ACM and writes each unique CNAME into Cloudflare.
|
|
101
|
+
*/
|
|
102
|
+
async function onUpsert(
|
|
103
|
+
event: CloudFormationCustomResourceCreateEvent | CloudFormationCustomResourceUpdateEvent,
|
|
104
|
+
): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
|
|
105
|
+
const properties = event.ResourceProperties as Record<string, unknown>;
|
|
106
|
+
const certificateArn = String(properties.certificateArn);
|
|
107
|
+
const zoneId = String(properties.zoneId);
|
|
108
|
+
const token = await getApiToken(String(properties.apiTokenSecretArn));
|
|
109
|
+
const delaySeconds = Number(properties.pollDelaySeconds ?? 5);
|
|
110
|
+
const timeoutSeconds = Number(properties.pollTimeoutSeconds ?? 120);
|
|
111
|
+
|
|
112
|
+
log('validation', { certificateArn, zoneId });
|
|
113
|
+
|
|
114
|
+
const records = await pollValidationRecords(certificateArn, delaySeconds, timeoutSeconds);
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
await upsertCname(zoneId, record.name, record.value, token);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log('validation', { message: `wrote ${records.length} validation record(s)`, names: records.map((r) => r.name) });
|
|
120
|
+
return {
|
|
121
|
+
PhysicalResourceId: certificateArn,
|
|
122
|
+
Data: { ValidationRecords: records.map((r) => r.name) },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Handles `Delete`. The validation CNAMEs are left in Cloudflare — they are
|
|
128
|
+
* harmless after issuance and cleaning them up would add fragile delete logic.
|
|
129
|
+
*/
|
|
130
|
+
async function onDelete(event: CloudFormationCustomResourceDeleteEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
|
|
131
|
+
log('validation', { message: 'delete; leaving validation records in Cloudflare' });
|
|
132
|
+
return { PhysicalResourceId: event.PhysicalResourceId, Data: {} };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The Lambda handler for the `Custom::CloudflareCertificateDnsValidation`
|
|
137
|
+
* custom resource.
|
|
138
|
+
*
|
|
139
|
+
* @param event The CloudFormation custom resource event.
|
|
140
|
+
* @returns The custom resource result with `PhysicalResourceId` and `Data`.
|
|
141
|
+
*/
|
|
142
|
+
export async function handler(event: CloudFormationCustomResourceEvent): Promise<{ PhysicalResourceId: string; Data: Record<string, unknown> }> {
|
|
143
|
+
try {
|
|
144
|
+
log('event', { requestType: event.RequestType });
|
|
145
|
+
|
|
146
|
+
switch (event.RequestType) {
|
|
147
|
+
case 'Create':
|
|
148
|
+
return await onUpsert(event);
|
|
149
|
+
case 'Update':
|
|
150
|
+
return await onUpsert(event);
|
|
151
|
+
case 'Delete':
|
|
152
|
+
return await onDelete(event);
|
|
153
|
+
default:
|
|
154
|
+
throw new Error(`Unsupported RequestType: ${(event as { RequestType?: string }).RequestType}`);
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
throw new Error(`Cloudflare ACM DNS validation custom resource failed during ${event.RequestType}: ${message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for the Lambda handlers. This module is bundled into each
|
|
5
|
+
* handler entry point by esbuild; it is never imported by CDK library code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The base URL for the Cloudflare API v4.
|
|
10
|
+
*/
|
|
11
|
+
export const CF_BASE_URL = 'https://api.cloudflare.com/client/v4';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How many attempts the retrying HTTP helper makes before giving up.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_ATTEMPTS = 5;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base backoff delay in milliseconds, doubled each attempt with full jitter.
|
|
20
|
+
*/
|
|
21
|
+
export const BACKOFF_BASE_MS = 500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Upper bound for a single backoff sleep in milliseconds.
|
|
25
|
+
*/
|
|
26
|
+
export const BACKOFF_CAP_MS = 4000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A regular expression matching strings that look like Cloudflare credentials
|
|
30
|
+
* (API tokens are 40 characters, global API keys 37). Anything matching is
|
|
31
|
+
* redacted from logs.
|
|
32
|
+
*/
|
|
33
|
+
const TOKEN_PATTERN = /[A-Za-z0-9_-]{37,}/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cloudflare record IDs are 32 lowercase hex characters.
|
|
37
|
+
*/
|
|
38
|
+
export const RECORD_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The shape of a Cloudflare API response envelope.
|
|
42
|
+
*/
|
|
43
|
+
export interface CloudflareResponse {
|
|
44
|
+
success: boolean;
|
|
45
|
+
errors: Array<{ code: number; message: string }>;
|
|
46
|
+
messages: Array<{ code: number; message: string }>;
|
|
47
|
+
result: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A DNS record as returned by the Cloudflare API.
|
|
52
|
+
*/
|
|
53
|
+
export interface CloudflareDnsRecord {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
type: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const secretCache = new Map<string, string>();
|
|
60
|
+
let secretsClient: SecretsManagerClient | undefined;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Redacts strings that look like Cloudflare credentials (and known token
|
|
64
|
+
* values) from arbitrary log payloads.
|
|
65
|
+
*
|
|
66
|
+
* @param value The value to redact.
|
|
67
|
+
* @returns A copy of `value` with token-shaped strings replaced.
|
|
68
|
+
*/
|
|
69
|
+
export function redact(value: unknown): unknown {
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
return value.replace(TOKEN_PATTERN, '[REDACTED]');
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((item) => redact(item));
|
|
75
|
+
}
|
|
76
|
+
if (value !== null && typeof value === 'object') {
|
|
77
|
+
const out: Record<string, unknown> = {};
|
|
78
|
+
for (const [key, item] of Object.entries(value)) {
|
|
79
|
+
out[key] = redact(item);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Logs a message to CloudWatch with all token-shaped strings redacted.
|
|
88
|
+
*/
|
|
89
|
+
export function log(...args: unknown[]): void {
|
|
90
|
+
console.log(...args.map((arg) => redact(arg)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the Cloudflare API token from Secrets Manager, caching it per ARN so
|
|
95
|
+
* warm invocations skip the API call. Supports the secret being either a raw
|
|
96
|
+
* string or a JSON blob with an `apiToken` key.
|
|
97
|
+
*
|
|
98
|
+
* @param secretArn The ARN of the secret holding the token.
|
|
99
|
+
*/
|
|
100
|
+
export async function getApiToken(secretArn: string): Promise<string> {
|
|
101
|
+
const cached = secretCache.get(secretArn);
|
|
102
|
+
if (cached !== undefined) {
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!secretsClient) {
|
|
107
|
+
secretsClient = new SecretsManagerClient({});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const response = await secretsClient.send(new GetSecretValueCommand({ SecretId: secretArn }));
|
|
111
|
+
const secretString = response.SecretString;
|
|
112
|
+
if (secretString === undefined) {
|
|
113
|
+
throw new Error(`Secret ${secretArn} has no SecretString; store the API token as a raw string or a JSON blob with an "apiToken" key`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let token = secretString;
|
|
117
|
+
try {
|
|
118
|
+
const parsed: unknown = JSON.parse(secretString);
|
|
119
|
+
if (parsed !== null && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>).apiToken === 'string') {
|
|
120
|
+
token = (parsed as Record<string, unknown>).apiToken as string;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Not JSON: treat the raw string as the token.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (token.length === 0) {
|
|
127
|
+
throw new Error(`Secret ${secretArn} resolved to an empty API token`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
secretCache.set(secretArn, token);
|
|
131
|
+
return token;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A Cloudflare API error, carrying the HTTP status and the error payload so the
|
|
136
|
+
* lifecycle logic can inspect specific error codes.
|
|
137
|
+
*/
|
|
138
|
+
export class CloudflareApiError extends Error {
|
|
139
|
+
public readonly status: number;
|
|
140
|
+
public readonly errors: Array<{ code: number; message: string }>;
|
|
141
|
+
|
|
142
|
+
public constructor(status: number, errors: Array<{ code: number; message: string }>) {
|
|
143
|
+
const details = errors.map((error) => `${error.code}: ${error.message}`).join('; ');
|
|
144
|
+
super(`Cloudflare API error (HTTP ${status}): ${String(redact(details))}`);
|
|
145
|
+
this.status = status;
|
|
146
|
+
this.errors = errors;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Performs an HTTP request against the Cloudflare API with retries.
|
|
152
|
+
*
|
|
153
|
+
* Retries with exponential backoff + full jitter on HTTP 429, 5xx and network
|
|
154
|
+
* errors, honouring the `Retry-After` header when present.
|
|
155
|
+
*
|
|
156
|
+
* @param path The API path, e.g. `/zones/{id}/dns_records`.
|
|
157
|
+
* @param options Request options. A resolved token is sent as `token`.
|
|
158
|
+
*/
|
|
159
|
+
export async function request(
|
|
160
|
+
path: string,
|
|
161
|
+
options: { method: string; token?: string; body?: unknown },
|
|
162
|
+
): Promise<{ status: number; body: CloudflareResponse }> {
|
|
163
|
+
const headers: Record<string, string> = {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
};
|
|
166
|
+
if (options.token !== undefined) {
|
|
167
|
+
headers['Authorization'] = `Bearer ${options.token}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const url = `${CF_BASE_URL}${path}`;
|
|
171
|
+
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
172
|
+
|
|
173
|
+
let lastError: Error | undefined;
|
|
174
|
+
|
|
175
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
method: options.method,
|
|
179
|
+
headers,
|
|
180
|
+
body,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const text = await response.text();
|
|
184
|
+
let parsed: CloudflareResponse;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(text) as CloudflareResponse;
|
|
187
|
+
} catch {
|
|
188
|
+
parsed = {
|
|
189
|
+
success: false,
|
|
190
|
+
errors: [{ code: 0, message: `Non-JSON response (HTTP ${response.status}): ${truncate(text, 500)}` }],
|
|
191
|
+
messages: [],
|
|
192
|
+
result: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
197
|
+
if (retryable && attempt < MAX_ATTEMPTS - 1) {
|
|
198
|
+
await sleepForRetry(response, attempt);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { status: response.status, body: parsed };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
205
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
206
|
+
log('retry', { path, attempt, reason: `network error: ${lastError.message}` });
|
|
207
|
+
await sleepForRetry(undefined, attempt);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw lastError ?? new Error(`Request to ${path} failed after ${MAX_ATTEMPTS} attempts`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Sleeps for a backoff interval, honouring `Retry-After` when the server
|
|
217
|
+
* provided one and otherwise using exponential backoff with full jitter.
|
|
218
|
+
*/
|
|
219
|
+
async function sleepForRetry(response: Response | undefined, attempt: number): Promise<void> {
|
|
220
|
+
let delayMs: number;
|
|
221
|
+
|
|
222
|
+
const retryAfter = response?.headers.get('Retry-After');
|
|
223
|
+
if (retryAfter !== null && retryAfter !== undefined) {
|
|
224
|
+
const seconds = Number.parseInt(retryAfter, 10);
|
|
225
|
+
delayMs = Number.isFinite(seconds) ? Math.min(seconds * 1000, BACKOFF_CAP_MS) : BACKOFF_CAP_MS;
|
|
226
|
+
} else {
|
|
227
|
+
const cap = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
228
|
+
delayMs = Math.random() * cap;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Truncates a long string for inclusion in an error message.
|
|
236
|
+
*/
|
|
237
|
+
function truncate(value: string, max: number): string {
|
|
238
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Throws a descriptive error when the Cloudflare response has `success: false`.
|
|
243
|
+
*/
|
|
244
|
+
export function assertSuccess(response: { status: number; body: CloudflareResponse }): void {
|
|
245
|
+
if (!response.body.success) {
|
|
246
|
+
throw new CloudflareApiError(response.status, response.body.errors);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns true when the response indicates the record already exists.
|
|
252
|
+
*/
|
|
253
|
+
export function isAlreadyExists(response: { status: number; body: CloudflareResponse }): boolean {
|
|
254
|
+
if (response.body.success) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return response.body.errors.some((error) => {
|
|
258
|
+
const codeMatches = error.code === 81053 || error.code === 81057;
|
|
259
|
+
const messageMatches = /already exists/i.test(error.message);
|
|
260
|
+
return codeMatches || messageMatches;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Returns true when the response indicates the record no longer exists.
|
|
266
|
+
*/
|
|
267
|
+
export function isRecordDoesNotExist(response: { status: number; body: CloudflareResponse }): boolean {
|
|
268
|
+
return response.body.errors.some((error) => error.code === 81044);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Builds the DNS record payload sent to the Cloudflare API from the custom
|
|
273
|
+
* resource properties.
|
|
274
|
+
*/
|
|
275
|
+
export function buildRecordPayload(properties: Record<string, unknown>): Record<string, unknown> {
|
|
276
|
+
const record = (properties.record ?? {}) as Record<string, unknown>;
|
|
277
|
+
const payload: Record<string, unknown> = {};
|
|
278
|
+
|
|
279
|
+
for (const key of ['name', 'type', 'content', 'data', 'ttl', 'proxied', 'priority', 'comment', 'tags']) {
|
|
280
|
+
if (record[key] !== undefined) {
|
|
281
|
+
payload[key] = record[key];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return payload;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Creates a DNS record and returns the Cloudflare record.
|
|
290
|
+
*/
|
|
291
|
+
export async function createRecord(zoneId: string, payload: Record<string, unknown>, token: string): Promise<CloudflareDnsRecord> {
|
|
292
|
+
const response = await request(`/zones/${zoneId}/dns_records`, { method: 'POST', token, body: payload });
|
|
293
|
+
assertSuccess(response);
|
|
294
|
+
return response.body.result as CloudflareDnsRecord;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Looks up a record by name and type in the zone.
|
|
299
|
+
*/
|
|
300
|
+
export async function findRecord(zoneId: string, name: string, type: string, token: string): Promise<CloudflareDnsRecord | undefined> {
|
|
301
|
+
const response = await request(`/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&type=${type}`, { method: 'GET', token });
|
|
302
|
+
assertSuccess(response);
|
|
303
|
+
const results = response.body.result as CloudflareDnsRecord[];
|
|
304
|
+
return results[0];
|
|
305
|
+
}
|