mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
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
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:
commit
d7884c7ce9
412
apps/dokploy/__test__/dns/infomaniak.test.ts
Normal file
412
apps/dokploy/__test__/dns/infomaniak.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
1
apps/dokploy/drizzle/0191_cool_christian_walker.sql
Normal file
1
apps/dokploy/drizzle/0191_cool_christian_walker.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
@ -18,6 +18,7 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
|
||||
cloudflare: ["apiToken"],
|
||||
route53: ["secretAccessKey"],
|
||||
porkbun: ["secretApiKey"],
|
||||
infomaniak: ["apiToken"],
|
||||
};
|
||||
|
||||
export const maskDnsProviderConfig = (
|
||||
|
||||
@ -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"]) =>
|
||||
|
||||
226
packages/server/src/utils/dns/infomaniak.ts
Normal file
226
packages/server/src/utils/dns/infomaniak.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user