From 2bc22c520db2893f07ce18b6d09326e3d2f2761d Mon Sep 17 00:00:00 2001 From: Guillaume Juge Date: Tue, 1 Sep 2026 15:58:31 +0200 Subject: [PATCH] fix(dns): represent the Infomaniak apex as a root dot Verified against a live Infomaniak account: the API returns `source: "."` for apex records, not "" as assumed. Reading them back produced a doubled dot ("..example.com"), and writing "" meant an apex upsert never matched the existing record, so it would have created a duplicate apex record instead of updating it. toSource now emits "." for the apex and toFqdn accepts ".", "" and "@" so a hand-written record still round-trips. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/dns/infomaniak.test.ts | 41 +++++++++++++++++--- packages/server/src/utils/dns/infomaniak.ts | 11 ++++-- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index c99039c8f..1a92f0efe 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -100,11 +100,9 @@ describe("infomaniakClient.listRecords", () => { ); }); - it("treats an @ source as the apex", async () => { + it.each([".", "", "@"])("treats a %s source as the apex", async (source) => { mockFetch.mockResolvedValue( - ikSuccess([ - { id: 12, type: "A", source: "@", target: "1.2.3.4", ttl: 300 }, - ]), + ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]), ); const records = await infomaniakClient.listRecords(config, "example.com"); @@ -200,7 +198,7 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().ttl).toBe(300); }); - it("writes an empty source for an apex record and strips the trailing dot", async () => { + it("writes a root dot as the source for an apex record and strips the trailing dot", async () => { mockFetch .mockResolvedValueOnce(ikSuccess([])) .mockResolvedValueOnce(ikSuccess({ id: 43 })); @@ -212,7 +210,38 @@ describe("infomaniakClient.upsertRecord", () => { content: "1.2.3.4", }); - expect(lastBody().source).toBe(""); + expect(lastBody().source).toBe("."); + }); + + it("matches the existing apex record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 8, + type: "TXT", + source: ".", + target: '"v=spf1 -all"', + ttl: 3600, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "example.com", + content: "v=spf1 -all", + }); + + expect(result).toEqual({ id: "8" }); + const [url, init] = lastCall(); + expect(init.method).toBe("PUT"); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/8", + ); + expect(lastBody().target).toBe('"v=spf1 -all"'); }); it("quotes a TXT target on write", async () => { diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index 002d8785f..b187c49fe 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -54,19 +54,22 @@ const ikFetch = async ( return body.data as T; }; -// Infomaniak's "source" holds the subdomain only, relative to the zone, and is -// empty for the apex. +// Infomaniak's "source" holds the subdomain only, relative to the zone. The apex +// is a bare root dot; "" and "@" are accepted too so a hand-written record still +// round-trips. +const APEX_SOURCES = new Set(["", ".", "@"]); + const toSource = (name: string, zone: string) => { const fqdn = name.replace(/\.$/, ""); if (fqdn === zone) { - return ""; + return "."; } const suffix = `.${zone}`; return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn; }; const toFqdn = (source: string, zone: string) => - source === "" || source === "@" ? zone : `${source}.${zone}`; + APEX_SOURCES.has(source) ? zone : `${source}.${zone}`; // TXT targets are stored quoted; keep Dokploy's view of them unquoted so that // editing a record does not stack a new pair of quotes on every save.