From f5430a8925cf6cf4e16c75f2358fbd78437e6705 Mon Sep 17 00:00:00 2001 From: Atirna Date: Wed, 2 Sep 2026 21:29:45 +0530 Subject: [PATCH] fix: preserve Route53 TXT record values (#791) --- .../plugin-aws/libs/aws-client.test.ts | 94 +++++++++++++++++++ .../src/plugins/plugin-aws/libs/aws-client.ts | 81 ++++++++++------ 2 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.test.ts diff --git a/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.test.ts b/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.test.ts new file mode 100644 index 000000000..46f4f8157 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import esmock from "esmock"; + +async function createAwsClient(records: any[]) { + const sentChanges: any[] = []; + + class ListResourceRecordSetsCommand { + constructor(readonly input: any) {} + } + + class ChangeResourceRecordSetsCommand { + constructor(readonly input: any) {} + } + + class Route53Client { + async send(command: any) { + if (command instanceof ListResourceRecordSetsCommand) { + return { ResourceRecordSets: records }; + } + sentChanges.push(command.input); + return {}; + } + } + + const { AwsClient } = await esmock("./aws-client.js", { + "@certd/basic": { utils: { sleep: async () => {} } }, + }); + const client = new AwsClient({ + access: { + accessKeyId: "key", + secretAccessKey: "secret", + importRuntime: async () => ({ + Route53Client, + ListResourceRecordSetsCommand, + ChangeResourceRecordSetsCommand, + }), + } as any, + logger: { info() {}, error() {} } as any, + region: "us-east-1", + }); + return { client, sentChanges }; +} + +describe("AwsClient.route53ChangeRecord", () => { + it("keeps an existing TXT challenge when adding a wildcard challenge", async () => { + const { client, sentChanges } = await createAwsClient([ + { + Name: "_acme-challenge.example.com.", + Type: "TXT", + TTL: 300, + ResourceRecords: [{ Value: '"apex-token"' }], + }, + ]); + + await client.route53ChangeRecord({ + hostedZoneId: "zone-id", + fullRecord: "_acme-challenge.example.com", + type: "TXT", + value: "wildcard-token", + action: "UPSERT", + }); + + const change = sentChanges[0].ChangeBatch.Changes[0]; + assert.equal(change.Action, "UPSERT"); + assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [ + { Value: '"apex-token"' }, + { Value: '"wildcard-token"' }, + ]); + assert.equal(change.ResourceRecordSet.TTL, 300); + }); + + it("keeps the wildcard TXT challenge when removing the apex challenge", async () => { + const { client, sentChanges } = await createAwsClient([ + { + Name: "_acme-challenge.example.com.", + Type: "TXT", + TTL: 300, + ResourceRecords: [{ Value: '"apex-token"' }, { Value: '"wildcard-token"' }], + }, + ]); + + await client.route53ChangeRecord({ + hostedZoneId: "zone-id", + fullRecord: "_acme-challenge.example.com", + type: "TXT", + value: "apex-token", + action: "DELETE", + }); + + const change = sentChanges[0].ChangeBatch.Changes[0]; + assert.equal(change.Action, "UPSERT"); + assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"wildcard-token"' }]); + }); +}); diff --git a/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.ts b/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.ts index d5550ee9c..a0ec768fb 100644 --- a/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.ts +++ b/packages/ui/certd-server/src/plugins/plugin-aws/libs/aws-client.ts @@ -129,32 +129,43 @@ export class AwsClient { } async route53ChangeRecord(req: { hostedZoneId: string; fullRecord: string; type: string; value: string; action: "UPSERT" | "DELETE" }) { - const { ChangeResourceRecordSetsCommand } = await this.access.importRuntime("@aws-sdk/client-route-53"); // ES Modules import - // const { Route53Client, ChangeResourceRecordSetsCommand } = require("@aws-sdk/client-route-53"); // CommonJS import - // import type { Route53ClientConfig } from "@aws-sdk/client-route-53"; + const recordType = req.type.toUpperCase(); + const recordValue = `"${req.value}"`; + const existingRecord = await this.route53GetRecord(req.hostedZoneId, req.fullRecord, recordType); + const existingValues = existingRecord?.ResourceRecords || []; + + let action: "UPSERT" | "DELETE" = "UPSERT"; + let values = existingValues; + if (req.action === "UPSERT") { + if (existingValues.some(item => item.Value === recordValue)) { + return; + } + values = [...existingValues, { Value: recordValue }]; + } else { + const remainingValues = existingValues.filter(item => item.Value !== recordValue); + if (remainingValues.length === existingValues.length) { + return; + } + if (remainingValues.length === 0) { + action = "DELETE"; + } else { + values = remainingValues; + } + } + + const { ChangeResourceRecordSetsCommand } = await this.access.importRuntime("@aws-sdk/client-route-53"); const client = await this.route53ClientGet(); const input = { - // ChangeResourceRecordSetsRequest - HostedZoneId: req.hostedZoneId, // required + HostedZoneId: req.hostedZoneId, ChangeBatch: { - // ChangeBatch Changes: [ - // Changes // required { - // Change - Action: req.action as any, // required + Action: action as any, ResourceRecordSet: { - // ResourceRecordSet - Name: req.fullRecord, // required - Type: req.type.toUpperCase() as any, - ResourceRecords: [ - // ResourceRecords - { - // ResourceRecord - Value: `"${req.value}"`, // required - }, - ], - TTL: 60, + Name: req.fullRecord, + Type: recordType as any, + ResourceRecords: values, + TTL: existingRecord?.TTL || 60, }, }, ], @@ -166,15 +177,27 @@ export class AwsClient { console.log("Add record successful:", JSON.stringify(response)); await utils.sleep(3000); return response; - /* - // { // ChangeResourceRecordSetsResponse -// ChangeInfo: { // ChangeInfo -// Id: "STRING_VALUE", // required -// Status: "PENDING" || "INSYNC", // required -// SubmittedAt: new Date("TIMESTAMP"), // required -// Comment: "STRING_VALUE", -// }, -// };*/ + } + + private async route53GetRecord(hostedZoneId: string, fullRecord: string, type: string): Promise { + const { ListResourceRecordSetsCommand } = await this.access.importRuntime("@aws-sdk/client-route-53"); + const client = await this.route53ClientGet(); + const command = new ListResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + StartRecordName: fullRecord, + StartRecordType: type, + MaxItems: "1", + }); + const response: any = await this.doRequest(() => client.send(command)); + const record = response.ResourceRecordSets?.[0]; + if (!record || record.Type !== type || !this.route53RecordNamesMatch(record.Name, fullRecord)) { + return undefined; + } + return record; + } + + private route53RecordNamesMatch(left: string, right: string): boolean { + return left.replace(/\.$/, "").toLowerCase() === right.replace(/\.$/, "").toLowerCase(); } async doRequest(call: () => Promise): Promise {