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 ? ( +