From e629248deab2a1a79b0e1741971a8966659f8ce4 Mon Sep 17 00:00:00 2001 From: Guillaume Juge Date: Tue, 1 Sep 2026 15:29:01 +0200 Subject: [PATCH] feat: add OVHcloud DNS provider support Adds OVHcloud alongside Cloudflare, AWS Route53 and Porkbun, following the existing DnsClient interface in packages/server/src/utils/dns/. - ovh.ts implements listZones, listRecords, upsertRecord, updateRecord, deleteRecord and testConnection against the /domain/zone endpoints of the OVHcloud API, on any of its seven regional endpoints (ovh-eu/ca/us, kimsufi and soyoustart). - A new `ovh` value was added to the DnsProviderType enum along with an ovhDnsConfigSchema (endpoint, applicationKey, applicationSecret, consumerKey) in the discriminated union, plus the Drizzle migration for the enum change. - The application secret and the consumer key are masked/merged like the other providers' secrets in services/dns-provider.ts. - UI: OVHcloud icon, an endpoint selector and the three credential fields in the DNS provider dialog, plus registration in the provider selector. Three OVH-specific behaviours are handled explicitly: - Requests are signed with `$1$` + sha1(applicationSecret+consumerKey+method+ url+body+timestamp). The timestamp comes from the API's own clock via an unauthenticated GET /auth/time, since a host clock a few seconds off would get every call rejected; the measured drift is cached per endpoint for an hour. - OVH only applies zone changes once the zone is explicitly refreshed, so every successful create, update and delete is followed by POST /domain/zone/{zone} /refresh. - The record update payload carries no fieldType, so changing a record's type replaces the record (DELETE then POST) and returns the new id. The record listing endpoint returns ids only, so each record is fetched individually with the fan-out capped at 8 concurrent requests. 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) --- apps/dokploy/__test__/dns/ovh.test.ts | 410 + .../settings/dns/handle-dns-provider.tsx | 119 +- .../settings/dns/show-dns-providers.tsx | 2 + .../components/icons/dns-provider-icons.tsx | 12 + apps/dokploy/drizzle/0190_even_mastermind.sql | 1 + apps/dokploy/drizzle/meta/0190_snapshot.json | 9148 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema/dns-provider.ts | 20 + packages/server/src/services/dns-provider.ts | 1 + packages/server/src/utils/dns/index.ts | 2 + packages/server/src/utils/dns/ovh.ts | 284 + 11 files changed, 10005 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/dns/ovh.test.ts create mode 100644 apps/dokploy/drizzle/0190_even_mastermind.sql create mode 100644 apps/dokploy/drizzle/meta/0190_snapshot.json create mode 100644 packages/server/src/utils/dns/ovh.ts diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts new file mode 100644 index 000000000..4ddeff8fb --- /dev/null +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -0,0 +1,410 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { ovhClient } from "@dokploy/server/utils/dns/ovh"; + +const textResponse = (body: string, ok = true, status = 200) => + ({ + ok, + status, + text: async () => body, + }) as Response; + +const ovhSuccess = (data: unknown) => + textResponse(data === undefined ? "" : JSON.stringify(data)); + +const ovhError = (message: string, status = 403) => + textResponse(JSON.stringify({ message }), false, status); + +const SERVER_TIME = 1788268225; + +const config = { + providerType: "ovh" as const, + endpoint: "ovh-eu" as const, + applicationKey: "app-key", + applicationSecret: "app-secret", + consumerKey: "consumer-key", +}; + +// Each test uses a distinct endpoint so the module-level clock-skew cache, which +// is keyed by base url, never leaks a measurement between them. +let endpointCursor = 0; +const endpoints = [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +] as const; +const baseUrls: Record<(typeof endpoints)[number], string> = { + "ovh-eu": "https://eu.api.ovh.com/1.0", + "ovh-ca": "https://ca.api.ovh.com/1.0", + "ovh-us": "https://api.us.ovhcloud.com/1.0", + "kimsufi-eu": "https://eu.api.kimsufi.com/1.0", + "kimsufi-ca": "https://ca.api.kimsufi.com/1.0", + "soyoustart-eu": "https://eu.api.soyoustart.com/1.0", + "soyoustart-ca": "https://ca.api.soyoustart.com/1.0", +}; + +/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */ +const freshConfig = () => { + const endpoint = endpoints[ + endpointCursor % endpoints.length + ] as (typeof endpoints)[number]; + endpointCursor += 1; + return { ...config, endpoint, baseUrl: baseUrls[endpoint] }; +}; + +/** Replies to /auth/time, then to each queued API response in order. */ +const mockApi = (...responses: Response[]) => { + let call = 0; + mockFetch.mockImplementation((url: string) => { + if (url.endsWith("/auth/time")) { + return Promise.resolve(textResponse(String(SERVER_TIME))); + } + const response = responses[call]; + call += 1; + return Promise.resolve(response ?? ovhSuccess(null)); + }); +}; + +const apiCalls = () => + mockFetch.mock.calls.filter( + ([url]) => !(url as string).endsWith("/auth/time"), + ) as [string, RequestInit][]; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("ovhClient request signing", () => { + it("signs the request with the API server clock, not the local one", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess(["example.com"])); + vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000); + + await ovhClient.listZones(cfg); + + const [url, init] = apiCalls()[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(url).toBe(`${baseUrl}/domain/zone`); + expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME)); + expect(headers["X-Ovh-Application"]).toBe("app-key"); + expect(headers["X-Ovh-Consumer"]).toBe("consumer-key"); + + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); + + it("signs a request body when one is sent", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null)); + vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url, init] = apiCalls()[1] as [string, RequestInit]; + const headers = init.headers as Record; + const body = init.body as string; + expect(body).not.toBe(""); + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join( + "+", + ), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); +}); + +describe("ovhClient.listZones", () => { + it("maps each zone name to a zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(["example.com", "example.fr"])); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([ + { id: "example.com", name: "example.com" }, + { id: "example.fr", name: "example.fr" }, + ]); + }); + + it("propagates the API error message", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This call has not been granted", 403)); + + await expect(ovhClient.listZones(cfg)).rejects.toThrow( + "This call has not been granted", + ); + }); +}); + +describe("ovhClient.listRecords", () => { + it("resolves each id into a full record", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([1, 2]), + ovhSuccess({ + id: 1, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }), + ovhSuccess({ + id: 2, + zone: "example.com", + fieldType: "A", + subDomain: null, + target: "5.6.7.8", + ttl: null, + }), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + { + id: "2", + type: "A", + name: "example.com", + content: "5.6.7.8", + ttl: 0, + }, + ]); + }); + + it("keeps the records in the order of the returned ids", async () => { + const cfg = freshConfig(); + const record = (id: number, subDomain: string) => ({ + id, + zone: "example.com", + fieldType: "A", + subDomain, + target: `10.0.0.${id}`, + ttl: 60, + }); + mockApi( + ovhSuccess([1, 2, 3, 4, 5]), + ...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]); + }); +}); + +describe("ovhClient.upsertRecord", () => { + it("creates the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }); + + expect(result).toEqual({ id: "9" }); + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain( + "/domain/zone/example.com/record?fieldType=A&subDomain=app", + ); + expect(calls[1]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh"); + expect(calls[2]?.[1].method).toBe("POST"); + }); + + it("updates the existing record instead of creating a duplicate", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([4]), ovhSuccess(null), ovhSuccess(null)); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + 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"); + }); + + it("omits the ttl so OVH applies the zone default", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty( + "ttl", + ); + }); + + it("writes an empty subDomain for the apex and strips the trailing dot", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "example.com.", + content: "1.2.3.4", + }); + + expect(apiCalls()[0]?.[0]).toContain("subDomain="); + expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe(""); + }); +}); + +describe("ovhClient.updateRecord", () => { + it("updates in place when the type is unchanged", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }); + + expect(result).toEqual({ id: "4" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("PUT"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + subDomain: "app", + target: "1.2.3.4", + ttl: 300, + }); + expect(calls[2]?.[0]).toContain("/refresh"); + }); + + it("replaces the record when the type changes, since PUT carries no fieldType", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess({ id: 11 }), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "example.com", + }); + + expect(result).toEqual({ id: "11" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("DELETE"); + expect(calls[2]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); +}); + +describe("ovhClient.deleteRecord", () => { + it("deletes the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await ovhClient.deleteRecord(cfg, "example.com", "4"); + + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[0]?.[1].method).toBe("DELETE"); + expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh"); + }); + + it("does not refresh the zone when the delete fails", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This object does not exist", 404)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "4"), + ).rejects.toThrow("This object does not exist"); + expect(apiCalls()).toHaveLength(1); + }); +}); + +describe("ovhClient.testConnection", () => { + it("resolves when the zone listing succeeds", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([])); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + }); + + it("rejects on invalid credentials", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.testConnection(cfg)).rejects.toThrow( + "Invalid signature", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index f45fcd836..f5d475c29 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -44,6 +44,17 @@ const providerLabels = { cloudflare: "Cloudflare", route53: "AWS Route53", porkbun: "Porkbun", + ovh: "OVHcloud", +} as const; + +const ovhEndpointLabels = { + "ovh-eu": "OVHcloud Europe", + "ovh-ca": "OVHcloud Canada", + "ovh-us": "OVHcloud US", + "kimsufi-eu": "Kimsufi Europe", + "kimsufi-ca": "Kimsufi Canada", + "soyoustart-eu": "So you Start Europe", + "soyoustart-ca": "So you Start Canada", } as const; type ProviderType = keyof typeof providerLabels; @@ -55,12 +66,21 @@ 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", "ovh"]), apiToken: z.string(), accessKeyId: z.string(), secretAccessKey: z.string(), apiKey: z.string(), secretApiKey: z.string(), + endpoint: z.enum( + Object.keys(ovhEndpointLabels) as [ + keyof typeof ovhEndpointLabels, + ...(keyof typeof ovhEndpointLabels)[], + ], + ), + applicationKey: z.string(), + applicationSecret: z.string(), + consumerKey: z.string(), }); type DnsProviderForm = z.infer; @@ -73,6 +93,10 @@ const defaultValues: DnsProviderForm = { secretAccessKey: "", apiKey: "", secretApiKey: "", + endpoint: "ovh-eu", + applicationKey: "", + applicationSecret: "", + consumerKey: "", }; const buildConfig = (data: DnsProviderForm) => { @@ -94,6 +118,14 @@ const buildConfig = (data: DnsProviderForm) => { apiKey: data.apiKey, secretApiKey: data.secretApiKey, }; + case "ovh": + return { + providerType: "ovh" as const, + endpoint: data.endpoint, + applicationKey: data.applicationKey, + applicationSecret: data.applicationSecret, + consumerKey: data.consumerKey, + }; } }; @@ -155,6 +187,12 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { apiKey: provider.config.apiKey, secretApiKey: provider.config.secretApiKey, }), + ...(provider.config.providerType === "ovh" && { + endpoint: provider.config.endpoint, + applicationKey: provider.config.applicationKey, + applicationSecret: provider.config.applicationSecret, + consumerKey: provider.config.consumerKey, + }), }); } else if (!dnsProviderId) { form.reset(defaultValues); @@ -383,6 +421,85 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { )} + {providerType === "ovh" && ( + <> + ( + + API Endpoint + + + + )} + /> + ( + + Application Key + + + + + + )} + /> + ( + + Application Secret + + + + + + )} + /> + ( + + Consumer Key + + + + + Create the three keys at once on api.ovh.com/createToken + with the GET, POST,{" "} + PUT and DELETE rights on{" "} + /domain/zone/*. + + + + )} + /> + + )} +