diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f2abbc4bb..f27e7bc60 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -164,6 +164,76 @@ describe("cloudflareClient MX priority", () => { }); }); +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 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 3f06581a5..237c2f32c 100644 --- a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -68,13 +68,29 @@ const valueFields: Record< PTR: { label: "Target", placeholder: "host.example.com" }, }; -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(), -}); +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 pattern = structuredValuePatterns[data.type]; + if (pattern && !pattern.test(data.content.trim())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: `Expected ${valueFields[data.type].placeholder}`, + }); + } + }); type DnsRecordForm = z.infer; diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 51eeaee31..24cb9c196 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -31,14 +31,51 @@ const proxySettings = (record: { type: string; proxied?: boolean }) => ? { proxied: record.proxied } : {}; -const splitPriority = (record: { type: string; content: string }) => { - if (record.type !== "MX") { - return { content: record.content }; +const buildValue = (record: { type: string; content: string }) => { + const value = record.content.trim(); + + if (record.type === "MX") { + const match = /^(\d+)\s+(\S.*)$/.exec(value); + return match + ? { content: match[2] as string, priority: Number(match[1]) } + : { content: value, priority: 10 }; } - const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); - return match - ? { content: match[2] as string, priority: Number(match[1]) } - : { content: record.content.trim(), priority: 10 }; + + if (record.type === "SRV") { + const parts = value.split(/\s+/); + const [priority, weight, port, target] = parts; + if (parts.length !== 4 || !target) { + throw new Error( + `Cloudflare: an SRV value must be "priority weight port target", got "${value}"`, + ); + } + return { + data: { + priority: Number(priority), + weight: Number(weight), + port: Number(port), + target, + }, + }; + } + + if (record.type === "CAA") { + const match = /^(\d+)\s+(\S+)\s+"?([^"]+)"?$/.exec(value); + if (!match) { + throw new Error( + `Cloudflare: a CAA value must be \`flags tag "value"\`, got "${value}"`, + ); + } + return { + data: { + flags: Number(match[1]), + tag: match[2] as string, + value: match[3] as string, + }, + }; + } + + return { content: value }; }; const cfFetch = async ( @@ -123,19 +160,19 @@ export const cloudflareClient: DnsClient = { }, async upsertRecord(config, record) { + const payload = { + type: record.type, + name: record.name, + ...buildValue(record), + ...proxySettings(record), + ttl: record.ttl ?? 1, + }; + const existing = await cfFetch<{ id: string }[]>( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); - const payload = { - type: record.type, - name: record.name, - ...splitPriority(record), - ...proxySettings(record), - ttl: record.ttl ?? 1, - }; - const existingRecord = existing[0]; if (existingRecord) { const updated = await cfFetch<{ id: string }>( @@ -163,7 +200,7 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - ...splitPriority(record), + ...buildValue(record), ...proxySettings(record), ttl: record.ttl ?? 1, }),