Merge pull request #5257 from mitc-gjuge/feat/infomaniak-dns-provider
Some checks failed
Auto PR to main when version changes / create-pr (push) Has been cancelled
Build Docker images / build-and-push-cloud-image (push) Has been cancelled
Build Docker images / build-and-push-schedule-image (push) Has been cancelled
Build Docker images / build-and-push-server-image (push) Has been cancelled
Dokploy Docker Build / docker-amd (push) Has been cancelled
Dokploy Docker Build / docker-arm (push) Has been cancelled
autofix.ci / format (push) Has been cancelled
Dokploy Monitoring Build / docker-amd (push) Has been cancelled
Dokploy Monitoring Build / docker-arm (push) Has been cancelled
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Has been cancelled
Dokploy Docker Build / combine-manifests (push) Has been cancelled
Dokploy Docker Build / generate-release (push) Has been cancelled
Dokploy Docker Build / sync-version (push) Has been cancelled
Dokploy Monitoring Build / combine-manifests (push) Has been cancelled

feat: add Infomaniak DNS provider support
This commit is contained in:
Narciso E. Núñez Arias 2026-09-04 17:44:11 -04:00 committed by GitHub
commit d7884c7ce9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 9855 additions and 1 deletions

View File

@ -0,0 +1,412 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
const ikPage = (data: unknown, page: number, pages: number) =>
jsonResponse({ result: "success", data, page, pages });
const ikError = (description: string, status = 400) =>
jsonResponse(
{ result: "error", error: { code: "not_authorized", description } },
false,
status,
);
const config = {
providerType: "infomaniak" as const,
apiToken: "ik_test_token",
};
const lastCall = () =>
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
const lastBody = () => JSON.parse(lastCall()[1].body as string);
beforeEach(() => {
mockFetch.mockReset();
});
describe("infomaniakClient.listZones", () => {
it("exposes each domain product as a zone keyed by its name", async () => {
mockFetch.mockResolvedValue(
ikPage(
[
{ id: 1, customer_name: "example.com" },
{ id: 2, customer_name: "example.ch" },
],
1,
1,
),
);
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.ch", name: "example.ch" },
]);
const [url, init] = lastCall();
// The documented endpoint is the plural one; the singular is legacy and
// returns no pagination metadata at all.
expect(url).toContain("/1/products?service_name=domain");
expect(url).toContain("page=1");
expect(init.headers).toMatchObject({
Authorization: "Bearer ik_test_token",
});
});
it("walks every page so accounts with many domains keep all their zones", async () => {
mockFetch
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
const zones = await infomaniakClient.listZones(config);
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
});
it("stops after a single page when the response has no pagination", async () => {
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("propagates the API error description", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
"Authorization required",
);
});
});
describe("infomaniakClient.listRecords", () => {
it("rebuilds the fqdn from the relative source", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "10",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
{
id: "11",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 600,
},
]);
expect(lastCall()[0]).toBe(
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
);
});
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 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.name).toBe("example.com");
});
it("unquotes TXT targets", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 13,
type: "TXT",
source: "_acme-challenge",
target: '"token-value"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe("token-value");
});
it("leaves a CAA target untouched", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 14,
type: "CAA",
source: "",
target: '0 issue "letsencrypt.org"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
});
});
describe("infomaniakClient.upsertRecord", () => {
it("creates the record when no matching source and type exists", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "42" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
expect(lastBody()).toEqual({
type: "A",
source: "app",
target: "1.2.3.4",
ttl: 600,
});
});
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody().ttl).toBe(300);
});
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 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(lastBody().source).toBe(".");
});
it.each([".", "", "@"])(
"matches an existing apex record stored with a %s source",
async (source) => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 8, type: "A", source, target: "1.1.1.1", ttl: 3600 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "8" });
expect(lastCall()[1].method).toBe("PUT");
},
);
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 () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: "token-value",
});
expect(lastBody().target).toBe('"token-value"');
});
it("does not double-quote a TXT target that is already quoted", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: '"token-value"',
});
expect(lastBody().target).toBe('"token-value"');
});
});
describe("infomaniakClient.updateRecord", () => {
it("updates the record and keeps its id", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
const result = await infomaniakClient.updateRecord(
config,
"example.com",
"7",
{
type: "CNAME",
name: "www.example.com",
content: "example.com",
ttl: 900,
},
);
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody()).toEqual({
type: "CNAME",
source: "www",
target: "example.com",
ttl: 900,
});
});
it("falls back to the default ttl when none is provided", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
await infomaniakClient.updateRecord(config, "example.com", "7", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(lastBody().ttl).toBe(300);
});
});
describe("infomaniakClient.deleteRecord", () => {
it("deletes the record", async () => {
mockFetch.mockResolvedValue(ikSuccess(null));
await infomaniakClient.deleteRecord(config, "example.com", "7");
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("DELETE");
});
it("propagates a delete failure", async () => {
mockFetch.mockResolvedValue(ikError("Record not found", 404));
await expect(
infomaniakClient.deleteRecord(config, "example.com", "7"),
).rejects.toThrow("Record not found");
});
});
describe("infomaniakClient.testConnection", () => {
it("resolves when the domain listing succeeds", async () => {
mockFetch.mockResolvedValue(ikSuccess([]));
await expect(
infomaniakClient.testConnection(config),
).resolves.toBeUndefined();
});
it("rejects on an invalid token", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
);
});
});

View File

@ -44,6 +44,7 @@ const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
} as const;
type ProviderType = keyof typeof providerLabels;
@ -55,7 +56,7 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
providerType: z.enum(["cloudflare", "route53", "porkbun", "infomaniak"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
@ -94,6 +95,11 @@ const buildConfig = (data: DnsProviderForm) => {
apiKey: data.apiKey,
secretApiKey: data.secretApiKey,
};
case "infomaniak":
return {
providerType: "infomaniak" as const,
apiToken: data.apiToken,
};
}
};
@ -155,6 +161,9 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
apiKey: provider.config.apiKey,
secretApiKey: provider.config.secretApiKey,
}),
...(provider.config.providerType === "infomaniak" && {
apiToken: provider.config.apiToken,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@ -383,6 +392,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</>
)}
{providerType === "infomaniak" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token at manager.infomaniak.com with the{" "}
<code>domain:read</code>, <code>dns:read</code> and{" "}
<code>dns:write</code> scopes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"

View File

@ -23,6 +23,8 @@ import { HandleDnsProvider } from "./handle-dns-provider";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
};
export const ShowDnsProviders = () => {

View File

@ -91,8 +91,20 @@ export const PorkbunIcon = ({ className }: Props) => (
</svg>
);
export const InfomaniakIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
infomaniak: InfomaniakIcon,
} as const;

View File

@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';

File diff suppressed because it is too large Load Diff

View File

@ -1338,6 +1338,13 @@
"when": 1788300390648,
"tag": "0190_nappy_anita_blake",
"breakpoints": true
},
{
"idx": 191,
"version": "7",
"when": 1788332224024,
"tag": "0191_cool_christian_walker",
"breakpoints": true
}
]
}

View File

@ -8,6 +8,7 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
"infomaniak",
]);
export const cloudflareDnsConfigSchema = z.object({
@ -27,10 +28,16 @@ export const porkbunDnsConfigSchema = z.object({
secretApiKey: z.string().trim().min(1),
});
export const infomaniakDnsConfigSchema = z.object({
providerType: z.literal("infomaniak"),
apiToken: z.string().trim().min(1),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
infomaniakDnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;

View File

@ -18,6 +18,7 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
infomaniak: ["apiToken"],
};
export const maskDnsProviderConfig = (

View File

@ -1,5 +1,6 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { infomaniakClient } from "./infomaniak";
import { porkbunClient } from "./porkbun";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
@ -8,6 +9,7 @@ const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
porkbun: porkbunClient as DnsClient,
infomaniak: infomaniakClient as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>

View File

@ -0,0 +1,226 @@
import type { infomaniakDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type InfomaniakConfig = z.infer<typeof infomaniakDnsConfigSchema>;
interface InfomaniakResponse<T> {
result: "success" | "error";
data?: T;
error?: { code?: string; description?: string };
page?: number;
pages?: number;
total?: number;
}
interface InfomaniakRecord {
id: number | string;
type: string;
source: string;
target: string;
ttl: number;
}
interface InfomaniakDomain {
id: number;
customer_name: string;
}
const INFOMANIAK_API = "https://api.infomaniak.com";
// Infomaniak requires a TTL on every record, within a 60..86400 range.
const DEFAULT_TTL = 300;
const ikRequest = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<InfomaniakResponse<T>> => {
const response = await dnsFetch(`${INFOMANIAK_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${config.apiToken.trim()}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = (await response.json()) as InfomaniakResponse<T>;
if (!response.ok || body.result !== "success") {
const detail = body.error?.description ?? body.error?.code;
throw new Error(
`Infomaniak: request to ${path} failed${
detail ? `: ${detail}` : ` (status ${response.status})`
}`,
);
}
return body;
};
const ikFetch = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<T> => (await ikRequest<T>(config, path, init)).data as T;
// 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 ".";
}
const suffix = `.${zone}`;
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
};
const toFqdn = (source: string, zone: string) =>
APEX_SOURCES.has(source) ? zone : `${source}.${zone}`;
// toSource always writes the apex as ".", so an existing record stored under one
// of the other apex spellings has to normalize to the same thing before it can
// be matched.
const normalizeSource = (source: string) =>
APEX_SOURCES.has(source) ? "." : source;
// 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.
const unquoteTarget = (target: string) => {
if (target.length >= 2 && target.startsWith('"') && target.endsWith('"')) {
try {
const unquoted: unknown = JSON.parse(target);
if (typeof unquoted === "string") {
return unquoted;
}
} catch {
return target;
}
}
return target;
};
const quoteTarget = (type: string, content: string) => {
const value = content.trim();
if (type !== "TXT") {
return value;
}
return value.startsWith('"') && value.endsWith('"')
? value
: JSON.stringify(value);
};
const recordPayload = (
record: { type: string; name: string; content: string; ttl?: number },
zone: string,
) => ({
type: record.type,
source: toSource(record.name, zone),
target: quoteTarget(record.type, record.content),
ttl: record.ttl ?? DEFAULT_TTL,
});
const PRODUCTS_PER_PAGE = 100;
// The products endpoint paginates — 15 per page by default — so an account with
// more domains than fit on one page would otherwise silently lose zones.
const listDomainProducts = async (config: InfomaniakConfig) => {
const domains: InfomaniakDomain[] = [];
let page = 1;
while (true) {
const body = await ikRequest<InfomaniakDomain[]>(
config,
`/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`,
);
domains.push(...(body.data ?? []));
if (page >= (body.pages ?? 1)) {
return domains;
}
page += 1;
}
};
const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) =>
await ikFetch<InfomaniakRecord[]>(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`,
);
export const infomaniakClient: DnsClient<InfomaniakConfig> = {
async listZones(config) {
const domains = await listDomainProducts(config);
// The v2 record endpoints are keyed by zone name, not by product id.
return domains.map((domain) => ({
id: domain.customer_name,
name: domain.customer_name,
}));
},
async listRecords(config, zoneId) {
const records = await listZoneRecords(config, zoneId);
return records.map((record) => ({
id: String(record.id),
type: record.type,
name: toFqdn(record.source, zoneId),
content: unquoteTarget(record.target),
ttl: Number(record.ttl),
}));
},
async upsertRecord(config, record) {
const source = toSource(record.name, record.zoneId);
const existing = await listZoneRecords(config, record.zoneId);
const match = existing.find(
(candidate) =>
candidate.type === record.type &&
normalizeSource(candidate.source) === source,
);
const body = JSON.stringify(recordPayload(record, record.zoneId));
const zone = encodeURIComponent(record.zoneId);
if (match) {
await ikFetch(config, `/2/zones/${zone}/records/${match.id}`, {
method: "PUT",
body,
});
return { id: String(match.id) };
}
const created = await ikFetch<InfomaniakRecord>(
config,
`/2/zones/${zone}/records`,
{ method: "POST", body },
);
// The API returns the created record, but older responses only carry its id.
return {
id:
typeof created === "object" && created !== null
? String(created.id)
: String(created),
};
},
async updateRecord(config, zoneId, recordId, record) {
await ikFetch(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
{ method: "PUT", body: JSON.stringify(recordPayload(record, zoneId)) },
);
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await ikFetch(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
{ method: "DELETE" },
);
},
async testConnection(config) {
await ikFetch(config, "/1/products?service_name=domain&per_page=1");
},
};