mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-20 15:35:23 +00:00
feat: add Infomaniak DNS provider support
Adds Infomaniak alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.
- infomaniak.ts implements listZones, listRecords, upsertRecord, updateRecord,
deleteRecord and testConnection against the Infomaniak API. Zones come from
/1/product?service_name=domain and records from the v2 /2/zones/{zone}/records
endpoints, which are keyed by zone name rather than by product id.
- A new `infomaniak` value was added to the DnsProviderType enum along with an
infomaniakDnsConfigSchema (apiToken) in the discriminated union, plus the
Drizzle migration for the enum change.
- The token is masked/merged like the other providers in services/dns-provider.ts.
- UI: Infomaniak icon and API Token field in the DNS provider dialog, plus
registration in the provider selector.
Infomaniak's `source` is relative to the zone (empty for the apex), so record
names are translated between Dokploy's fully-qualified format and Infomaniak's
subdomain-only format internally, with the trailing dot handled. TXT targets are
stored quoted by the API and are unquoted on read / quoted on write so that
editing a record does not stack quotes on every save.
Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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 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(
|
||||
ikSuccess([
|
||||
{ id: 1, customer_name: "example.com" },
|
||||
{ id: 2, customer_name: "example.ch" },
|
||||
]),
|
||||
);
|
||||
|
||||
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();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/1/product?service_name=domain",
|
||||
);
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer ik_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
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("treats an @ source as the apex", async () => {
|
||||
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 an empty 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("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/product?service_name=domain failed: Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1331,6 +1331,13 @@
|
||||
"when": 1788251861403,
|
||||
"tag": "0189_wooden_nextwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 190,
|
||||
"version": "7",
|
||||
"when": 1788269053738,
|
||||
"tag": "0190_perpetual_red_skull",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user