From 38e855bc5e2625f6a9de1234cc00fa2fe861c7b6 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 14:58:08 +0200 Subject: [PATCH] fix(dns): preserve every value of a Route53 record set Route53 returns a record set as a list of values, but listing joined them into one string and writing sent that string back as a single ResourceRecord. Editing a multi-value NS, MX or TXT set therefore either failed validation or collapsed the set into one bogus value, and creating a record for a name that already had values replaced them silently. Values are now newline separated end to end: listing joins with a newline, writing splits back into one ResourceRecord per line, and creating merges into the existing set instead of replacing it. Unquoted TXT values get the quotes Route53 requires. The record panel shows a textarea for Route53 and validates every line. --- apps/dokploy/__test__/dns/route53.test.ts | 105 +++++++++++++++++- .../settings/dns/dns-record-panel.tsx | 44 ++++++-- packages/server/src/utils/dns/route53.ts | 29 ++++- 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index cd344e201..6b3e0ab62 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => { const records = await route53Client.listRecords(config, "Z123"); - expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com"); + expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com"); }); }); describe("route53Client.upsertRecord", () => { - it("sends a single UPSERT change", async () => { - send.mockResolvedValueOnce({}); + it("sends a single UPSERT change when nothing exists yet", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); const result = await route53Client.upsertRecord(config, { zoneId: "Z123", @@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => { }); expect(result).toEqual({ id: "A:app.example.com" }); - const command = send.mock.calls[0]?.[0] as HasInput; + const command = send.mock.calls[1]?.[0] as HasInput; expect(command.input.HostedZoneId).toBe("Z123"); expect(command.input.ChangeBatch.Changes).toEqual([ { @@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => { }, ]); }); + + it("keeps the values already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "3.3.3.3", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "1.1.1.1" }, + { Value: "2.2.2.2" }, + { Value: "3.3.3.3" }, + ]); + }); + + it("does not duplicate a value that is already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.1.1.1", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: "1.1.1.1" }]); + }); + + it("wraps unquoted TXT values in double quotes", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "TXT", + name: "example.com", + content: 'v=spf1 ~all\n"already quoted"', + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]); + }); }); describe("route53Client.updateRecord", () => { @@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => { ]); }); + it("keeps every line of a multi-value record set", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "NS:example.com", { + type: "NS", + name: "example.com", + content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ", + }); + + const command = send.mock.calls[0]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + { Value: "ns3.example.com" }, + ]); + }); + it("skips the DELETE when the old record no longer exists", async () => { send .mockResolvedValueOnce({ ResourceRecordSets: [] }) diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx index 237c2f32c..17dfc1166 100644 --- a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; @@ -82,8 +83,20 @@ const DnsRecordSchema = z proxied: z.boolean(), }) .superRefine((data, ctx) => { + const values = data.content + .split("\n") + .map((value) => value.trim()) + .filter(Boolean); + if (!values.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: "Content is required", + }); + return; + } const pattern = structuredValuePatterns[data.type]; - if (pattern && !pattern.test(data.content.trim())) { + if (pattern && !values.every((value) => pattern.test(value))) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["content"], @@ -161,6 +174,7 @@ export const DnsRecordPanel = ({ const proxied = form.watch("proxied"); const canProxy = provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type); + const supportsMultipleValues = provider?.providerType === "route53"; const usesAutomaticTtl = canProxy && proxied; const onSubmit = async (data: DnsRecordForm) => { @@ -286,13 +300,29 @@ export const DnsRecordPanel = ({ {valueFields[type].label} - + {supportsMultipleValues ? ( +