From 91db4e60c497ccec6addf18a4782d16b159ff709 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Tue, 8 Sep 2026 16:59:16 +0530 Subject: [PATCH 1/6] fix(dns): match record content in upsertRecord to prevent overwriting existing records Previously, upsertRecord matched only by type and name, silently overwriting any existing record of the same type/name with different content. This caused data loss when users had multiple records of the same type (e.g. multiple TXT records for SPF + site verification). Now upsertRecord also checks the record content/target before deciding to update. If the content differs, a new record is created alongside the existing one instead of replacing it. Affected providers: Cloudflare, Porkbun, Infomaniak, OVH. Tests updated accordingly. --- apps/dokploy/__test__/dns/cloudflare.test.ts | 25 +++++++++- apps/dokploy/__test__/dns/infomaniak.test.ts | 28 +++++++++-- apps/dokploy/__test__/dns/ovh.test.ts | 51 ++++++++++++++++++-- apps/dokploy/__test__/dns/porkbun.test.ts | 30 +++++++++++- packages/server/src/utils/dns/cloudflare.ts | 8 ++- packages/server/src/utils/dns/infomaniak.ts | 3 +- packages/server/src/utils/dns/ovh.ts | 13 ++++- packages/server/src/utils/dns/porkbun.ts | 4 +- 8 files changed, 145 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f27e7bc60..8edc07885 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => { expect(createInit.method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }])) + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]), + ) .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); const result = await cloudflareClient.upsertRecord(config, { @@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => { expect(updateInit.method).toBe("PUT"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "new-2" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + it("defaults ttl to 1 (automatic) when not provided", async () => { mockFetch .mockResolvedValueOnce(cfSuccess([])) diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index 1271d400d..d8bfe1f18 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -203,11 +203,11 @@ describe("infomaniakClient.upsertRecord", () => { }); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch .mockResolvedValueOnce( ikSuccess([ - { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + { id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, ]), ) .mockResolvedValueOnce(ikSuccess({ id: 7 })); @@ -228,6 +228,28 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().ttl).toBe(300); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "50" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + }); + it("writes a root dot as the source for an apex record and strips the trailing dot", async () => { mockFetch .mockResolvedValueOnce(ikSuccess([])) @@ -249,7 +271,7 @@ describe("infomaniakClient.upsertRecord", () => { mockFetch .mockResolvedValueOnce( ikSuccess([ - { id: 8, type: "A", source, target: "1.1.1.1", ttl: 3600 }, + { id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 }, ]), ) .mockResolvedValueOnce(ikSuccess({ id: 8 })); diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 744716cbd..7e08d7f99 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -261,9 +261,21 @@ describe("ovhClient.upsertRecord", () => { expect(calls[2]?.[1].method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { const cfg = freshConfig(); - mockApi(ovhSuccess([4]), ovhSuccess(null), ovhSuccess(null)); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); const result = await ovhClient.upsertRecord(cfg, { zoneId: "example.com", @@ -274,9 +286,38 @@ describe("ovhClient.upsertRecord", () => { expect(result).toEqual({ id: "4" }); const calls = apiCalls(); - expect(calls[1]?.[0]).toContain("/domain/zone/example.com/record/4"); - expect(calls[1]?.[1].method).toBe("PUT"); - expect(calls[2]?.[0]).toContain("/refresh"); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[2]?.[1].method).toBe("PUT"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("creates a new record when content differs from existing", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess({ id: 10 }), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "10" }); + const calls = apiCalls(); + expect(calls[2]?.[1].method).toBe("POST"); + expect(calls[3]?.[0]).toContain("/refresh"); }); it("omits the ttl so OVH applies the zone default", async () => { diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts index 2275e9c5f..a72b882ba 100644 --- a/apps/dokploy/__test__/dns/porkbun.test.ts +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => { expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); }); - it("edits the existing record instead of creating a duplicate", async () => { + it("edits the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }), + ) .mockResolvedValueOnce(pbSuccess({})); const result = await porkbunClient.upsertRecord(config, { @@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => { expect(editUrl).toContain("/dns/edit/example.com/existing-1"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }), + ) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" }); + }); + it("defaults ttl to 600 when not provided", async () => { mockFetch .mockResolvedValueOnce(pbSuccess({ records: [] })) diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 24cb9c196..85c53715f 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -168,12 +168,16 @@ export const cloudflareClient: DnsClient = { ttl: record.ttl ?? 1, }; - const existing = await cfFetch<{ id: string }[]>( + const existing = await cfFetch< + { id: string; type: string; content: string; priority?: number }[] + >( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); - const existingRecord = existing[0]; + const existingRecord = existing.find( + (r) => inlinePriority(r) === record.content, + ); if (existingRecord) { const updated = await cfFetch<{ id: string }>( config, diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index 56c0a8231..0ddcee26e 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -175,7 +175,8 @@ export const infomaniakClient: DnsClient = { const match = existing.find( (candidate) => candidate.type === record.type && - normalizeSource(candidate.source) === source, + normalizeSource(candidate.source) === source && + unquoteTarget(candidate.target) === record.content, ); const body = JSON.stringify(recordPayload(record, record.zoneId)); diff --git a/packages/server/src/utils/dns/ovh.ts b/packages/server/src/utils/dns/ovh.ts index 1e0d0a075..67fbd6ca6 100644 --- a/packages/server/src/utils/dns/ovh.ts +++ b/packages/server/src/utils/dns/ovh.ts @@ -285,7 +285,18 @@ export const ovhClient: DnsClient = { )}&subDomain=${encodeURIComponent(subDomain)}`, ); - const existingId = existing[0]; + let existingId: number | undefined; + for (const id of existing) { + const candidate = await ovhFetch( + config, + `/domain/zone/${zone}/record/${id}`, + ); + if (candidate.target === record.content) { + existingId = id; + break; + } + } + if (existingId !== undefined) { await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, { method: "PUT", diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index 09133fe38..0d1cb3ef5 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -96,7 +96,9 @@ export const porkbunClient: DnsClient = { ttl: record.ttl ?? 600, }; - const existingRecord = existing.records[0]; + const existingRecord = existing.records.find( + (r) => r.content === record.content, + ); if (existingRecord) { await pbFetch( config, From df0986b4e1c16f28a91ec63efe68aa27cebf1481 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Tue, 8 Sep 2026 17:13:58 +0530 Subject: [PATCH 2/6] fix(dns): apply content normalization in upsert match logic to prevent duplicates --- packages/server/src/utils/dns/cloudflare.ts | 10 +++++++++- packages/server/src/utils/dns/infomaniak.ts | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 85c53715f..f08823ffa 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -175,8 +175,16 @@ export const cloudflareClient: DnsClient = { `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); + const built = buildValue(record); + const normalizedRecord = { + type: record.type, + content: built.content ?? record.content.trim(), + priority: built.priority, + }; + const expectedContent = inlinePriority(normalizedRecord); + const existingRecord = existing.find( - (r) => inlinePriority(r) === record.content, + (r) => inlinePriority(r) === expectedContent, ); if (existingRecord) { const updated = await cfFetch<{ id: string }>( diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index 0ddcee26e..e161c9f0d 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -172,11 +172,14 @@ export const infomaniakClient: DnsClient = { async upsertRecord(config, record) { const source = toSource(record.name, record.zoneId); const existing = await listZoneRecords(config, record.zoneId); + const expectedContent = unquoteTarget( + quoteTarget(record.type, record.content), + ); const match = existing.find( (candidate) => candidate.type === record.type && normalizeSource(candidate.source) === source && - unquoteTarget(candidate.target) === record.content, + unquoteTarget(candidate.target) === expectedContent, ); const body = JSON.stringify(recordPayload(record, record.zoneId)); From ea1c6fc3fe193b0dc8f7e8825d2b4c29570ad881 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Tue, 8 Sep 2026 17:20:59 +0530 Subject: [PATCH 3/6] fix(dns): fallback to data fields comparison for cloudflare structured SRV/CAA records --- packages/server/src/utils/dns/cloudflare.ts | 30 ++++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index f08823ffa..56275177e 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -169,23 +169,33 @@ export const cloudflareClient: DnsClient = { }; const existing = await cfFetch< - { id: string; type: string; content: string; priority?: number }[] + { + id: string; + type: string; + content: string; + priority?: number; + data?: Record; + }[] >( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); const built = buildValue(record); - const normalizedRecord = { - type: record.type, - content: built.content ?? record.content.trim(), - priority: built.priority, - }; - const expectedContent = inlinePriority(normalizedRecord); - const existingRecord = existing.find( - (r) => inlinePriority(r) === expectedContent, - ); + const existingRecord = existing.find((r) => { + if (built.data) { + return Object.entries(built.data).every( + ([k, v]) => r.data && r.data[k] === v, + ); + } + const normalizedRecord = { + type: record.type, + content: built.content ?? record.content.trim(), + priority: built.priority, + }; + return inlinePriority(r) === inlinePriority(normalizedRecord); + }); if (existingRecord) { const updated = await cfFetch<{ id: string }>( config, From 94cd5035efffde9305127ebef44070d2a5a626c9 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Tue, 8 Sep 2026 17:24:17 +0530 Subject: [PATCH 4/6] fix(dns): extract and normalize prio for porkbun MX/SRV records to prevent duplicates --- packages/server/src/utils/dns/porkbun.ts | 38 +++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index 0d1cb3ef5..2e3366f7d 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -56,6 +56,26 @@ interface PorkbunRecord { notes: string; } +const inlinePriority = (record: { + type: string; + content: string; + prio?: string | null; +}) => + (record.type === "MX" || record.type === "SRV") && record.prio && record.prio !== "0" + ? `${record.prio} ${record.content}` + : record.content; + +const buildValue = (record: { type: string; content: string }) => { + const value = record.content.trim(); + if (record.type === "MX" || record.type === "SRV") { + const match = /^(\d+)\s+(\S.*)$/.exec(value); + if (match) { + return { content: match[2] as string, prio: match[1] as string }; + } + } + return { content: value }; +}; + export const porkbunClient: DnsClient = { async listZones(config) { const result = await pbFetch<{ domains: { domain: string }[] }>( @@ -77,7 +97,7 @@ export const porkbunClient: DnsClient = { id: record.id, type: record.type, name: record.name, - content: record.content, + content: inlinePriority(record), ttl: Number(record.ttl), })); }, @@ -89,15 +109,23 @@ export const porkbunClient: DnsClient = { `/dns/retrieveByNameType/${record.zoneId}/${record.type}/${subdomain}`, ); + const built = buildValue(record); const payload = { name: subdomain, type: record.type, - content: record.content, + content: built.content, + ...(built.prio ? { prio: built.prio } : {}), ttl: record.ttl ?? 600, }; + const expectedContent = inlinePriority({ + type: record.type, + content: built.content, + prio: built.prio, + }); + const existingRecord = existing.records.find( - (r) => r.content === record.content, + (r) => inlinePriority(r) === expectedContent, ); if (existingRecord) { await pbFetch( @@ -117,10 +145,12 @@ export const porkbunClient: DnsClient = { }, async updateRecord(config, zoneId, recordId, record) { + const built = buildValue(record); await pbFetch(config, `/dns/edit/${zoneId}/${recordId}`, { name: toSubdomain(record.name, zoneId), type: record.type, - content: record.content, + content: built.content, + ...(built.prio ? { prio: built.prio } : {}), ttl: record.ttl ?? 600, }); return { id: recordId }; From e701644fa538955f5e666596b4edf9dc822940e9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:54:43 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- packages/server/src/utils/dns/porkbun.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index 2e3366f7d..cb029b81e 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -61,7 +61,9 @@ const inlinePriority = (record: { content: string; prio?: string | null; }) => - (record.type === "MX" || record.type === "SRV") && record.prio && record.prio !== "0" + (record.type === "MX" || record.type === "SRV") && + record.prio && + record.prio !== "0" ? `${record.prio} ${record.content}` : record.content; From 8291165259de9058da4a67877b4a476d9f3d3496 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Tue, 8 Sep 2026 17:32:11 +0530 Subject: [PATCH 6/6] Update packages/server/src/utils/dns/porkbun.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/server/src/utils/dns/porkbun.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index cb029b81e..c3b68fead 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -61,9 +61,7 @@ const inlinePriority = (record: { content: string; prio?: string | null; }) => - (record.type === "MX" || record.type === "SRV") && - record.prio && - record.prio !== "0" + (record.type === "MX" || record.type === "SRV") && record.prio != null ? `${record.prio} ${record.content}` : record.content;