mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
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:
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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"]) =>
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user