fix: preserve Route53 TXT record values (#791)

This commit is contained in:
Atirna
2026-09-02 23:59:45 +08:00
committed by GitHub
parent f44084dfd2
commit f5430a8925
2 changed files with 146 additions and 29 deletions
@@ -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"' }]);
});
});
@@ -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<any> {
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<T>(call: () => Promise<T>): Promise<T> {