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) <noreply@anthropic.com>
This commit is contained in:
Guillaume Juge
2026-09-01 15:29:01 +02:00
parent c225188ed4
commit e629248dea
11 changed files with 10005 additions and 1 deletions
+410
View File
@@ -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<string, string>;
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<string, string>;
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",
);
});
});
@@ -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<typeof DnsProviderSchema>;
@@ -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" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>API Endpoint</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(ovhEndpointLabels).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationKey"
render={({ field }) => (
<FormItem>
<FormLabel>Application Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Application Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consumerKey"
render={({ field }) => (
<FormItem>
<FormLabel>Consumer Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create the three keys at once on api.ovh.com/createToken
with the <code>GET</code>, <code>POST</code>,{" "}
<code>PUT</code> and <code>DELETE</code> rights on{" "}
<code>/domain/zone/*</code>.
</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",
ovh: "OVHcloud",
};
export const ShowDnsProviders = () => {
@@ -91,8 +91,20 @@ export const PorkbunIcon = ({ className }: Props) => (
</svg>
);
export const OvhIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
ovh: OvhIcon,
} as const;
@@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1331,6 +1331,13 @@
"when": 1788251861403,
"tag": "0189_wooden_nextwave",
"breakpoints": true
},
{
"idx": 190,
"version": "7",
"when": 1788269306041,
"tag": "0190_even_mastermind",
"breakpoints": true
}
]
}
@@ -8,6 +8,7 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
"ovh",
]);
export const cloudflareDnsConfigSchema = z.object({
@@ -27,10 +28,29 @@ export const porkbunDnsConfigSchema = z.object({
secretApiKey: z.string().trim().min(1),
});
export const ovhApiEndpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
export const ovhDnsConfigSchema = z.object({
providerType: z.literal("ovh"),
endpoint: z.enum(ovhApiEndpoints).default("ovh-eu"),
applicationKey: z.string().trim().min(1),
applicationSecret: z.string().trim().min(1),
consumerKey: z.string().trim().min(1),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
ovhDnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
@@ -18,6 +18,7 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
ovh: ["applicationSecret", "consumerKey"],
};
export const maskDnsProviderConfig = (
+2
View File
@@ -1,5 +1,6 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { ovhClient } from "./ovh";
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,
ovh: ovhClient as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
+284
View File
@@ -0,0 +1,284 @@
import { createHash } from "node:crypto";
import type { ovhDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type OvhConfig = z.infer<typeof ovhDnsConfigSchema>;
interface OvhRecord {
id: number;
zone: string;
fieldType: string;
subDomain: string | null;
target: string;
ttl: number | null;
}
const OVH_ENDPOINTS: Record<OvhConfig["endpoint"], 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",
};
// Fetching every record of a zone takes one call per record, so cap how many of
// them are in flight at once.
const RECORD_CONCURRENCY = 8;
// Requests are signed with the API's own clock: a local clock more than a few
// seconds off would get every call rejected. The drift is re-measured
// periodically in case the host clock is corrected under us.
const CLOCK_SKEW_TTL_MS = 60 * 60 * 1000;
const clockSkews = new Map<
string,
{ deltaSeconds: number; measuredAt: number }
>();
const localTimestamp = () => Math.floor(Date.now() / 1000);
const getTimestamp = async (baseUrl: string) => {
const cached = clockSkews.get(baseUrl);
if (cached && Date.now() - cached.measuredAt < CLOCK_SKEW_TTL_MS) {
return localTimestamp() + cached.deltaSeconds;
}
const response = await dnsFetch(`${baseUrl}/auth/time`);
const serverTime = Number(await response.text());
if (!response.ok || !Number.isFinite(serverTime)) {
throw new Error(
`OVH: could not read the API server time (status ${response.status})`,
);
}
const deltaSeconds = serverTime - localTimestamp();
clockSkews.set(baseUrl, { deltaSeconds, measuredAt: Date.now() });
return localTimestamp() + deltaSeconds;
};
const sign = (
config: OvhConfig,
method: string,
url: string,
body: string,
timestamp: number,
) => {
const digest = createHash("sha1")
.update(
[
config.applicationSecret,
config.consumerKey,
method,
url,
body,
timestamp,
].join("+"),
)
.digest("hex");
return `$1$${digest}`;
};
const ovhFetch = async <T>(
config: OvhConfig,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> => {
const baseUrl = OVH_ENDPOINTS[config.endpoint];
const url = `${baseUrl}${path}`;
const method = init.method ?? "GET";
const body = init.body === undefined ? "" : JSON.stringify(init.body);
const timestamp = await getTimestamp(baseUrl);
const response = await dnsFetch(url, {
method,
...(body ? { body } : {}),
headers: {
"Content-Type": "application/json",
"X-Ovh-Application": config.applicationKey,
"X-Ovh-Consumer": config.consumerKey,
"X-Ovh-Timestamp": String(timestamp),
"X-Ovh-Signature": sign(config, method, url, body, timestamp),
},
});
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const detail =
payload && typeof payload === "object" && "message" in payload
? String((payload as { message: unknown }).message)
: undefined;
throw new Error(
`OVH: request to ${method} ${path} failed${
detail ? `: ${detail}` : ` (status ${response.status})`
}`,
);
}
return payload as T;
};
// OVH addresses records by their subdomain, relative to the zone and empty for
// the apex, while Dokploy works with fully-qualified names.
const toSubDomain = (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 = (subDomain: string | null, zone: string) =>
subDomain ? `${subDomain}.${zone}` : zone;
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
run: (item: T) => Promise<R>,
) => {
const results = new Array<R>(items.length);
let cursor = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await run(items[index] as T);
}
},
);
await Promise.all(workers);
return results;
};
// OVH only applies zone changes once the zone is explicitly refreshed.
const refreshZone = async (config: OvhConfig, zone: string) => {
await ovhFetch(config, `/domain/zone/${encodeURIComponent(zone)}/refresh`, {
method: "POST",
});
};
const recordBody = (
record: { name: string; content: string; ttl?: number },
zone: string,
) => ({
subDomain: toSubDomain(record.name, zone),
target: record.content,
// Leaving the ttl out lets OVH apply the zone's default.
...(record.ttl === undefined ? {} : { ttl: record.ttl }),
});
export const ovhClient: DnsClient<OvhConfig> = {
async listZones(config) {
const zones = await ovhFetch<string[]>(config, "/domain/zone");
return zones.map((zone) => ({ id: zone, name: zone }));
},
async listRecords(config, zoneId) {
const zone = encodeURIComponent(zoneId);
// The listing endpoint only returns ids, so each record is fetched on its own.
const ids = await ovhFetch<number[]>(config, `/domain/zone/${zone}/record`);
const records = await mapWithConcurrency(ids, RECORD_CONCURRENCY, (id) =>
ovhFetch<OvhRecord>(config, `/domain/zone/${zone}/record/${id}`),
);
return records.map((record) => ({
id: String(record.id),
type: record.fieldType,
name: toFqdn(record.subDomain, zoneId),
content: record.target,
ttl: record.ttl ?? 0,
}));
},
async upsertRecord(config, record) {
const zone = encodeURIComponent(record.zoneId);
const subDomain = toSubDomain(record.name, record.zoneId);
const existing = await ovhFetch<number[]>(
config,
`/domain/zone/${zone}/record?fieldType=${encodeURIComponent(
record.type,
)}&subDomain=${encodeURIComponent(subDomain)}`,
);
const existingId = existing[0];
if (existingId !== undefined) {
await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, {
method: "PUT",
body: recordBody(record, record.zoneId),
});
await refreshZone(config, record.zoneId);
return { id: String(existingId) };
}
const created = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record`,
{
method: "POST",
body: { fieldType: record.type, ...recordBody(record, record.zoneId) },
},
);
await refreshZone(config, record.zoneId);
return { id: String(created.id) };
},
async updateRecord(config, zoneId, recordId, record) {
const zone = encodeURIComponent(zoneId);
const existing = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record/${recordId}`,
);
// The update payload carries no fieldType, so switching a record's type
// means replacing it.
if (existing.fieldType !== record.type) {
await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, {
method: "DELETE",
});
const created = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record`,
{
method: "POST",
body: { fieldType: record.type, ...recordBody(record, zoneId) },
},
);
await refreshZone(config, zoneId);
return { id: String(created.id) };
}
await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, {
method: "PUT",
body: recordBody(record, zoneId),
});
await refreshZone(config, zoneId);
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await ovhFetch(
config,
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,
{ method: "DELETE" },
);
await refreshZone(config, zoneId);
},
async testConnection(config) {
await ovhFetch(config, "/domain/zone");
},
};