Skip to content

Commit 36d356d

Browse files
authored
feat(certificatemanager): DnsValidatedCertificate DNS record cleanup (#18311)
Adds an option to DnsValidatedCertificate to automatically cleanup the related DNS validation records when the Certificate is deleted. This is an opt-in property and discouraged for production use, as there are edge cases that can cause unintended side effects. The most obvious is that if two or more certificates exist with the same domain, the same validation record is used for both. If one certificate is deleted (and deletes the validation record), the second certificate (with the same domain name) will be unable to automatically renew. closes #3333 closes #7063 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 9ed263c commit 36d356d

File tree

4 files changed

+281
-52
lines changed

4 files changed

+281
-52
lines changed

Diff for: packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/lib/index.js

+96-52
Original file line numberDiff line numberDiff line change
@@ -110,69 +110,28 @@ const requestCertificate = async function (requestId, domainName, subjectAlterna
110110

111111
console.log('Waiting for ACM to provide DNS records for validation...');
112112

113-
let records;
114-
for (let attempt = 0; attempt < maxAttempts && !records; attempt++) {
113+
let records = [];
114+
for (let attempt = 0; attempt < maxAttempts && !records.length; attempt++) {
115115
const { Certificate } = await acm.describeCertificate({
116116
CertificateArn: reqCertResponse.CertificateArn
117117
}).promise();
118-
const options = Certificate.DomainValidationOptions || [];
119-
// Ensure all records are ready; there is (at least a theory there's) a chance of a partial response here in rare cases.
120-
if (options.length > 0 && options.every(opt => opt && !!opt.ResourceRecord)) {
121-
// some alternative names will produce the same validation record
122-
// as the main domain (eg. example.com + *.example.com)
123-
// filtering duplicates to avoid errors with adding the same record
124-
// to the route53 zone twice
125-
const unique = options
126-
.map((val) => val.ResourceRecord)
127-
.reduce((acc, cur) => {
128-
acc[cur.Name] = cur;
129-
return acc;
130-
}, {});
131-
records = Object.keys(unique).sort().map(key => unique[key]);
132-
} else {
118+
119+
records = getDomainValidationRecords(Certificate);
120+
if (!records.length) {
133121
// Exponential backoff with jitter based on 200ms base
134122
// component of backoff fixed to ensure minimum total wait time on
135123
// slow targets.
136124
const base = Math.pow(2, attempt);
137125
await sleep(random() * base * 50 + base * 150);
138126
}
139127
}
140-
if (!records) {
128+
if (!records.length) {
141129
throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`)
142130
}
143131

144-
145132
console.log(`Upserting ${records.length} DNS records into zone ${hostedZoneId}:`);
146133

147-
const changeBatch = await route53.changeResourceRecordSets({
148-
ChangeBatch: {
149-
Changes: records.map((record) => {
150-
console.log(`${record.Name} ${record.Type} ${record.Value}`)
151-
return {
152-
Action: 'UPSERT',
153-
ResourceRecordSet: {
154-
Name: record.Name,
155-
Type: record.Type,
156-
TTL: 60,
157-
ResourceRecords: [{
158-
Value: record.Value
159-
}]
160-
}
161-
};
162-
}),
163-
},
164-
HostedZoneId: hostedZoneId
165-
}).promise();
166-
167-
console.log('Waiting for DNS records to commit...');
168-
await route53.waitFor('resourceRecordSetsChanged', {
169-
// Wait up to 5 minutes
170-
$waiter: {
171-
delay: 30,
172-
maxAttempts: 10
173-
},
174-
Id: changeBatch.ChangeInfo.Id
175-
}).promise();
134+
await commitRoute53Records(route53, records, hostedZoneId);
176135

177136
console.log('Waiting for validation...');
178137
await acm.waitFor('certificateValidated', {
@@ -193,47 +152,126 @@ const requestCertificate = async function (requestId, domainName, subjectAlterna
193152
*
194153
* @param {string} arn The certificate ARN
195154
*/
196-
const deleteCertificate = async function (arn, region) {
155+
const deleteCertificate = async function (arn, region, hostedZoneId, route53Endpoint, cleanupRecords) {
197156
const acm = new aws.ACM({ region });
157+
const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53();
158+
if (waiter) {
159+
// Used by the test suite, since waiters aren't mockable yet
160+
route53.waitFor = acm.waitFor = waiter;
161+
}
198162

199163
try {
200164
console.log(`Waiting for certificate ${arn} to become unused`);
201165

202166
let inUseByResources;
167+
let records = [];
203168
for (let attempt = 0; attempt < maxAttempts; attempt++) {
204169
const { Certificate } = await acm.describeCertificate({
205170
CertificateArn: arn
206171
}).promise();
207172

173+
if (cleanupRecords) {
174+
records = getDomainValidationRecords(Certificate);
175+
}
208176
inUseByResources = Certificate.InUseBy || [];
209177

210-
if (inUseByResources.length) {
178+
if (inUseByResources.length || !records.length) {
211179
// Exponential backoff with jitter based on 200ms base
212180
// component of backoff fixed to ensure minimum total wait time on
213181
// slow targets.
214182
const base = Math.pow(2, attempt);
215183
await sleep(random() * base * 50 + base * 150);
216184
} else {
217-
break
185+
break;
218186
}
219187
}
220188

221189
if (inUseByResources.length) {
222190
throw new Error(`Response from describeCertificate did not contain an empty InUseBy list after ${maxAttempts} attempts.`)
223191
}
192+
if (cleanupRecords && !records.length) {
193+
throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`)
194+
}
224195

225196
console.log(`Deleting certificate ${arn}`);
226197

227198
await acm.deleteCertificate({
228199
CertificateArn: arn
229200
}).promise();
201+
202+
if (cleanupRecords) {
203+
console.log(`Deleting ${records.length} DNS records from zone ${hostedZoneId}:`);
204+
205+
await commitRoute53Records(route53, records, hostedZoneId, 'DELETE');
206+
}
207+
230208
} catch (err) {
231209
if (err.name !== 'ResourceNotFoundException') {
232210
throw err;
233211
}
234212
}
235213
};
236214

215+
/**
216+
* Retrieve the unique domain validation options as records to be upserted (or deleted) from Route53.
217+
*
218+
* Returns an empty array ([]) if the domain validation options is empty or the records are not yet ready.
219+
*/
220+
function getDomainValidationRecords(certificate) {
221+
const options = certificate.DomainValidationOptions || [];
222+
// Ensure all records are ready; there is (at least a theory there's) a chance of a partial response here in rare cases.
223+
if (options.length > 0 && options.every(opt => opt && !!opt.ResourceRecord)) {
224+
// some alternative names will produce the same validation record
225+
// as the main domain (eg. example.com + *.example.com)
226+
// filtering duplicates to avoid errors with adding the same record
227+
// to the route53 zone twice
228+
const unique = options
229+
.map((val) => val.ResourceRecord)
230+
.reduce((acc, cur) => {
231+
acc[cur.Name] = cur;
232+
return acc;
233+
}, {});
234+
return Object.keys(unique).sort().map(key => unique[key]);
235+
}
236+
return [];
237+
}
238+
239+
/**
240+
* Execute Route53 ChangeResourceRecordSets for a set of records within a Hosted Zone,
241+
* and wait for the records to commit. Defaults to an 'UPSERT' action.
242+
*/
243+
async function commitRoute53Records(route53, records, hostedZoneId, action = 'UPSERT') {
244+
const changeBatch = await route53.changeResourceRecordSets({
245+
ChangeBatch: {
246+
Changes: records.map((record) => {
247+
console.log(`${record.Name} ${record.Type} ${record.Value}`);
248+
return {
249+
Action: action,
250+
ResourceRecordSet: {
251+
Name: record.Name,
252+
Type: record.Type,
253+
TTL: 60,
254+
ResourceRecords: [{
255+
Value: record.Value
256+
}]
257+
}
258+
};
259+
}),
260+
},
261+
HostedZoneId: hostedZoneId
262+
}).promise();
263+
264+
console.log('Waiting for DNS records to commit...');
265+
await route53.waitFor('resourceRecordSetsChanged', {
266+
// Wait up to 5 minutes
267+
$waiter: {
268+
delay: 30,
269+
maxAttempts: 10
270+
},
271+
Id: changeBatch.ChangeInfo.Id
272+
}).promise();
273+
}
274+
237275
/**
238276
* Main handler, invoked by Lambda
239277
*/
@@ -262,7 +300,13 @@ exports.certificateRequestHandler = async function (event, context) {
262300
// If the resource didn't create correctly, the physical resource ID won't be the
263301
// certificate ARN, so don't try to delete it in that case.
264302
if (physicalResourceId.startsWith('arn:')) {
265-
await deleteCertificate(physicalResourceId, event.ResourceProperties.Region);
303+
await deleteCertificate(
304+
physicalResourceId,
305+
event.ResourceProperties.Region,
306+
event.ResourceProperties.HostedZoneId,
307+
event.ResourceProperties.Route53Endpoint,
308+
event.ResourceProperties.CleanupRecords === "true",
309+
);
266310
}
267311
break;
268312
default:

Diff for: packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/test/handler.test.js

+168
Original file line numberDiff line numberDiff line change
@@ -996,4 +996,172 @@ describe('DNS Validated Certificate Handler', () => {
996996
expect(request.isDone()).toBe(true);
997997
});
998998
});
999+
1000+
describe('Delete option record cleanup', () => {
1001+
let describeCertificateFake;
1002+
let deleteCertificateFake;
1003+
let changeResourceRecordSetsFake;
1004+
1005+
beforeEach(() => {
1006+
deleteCertificateFake = sinon.fake.resolves({});
1007+
AWS.mock('ACM', 'deleteCertificate', deleteCertificateFake);
1008+
changeResourceRecordSetsFake = sinon.fake.resolves({
1009+
ChangeInfo: {
1010+
Id: 'bogus'
1011+
}
1012+
});
1013+
AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake);
1014+
1015+
describeCertificateFake = sinon.fake.resolves({
1016+
Certificate: {
1017+
CertificateArn: testCertificateArn,
1018+
DomainValidationOptions: [{
1019+
ValidationStatus: 'SUCCESS',
1020+
ResourceRecord: {
1021+
Name: testRRName,
1022+
Type: 'CNAME',
1023+
Value: testRRValue
1024+
}
1025+
}]
1026+
}
1027+
});
1028+
AWS.mock('ACM', 'describeCertificate', describeCertificateFake);
1029+
});
1030+
1031+
test('ignores records if CleanupRecords is not set', () => {
1032+
const request = nock(ResponseURL).put('/', body => {
1033+
return body.Status === 'SUCCESS';
1034+
}).reply(200);
1035+
1036+
return LambdaTester(handler.certificateRequestHandler)
1037+
.event({
1038+
RequestType: 'Delete',
1039+
RequestId: testRequestId,
1040+
PhysicalResourceId: testCertificateArn,
1041+
ResourceProperties: {
1042+
Region: 'us-east-1',
1043+
HostedZoneId: testHostedZoneId,
1044+
}
1045+
})
1046+
.expectResolve(() => {
1047+
sinon.assert.calledWith(describeCertificateFake, sinon.match({
1048+
CertificateArn: testCertificateArn
1049+
}));
1050+
sinon.assert.calledWith(deleteCertificateFake, sinon.match({
1051+
CertificateArn: testCertificateArn
1052+
}));
1053+
sinon.assert.notCalled(changeResourceRecordSetsFake);
1054+
expect(request.isDone()).toBe(true);
1055+
});
1056+
});
1057+
1058+
test('ignores records if CleanupRecords is not set to "true"', () => {
1059+
const request = nock(ResponseURL).put('/', body => {
1060+
return body.Status === 'SUCCESS';
1061+
}).reply(200);
1062+
1063+
return LambdaTester(handler.certificateRequestHandler)
1064+
.event({
1065+
RequestType: 'Delete',
1066+
RequestId: testRequestId,
1067+
PhysicalResourceId: testCertificateArn,
1068+
ResourceProperties: {
1069+
Region: 'us-east-1',
1070+
HostedZoneId: testHostedZoneId,
1071+
CleanupRecords: 'TRUE', // Not "true"
1072+
}
1073+
})
1074+
.expectResolve(() => {
1075+
sinon.assert.calledWith(describeCertificateFake, sinon.match({
1076+
CertificateArn: testCertificateArn
1077+
}));
1078+
sinon.assert.calledWith(deleteCertificateFake, sinon.match({
1079+
CertificateArn: testCertificateArn
1080+
}));
1081+
sinon.assert.notCalled(changeResourceRecordSetsFake);
1082+
expect(request.isDone()).toBe(true);
1083+
});
1084+
});
1085+
1086+
test('deletes records if CleanupRecords is set to true and records are present', () => {
1087+
const request = nock(ResponseURL).put('/', body => {
1088+
return body.Status === 'SUCCESS';
1089+
}).reply(200);
1090+
1091+
AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake);
1092+
1093+
return LambdaTester(handler.certificateRequestHandler)
1094+
.event({
1095+
RequestType: 'Delete',
1096+
RequestId: testRequestId,
1097+
PhysicalResourceId: testCertificateArn,
1098+
ResourceProperties: {
1099+
Region: 'us-east-1',
1100+
HostedZoneId: testHostedZoneId,
1101+
CleanupRecords: 'true',
1102+
},
1103+
})
1104+
.expectResolve(() => {
1105+
sinon.assert.calledWith(describeCertificateFake, sinon.match({
1106+
CertificateArn: testCertificateArn
1107+
}));
1108+
sinon.assert.calledWith(deleteCertificateFake, sinon.match({
1109+
CertificateArn: testCertificateArn
1110+
}));
1111+
sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({
1112+
ChangeBatch: {
1113+
Changes: [{
1114+
Action: 'DELETE',
1115+
ResourceRecordSet: {
1116+
Name: testRRName,
1117+
Type: 'CNAME',
1118+
TTL: 60,
1119+
ResourceRecords: [{
1120+
Value: testRRValue
1121+
}]
1122+
}
1123+
}]
1124+
},
1125+
HostedZoneId: testHostedZoneId
1126+
}));
1127+
expect(request.isDone()).toBe(true);
1128+
});
1129+
});
1130+
1131+
test('fails if CleanupRecords is set to true and records are not present', () => {
1132+
describeCertificateFake = sinon.fake.resolves({
1133+
Certificate: {
1134+
CertificateArn: testCertificateArn,
1135+
}
1136+
});
1137+
AWS.remock('ACM', 'describeCertificate', describeCertificateFake);
1138+
1139+
const request = nock(ResponseURL).put('/', body => {
1140+
return body.Status === 'FAILED' &&
1141+
body.Reason.startsWith('Response from describeCertificate did not contain DomainValidationOptions');
1142+
}).reply(200);
1143+
1144+
AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake);
1145+
1146+
return LambdaTester(handler.certificateRequestHandler)
1147+
.event({
1148+
RequestType: 'Delete',
1149+
RequestId: testRequestId,
1150+
PhysicalResourceId: testCertificateArn,
1151+
ResourceProperties: {
1152+
Region: 'us-east-1',
1153+
HostedZoneId: testHostedZoneId,
1154+
CleanupRecords: 'true',
1155+
},
1156+
})
1157+
.expectResolve(() => {
1158+
sinon.assert.calledWith(describeCertificateFake, sinon.match({
1159+
CertificateArn: testCertificateArn
1160+
}));
1161+
sinon.assert.notCalled(deleteCertificateFake);
1162+
sinon.assert.notCalled(changeResourceRecordSetsFake);
1163+
expect(request.isDone()).toBe(true);
1164+
});
1165+
});
1166+
});
9991167
});

0 commit comments

Comments
 (0)