diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index 356d429a4..f27e7bc60 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => { }); }); +describe("cloudflareClient MX priority", () => { + it("inlines the priority into the content when listing", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "mx-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + ttl: 300, + priority: 20, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.content).toBe("20 mail.example.com"); + }); + + it("splits the priority back out when writing", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "20 mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 20, + }); + }); + + it("falls back to priority 10 when the content has no leading number", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 10, + }); + }); + + it("leaves non-MX content untouched", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "10 not-a-priority", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.content).toBe("10 not-a-priority"); + expect(body.priority).toBeUndefined(); + }); +}); + +describe("cloudflareClient structured records", () => { + const stubCreate = () => + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "rec-1" })); + + it("sends SRV values as structured data", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269 talk.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + priority: 1, + weight: 10, + port: 5269, + target: "talk.example.com", + }); + expect(body.content).toBeUndefined(); + }); + + it("sends CAA values as structured data and drops the quotes", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: '0 issue "letsencrypt.org"', + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + flags: 0, + tag: "issue", + value: "letsencrypt.org", + }); + expect(body.content).toBeUndefined(); + }); + + it("rejects an SRV value that is missing a part", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269", + }), + ).rejects.toThrow(/SRV value/); + }); + + it("rejects a CAA value that has no tag", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: "0", + }), + ).rejects.toThrow(/CAA value/); + }); +}); + +describe("cloudflareClient proxy status", () => { + it("sends the proxy status for proxiable types", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBe(true); + }); + + it("omits the proxy status for types Cloudflare cannot proxy", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "hello", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBeUndefined(); + }); + + it("leaves the proxy status untouched when the caller does not set it", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect("proxied" in JSON.parse(init.body as string)).toBe(false); + }); + + it("returns the proxy status when listing records", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "a-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + proxied: true, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.proxied).toBe(true); + }); +}); + describe("cloudflareClient.upsertRecord", () => { it("creates a record when none exists for the name/type", async () => { mockFetch 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 new file mode 100644 index 000000000..17dfc1166 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -0,0 +1,421 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Cloud, CloudOff, XIcon } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +export const DNS_RECORD_TYPES = [ + "A", + "AAAA", + "CNAME", + "MX", + "TXT", + "NS", + "SRV", + "CAA", + "PTR", +] as const; + +type RecordType = (typeof DNS_RECORD_TYPES)[number]; + +export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"]; + +const valueFields: Record< + RecordType, + { label: string; placeholder: string; hint?: string } +> = { + A: { label: "IPv4 address", placeholder: "203.0.113.10" }, + AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" }, + CNAME: { label: "Target", placeholder: "app.example.com" }, + MX: { + label: "Mail server", + placeholder: "10 mail.example.com", + hint: "Start with the priority, then the mail server.", + }, + TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" }, + NS: { label: "Nameserver", placeholder: "ns1.example.com" }, + SRV: { + label: "Target", + placeholder: "1 10 5269 talk.example.com", + hint: "Priority, weight, port, then target.", + }, + CAA: { + label: "Value", + placeholder: '0 issue "letsencrypt.org"', + hint: "Flags, tag, then the quoted value.", + }, + PTR: { label: "Target", placeholder: "host.example.com" }, +}; + +const structuredValuePatterns: Partial> = { + SRV: /^\d+\s+\d+\s+\d+\s+\S+$/, + CAA: /^\d+\s+\S+\s+.+$/, +}; + +const DnsRecordSchema = z + .object({ + type: z.enum(DNS_RECORD_TYPES), + name: z.string().min(1, { message: "Name is required" }), + content: z.string().min(1, { message: "Content is required" }), + ttl: z.string(), + 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 && !values.every((value) => pattern.test(value))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: `Expected ${valueFields[data.type].placeholder}`, + }); + } + }); + +type DnsRecordForm = z.infer; + +export interface DnsRecordValue { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied?: boolean; +} + +interface Props { + dnsProviderId: string; + zoneId: string; + zoneName: string; + record: DnsRecordValue | null; + onClose: () => void; +} + +export const DnsRecordPanel = ({ + dnsProviderId, + zoneId, + zoneName, + record, + onClose, +}: Props) => { + const utils = api.useUtils(); + const createRecord = api.dnsProvider.createRecord.useMutation(); + const updateRecord = api.dnsProvider.updateRecord.useMutation(); + const { mutateAsync, isPending, error, isError } = record + ? updateRecord + : createRecord; + + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data: servers } = api.server.all.useQuery(); + const { data: panelPublicIp } = api.server.publicIp.useQuery(); + const { data: panelStoredIp } = api.settings.getIp.useQuery(); + + const panelIp = panelPublicIp || panelStoredIp; + const ipSuggestions = [ + ...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []), + ...(servers ?? []).map((server) => ({ + ip: server.ipAddress, + label: server.name, + })), + ].filter( + (suggestion, index, all) => + !!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index, + ); + + const form = useForm({ + defaultValues: record + ? { + type: (DNS_RECORD_TYPES.includes(record.type as RecordType) + ? record.type + : "A") as RecordType, + name: record.name, + content: record.content, + ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "", + proxied: record.proxied ?? false, + } + : { type: "A", name: "", content: "", ttl: "", proxied: false }, + resolver: zodResolver(DnsRecordSchema), + }); + + const type = form.watch("type"); + 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) => { + const name = data.name.trim() === "@" ? zoneName : data.name; + const isProxied = canProxy && data.proxied; + const payload = { + dnsProviderId, + zoneId, + type: data.type, + name, + content: data.content, + ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined, + ...(canProxy && { proxied: data.proxied }), + ...(record && { recordId: record.id }), + }; + await mutateAsync(payload as any) + .then(() => { + toast.success(record ? "Record updated" : "Record created"); + utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); + onClose(); + }) + .catch(() => {}); + }; + + return ( +
+
+
+ + {record ? "Edit record" : "New record"} + + {zoneName} +
+ +
+ + {isError && {error?.message}} + +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + {valueFields[type].label} + + {supportsMultipleValues ? ( +