feat: add DNS provider integration (Cloudflare, AWS Route53)

Lets you connect a DNS provider and manage its records (create,
update, delete) from Settings -> DNS Providers, instead of doing it
by hand in Cloudflare/AWS.

- dns_provider table, org-scoped, jsonb config as a discriminated
  union per provider type
- Cloudflare adapter (REST, bearer token)
- Route53 adapter (AWS SDK, SigV4); records are identified by
  type:name since Route53 has no native record id, so update
  handles renames as delete-old + upsert-new
- listZones/listRecords/createRecord/updateRecord/deleteRecord/
  testConnection wired through a shared DnsClient interface
- Settings UI: provider management, zone browser, record CRUD,
  IP-fill dropdown for A records (panel IP + remote servers)
- Access control: dnsProvider resource wired into custom roles
- Unit tests for both adapters and the config mask/merge logic
This commit is contained in:
Mauricio Siu 2026-08-12 03:25:25 -06:00
parent 3848fa9c0d
commit 4d58f3987f
29 changed files with 12233 additions and 18 deletions

View File

@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const cfSuccess = (result: unknown) =>
jsonResponse({ success: true, errors: [], result });
const cfError = (message: string, status = 400) =>
jsonResponse(
{ success: false, errors: [{ code: 1, message }] },
false,
status,
);
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
beforeEach(() => {
mockFetch.mockReset();
});
describe("cloudflareClient.listZones", () => {
it("returns a single page of zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
const zones = await cloudflareClient.listZones(config);
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/zones?per_page=50&page=1");
});
it("paginates until a short page is returned", async () => {
const page = (count: number) =>
cfSuccess(
Array.from({ length: count }, (_, i) => ({
id: `z${i}`,
name: `zone${i}.com`,
})),
);
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
const zones = await cloudflareClient.listZones(config);
expect(zones).toHaveLength(53);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
});
});
describe("cloudflareClient.listRecords", () => {
it("lists records for a zone", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records).toEqual([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(updateUrl).toContain("/dns_records/existing-1");
expect(updateInit.method).toBe("PUT");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(1);
});
});
describe("cloudflareClient.updateRecord", () => {
it("PUTs directly to the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
expect(result).toEqual({ id: "r1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("cloudflareClient.deleteRecord", () => {
it("DELETEs the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({}));
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("DELETE");
});
});
describe("cloudflareClient.testConnection", () => {
it("succeeds when the token can list zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await expect(
cloudflareClient.testConnection(config),
).resolves.toBeUndefined();
});
it("surfaces Cloudflare's error message on an invalid token", async () => {
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
"Invalid API Token",
);
});
});
describe("cloudflareClient auth header", () => {
it("trims whitespace pasted into the token", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await cloudflareClient.testConnection({
providerType: "cloudflare",
apiToken: " cf-token\n",
});
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer cf-token",
);
});
});

View File

@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import {
DNS_SECRET_MASK,
maskDnsProviderConfig,
mergeDnsProviderConfig,
} from "@dokploy/server/services/dns-provider";
describe("maskDnsProviderConfig", () => {
it("masks the apiToken for a cloudflare config", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "real-token",
});
expect(masked).toEqual({
providerType: "cloudflare",
apiToken: DNS_SECRET_MASK,
});
});
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
const masked = maskDnsProviderConfig({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: "shh",
});
expect(masked).toEqual({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: DNS_SECRET_MASK,
});
});
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "",
});
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
});
});
describe("mergeDnsProviderConfig", () => {
it("restores the real secret when the incoming config still has the mask placeholder", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
});
it("keeps a freshly entered secret instead of the stored one", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "old-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: "new-token",
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
});
it("throws when switching provider type while the field is still masked", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA",
secretAccessKey: DNS_SECRET_MASK,
};
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
"Credentials must be re-entered",
);
});
it("does not require re-entry for fields that are not sensitive", () => {
const existing = {
providerType: "route53" as const,
accessKeyId: "AKIA_OLD",
secretAccessKey: "old-secret",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA_NEW",
secretAccessKey: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
providerType: "route53",
accessKeyId: "AKIA_NEW",
secretAccessKey: "old-secret",
});
});
});

View File

@ -0,0 +1,288 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: any };
const {
send,
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: any;
constructor(input: any) {
this.input = input;
}
}
const send = vi.fn();
class Route53Client {
send(command: unknown) {
return send(command);
}
}
return {
send,
Route53Client,
ListHostedZonesCommand: class extends FakeCommand {},
ListResourceRecordSetsCommand: class extends FakeCommand {},
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-route-53", () => ({
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
}));
import { route53Client } from "@dokploy/server/utils/dns/route53";
const config = {
providerType: "route53" as const,
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
});
describe("route53Client.listZones", () => {
it("strips the hostedzone prefix and the trailing dot", async () => {
send.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
});
it("follows the marker until IsTruncated is false", async () => {
send
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
IsTruncated: true,
NextMarker: "marker-1",
})
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([
{ id: "Z1", name: "a.com" },
{ id: "Z2", name: "b.com" },
]);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
});
});
describe("route53Client.listRecords", () => {
it("builds a type:name id and strips the trailing dot from the name", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([
{
id: "A:app.example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([]);
});
it("joins multiple values for the same record", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "example.com.",
Type: "NS",
TTL: 172800,
ResourceRecords: [
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
},
]);
});
});
describe("route53Client.updateRecord", () => {
it("only UPSERTs when the name/type identity did not change", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
});
expect(send).toHaveBeenCalledTimes(1);
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
it("deletes the old record set and creates the new one on rename", async () => {
const existingSet = {
Name: "old.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
};
send
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "new.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
},
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
});
describe("route53Client.deleteRecord", () => {
it("deletes the record set found by name/type", async () => {
const existingSet = {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
await expect(
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
).rejects.toThrow("not found");
});
it("throws for a malformed record id", async () => {
await expect(
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
).rejects.toThrow("Invalid Route53 record id");
});
});
describe("route53Client.testConnection", () => {
it("resolves when ListHostedZones succeeds", async () => {
send.mockResolvedValueOnce({ HostedZones: [] });
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
});
it("propagates SDK errors", async () => {
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
await expect(route53Client.testConnection(config)).rejects.toThrow(
"InvalidClientTokenId",
);
});
});

View File

@ -0,0 +1,361 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
} as const;
type ProviderType = keyof typeof providerLabels;
const DnsProviderSchema = z.object({
name: z
.string()
.min(1, { message: "Name is required" })
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
endpoint: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
const defaultValues: DnsProviderForm = {
name: "",
providerType: "cloudflare",
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
endpoint: "",
};
const buildConfig = (data: DnsProviderForm) => {
switch (data.providerType) {
case "cloudflare":
return {
providerType: "cloudflare" as const,
apiToken: data.apiToken,
};
case "route53":
return {
providerType: "route53" as const,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
endpoint: data.endpoint || undefined,
};
}
};
const extractErrorMessage = (err: unknown) => {
if (!(err instanceof Error)) return undefined;
try {
const issues = JSON.parse(err.message) as { message?: string }[];
if (Array.isArray(issues)) {
return issues
.map((issue) => issue.message)
.filter(Boolean)
.join(", ");
}
} catch {}
return err.message;
};
interface Props {
dnsProviderId?: string;
}
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: provider } = api.dnsProvider.one.useQuery(
{ dnsProviderId: dnsProviderId || "" },
{ enabled: !!dnsProviderId && isOpen },
);
const { mutateAsync, isPending, error, isError } = dnsProviderId
? api.dnsProvider.update.useMutation()
: api.dnsProvider.create.useMutation();
const { mutateAsync: testConnection, isPending: isTesting } =
api.dnsProvider.testConnection.useMutation();
const form = useForm<DnsProviderForm>({
defaultValues,
resolver: zodResolver(DnsProviderSchema),
});
const providerType = form.watch("providerType");
useEffect(() => {
if (provider) {
form.reset({
...defaultValues,
name: provider.name,
providerType: provider.config.providerType,
...(provider.config.providerType === "cloudflare" && {
apiToken: provider.config.apiToken,
}),
...(provider.config.providerType === "route53" && {
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
endpoint: provider.config.endpoint ?? "",
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
}
}, [provider, dnsProviderId, form, isOpen]);
const onSubmit = async (data: DnsProviderForm) => {
const payload: any = {
name: data.name,
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
};
await mutateAsync(payload)
.then(() => {
toast.success(
dnsProviderId ? "DNS provider updated" : "DNS provider created",
);
utils.dnsProvider.all.invalidate();
setIsOpen(false);
})
.catch(() => {});
};
const onTestConnection = async () => {
const isValid = await form.trigger();
if (!isValid) {
return;
}
const data = form.getValues();
await testConnection({
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
})
.then(() => {
toast.success("Connection successful");
})
.catch((err) => {
toast.error("Connection failed", {
description: extractErrorMessage(err),
});
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{dnsProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
</DialogTitle>
<DialogDescription>
Connect a DNS provider to create records for your domains
automatically instead of setting them up by hand.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="prod-cloudflare" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={!!dnsProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(providerLabels).map(([value, label]) => {
const ProviderIcon =
dnsProviderIcons[value as ProviderType];
return (
<SelectItem key={value} value={value}>
<div className="flex flex-row items-center gap-2">
<ProviderIcon className="size-4 shrink-0" />
{label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{providerType === "cloudflare" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token scoped to Zone DNS Edit for the zones
you want Dokploy to manage. Avoid the Global API Key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "route53" && (
<>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Use an IAM user/role scoped to{" "}
<code>route53:ListHostedZones</code>,{" "}
<code>route53:ListResourceRecordSets</code> and{" "}
<code>route53:ChangeResourceRecordSets</code> avoid
root account credentials.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint (optional)</FormLabel>
<FormControl>
<Input placeholder="http://localhost:4566" {...field} />
</FormControl>
<FormDescription>
Only for a LocalStack/API-compatible emulator. Leave
empty to use AWS.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
onClick={onTestConnection}
>
Test Connection
</Button>
<Button type="submit" isLoading={isPending}>
{dnsProviderId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,264 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({ dnsProviderId, zoneId, record }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name: data.name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,211 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
{record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord dnsProviderId={dnsProviderId} zoneId={zoneId} />
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,145 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -0,0 +1,40 @@
interface Props {
className?: string;
}
export const CloudflareIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727" />
</svg>
);
export const Route53Icon = ({ className }: Props) => (
<svg
viewBox="0 0 304 182"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="currentColor"
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
/>
<path
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
fill="#f90"
/>
<path
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
fill="#f90"
/>
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
} as const;

View File

@ -19,6 +19,7 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
Globe,
House,
Key,
KeyRound,
@ -370,6 +371,13 @@ const MENU: Menu = {
icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
},
{
isSingle: true,
title: "DNS Providers",
url: "/dashboard/settings/dns",
icon: Globe,
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
},
{
isSingle: true,
title: "S3 Destinations",

View File

@ -175,6 +175,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
};
/** Descriptions for each action within a resource */
@ -447,6 +452,25 @@ const ACTION_META: Record<
},
delete: { label: "Delete", description: "Remove secret providers" },
},
dnsProvider: {
read: {
label: "Read",
description: "View configured DNS providers and their zones/records",
},
create: {
label: "Create",
description:
"Connect new DNS providers, test their connection, and create records",
},
update: {
label: "Update",
description: "Edit provider credentials and update existing records",
},
delete: {
label: "Delete",
description: "Remove DNS providers and delete their records",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */

View File

@ -0,0 +1,12 @@
CREATE TYPE "public"."DnsProviderType" AS ENUM('cloudflare', 'route53');--> statement-breakpoint
CREATE TABLE "dns_provider" (
"dnsProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "DnsProviderType" NOT NULL,
"config" jsonb NOT NULL,
"organizationId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "dns_provider" ADD CONSTRAINT "dns_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "dns_provider_org_name_idx" ON "dns_provider" USING btree ("organizationId","name");

File diff suppressed because it is too large Load Diff

View File

@ -1296,6 +1296,13 @@
"when": 1786500135945,
"tag": "0184_wonderful_absorbing_man",
"breakpoints": true
},
{
"idx": 185,
"version": "7",
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
}
]
}

View File

@ -40,7 +40,6 @@
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
@ -48,6 +47,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",

View File

@ -0,0 +1,53 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowDnsProviders />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@ -9,6 +9,7 @@ import { clusterRouter } from "./routers/cluster";
import { composeRouter } from "./routers/compose";
import { deploymentRouter } from "./routers/deployment";
import { destinationRouter } from "./routers/destination";
import { dnsProviderRouter } from "./routers/dns-provider";
import { dockerRouter } from "./routers/docker";
import { dockerVolumeRouter } from "./routers/docker-volume";
import { domainRouter } from "./routers/domain";
@ -69,6 +70,7 @@ export const appRouter = createTRPCRouter({
compose: composeRouter,
deployment: deploymentRouter,
destination: destinationRouter,
dnsProvider: dnsProviderRouter,
docker: dockerRouter,
dockerVolume: dockerVolumeRouter,
domain: domainRouter,

View File

@ -0,0 +1,222 @@
import {
createDnsProvider,
createDnsProviderRecord,
deleteDnsProviderRecord,
findDnsProviderInOrganization,
findDnsProvidersByOrganizationId,
listDnsProviderRecords,
listDnsProviderZones,
maskDnsProviderConfig,
mergeDnsProviderConfig,
removeDnsProvider,
testDnsProviderConnection,
updateDnsProvider,
updateDnsProviderRecord,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateDnsProvider,
apiCreateDnsRecord,
apiDeleteDnsRecord,
apiFindOneDnsProvider,
apiListDnsRecords,
apiListDnsZones,
apiRemoveDnsProvider,
apiTestDnsProvider,
apiUpdateDnsProvider,
apiUpdateDnsRecord,
} from "@/server/db/schema";
import { createTRPCRouter, withPermission } from "../trpc";
export const dnsProviderRouter = createTRPCRouter({
create: withPermission("dnsProvider", "create")
.input(apiCreateDnsProvider)
.mutation(async ({ ctx, input }) => {
const provider = await createDnsProvider(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "dnsProvider",
resourceId: provider.dnsProviderId,
resourceName: provider.name,
});
return { ...provider, config: maskDnsProviderConfig(provider.config) };
}),
update: withPermission("dnsProvider", "update")
.input(apiUpdateDnsProvider)
.mutation(async ({ ctx, input }) => {
await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
const updated = await updateDnsProvider(
input.dnsProviderId,
input.name,
input.config,
);
await audit(ctx, {
action: "update",
resourceType: "dnsProvider",
resourceId: updated.dnsProviderId,
resourceName: updated.name,
});
return { ...updated, config: maskDnsProviderConfig(updated.config) };
}),
remove: withPermission("dnsProvider", "delete")
.input(apiRemoveDnsProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "delete",
resourceType: "dnsProvider",
resourceId: provider.dnsProviderId,
resourceName: provider.name,
});
await removeDnsProvider(input.dnsProviderId);
return true;
}),
all: withPermission("dnsProvider", "read").query(async ({ ctx }) => {
const providers = await findDnsProvidersByOrganizationId(
ctx.session.activeOrganizationId,
);
return providers.map((provider) => ({
...provider,
config: maskDnsProviderConfig(provider.config),
}));
}),
one: withPermission("dnsProvider", "read")
.input(apiFindOneDnsProvider)
.query(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
return { ...provider, config: maskDnsProviderConfig(provider.config) };
}),
testConnection: withPermission("dnsProvider", "create")
.input(apiTestDnsProvider)
.mutation(async ({ ctx, input }) => {
if (!input.config && !input.dnsProviderId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Provide a config or a dnsProviderId to test",
});
}
let config = input.config;
if (input.dnsProviderId) {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
config = config
? mergeDnsProviderConfig(config, provider.config)
: provider.config;
}
await testDnsProviderConnection(config!);
return true;
}),
listZones: withPermission("dnsProvider", "read")
.input(apiListDnsZones)
.query(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
return await listDnsProviderZones(provider.config);
}),
listRecords: withPermission("dnsProvider", "read")
.input(apiListDnsRecords)
.query(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
return await listDnsProviderRecords(provider.config, input.zoneId);
}),
createRecord: withPermission("dnsProvider", "update")
.input(apiCreateDnsRecord)
.mutation(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
const record = await createDnsProviderRecord(provider.config, {
zoneId: input.zoneId,
type: input.type,
name: input.name,
content: input.content,
ttl: input.ttl,
});
await audit(ctx, {
action: "create",
resourceType: "dnsProvider",
resourceId: record.id,
resourceName: input.name,
});
return record;
}),
updateRecord: withPermission("dnsProvider", "update")
.input(apiUpdateDnsRecord)
.mutation(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
const record = await updateDnsProviderRecord(
provider.config,
input.zoneId,
input.recordId,
{
type: input.type,
name: input.name,
content: input.content,
ttl: input.ttl,
},
);
await audit(ctx, {
action: "update",
resourceType: "dnsProvider",
resourceId: record.id,
resourceName: input.name,
});
return record;
}),
deleteRecord: withPermission("dnsProvider", "delete")
.input(apiDeleteDnsRecord)
.mutation(async ({ ctx, input }) => {
const provider = await findDnsProviderInOrganization(
input.dnsProviderId,
ctx.session.activeOrganizationId,
);
await deleteDnsProviderRecord(
provider.config,
input.zoneId,
input.recordId,
);
await audit(ctx, {
action: "delete",
resourceType: "dnsProvider",
resourceId: input.recordId,
resourceName: input.zoneId,
});
return true;
}),
});

View File

@ -37,6 +37,7 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",

View File

@ -93,4 +93,5 @@ export type AuditResourceType =
| "application"
| "compose"
| "network"
| "vaultProvider";
| "vaultProvider"
| "dnsProvider";

View File

@ -0,0 +1,122 @@
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
]);
export const cloudflareDnsConfigSchema = z.object({
providerType: z.literal("cloudflare"),
apiToken: z.string().trim().min(1),
});
export const route53DnsConfigSchema = z.object({
providerType: z.literal("route53"),
accessKeyId: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
endpoint: z.string().trim().url().optional(),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
export const dnsProvider = pgTable(
"dns_provider",
{
dnsProviderId: text("dnsProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: dnsProviderType("providerType").notNull(),
config: jsonb("config").$type<DnsProviderConfig>().notNull(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
},
(table) => [
uniqueIndex("dns_provider_org_name_idx").on(
table.organizationId,
table.name,
),
],
);
const dnsProviderNameSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-zA-Z0-9_-]+$/,
"Name can only contain letters, numbers, dashes and underscores",
);
const createSchema = createInsertSchema(dnsProvider);
export const apiCreateDnsProvider = createSchema.pick({}).extend({
name: dnsProviderNameSchema,
config: dnsProviderConfigSchema,
});
export const apiUpdateDnsProvider = createSchema.pick({}).extend({
dnsProviderId: z.string().min(1),
name: dnsProviderNameSchema,
config: dnsProviderConfigSchema,
});
export const apiFindOneDnsProvider = z.object({
dnsProviderId: z.string().min(1),
});
export const apiRemoveDnsProvider = z.object({
dnsProviderId: z.string().min(1),
});
export const apiTestDnsProvider = z.object({
dnsProviderId: z.string().min(1).optional(),
config: dnsProviderConfigSchema.optional(),
});
export const apiListDnsZones = z.object({
dnsProviderId: z.string().min(1),
});
export const apiListDnsRecords = z.object({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
});
const dnsRecordFieldsSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1),
content: z.string().min(1),
ttl: z.number().int().positive().optional(),
});
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
});
export const apiUpdateDnsRecord = dnsRecordFieldsSchema.extend({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
recordId: z.string().min(1),
});
export const apiDeleteDnsRecord = z.object({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
recordId: z.string().min(1),
});

View File

@ -8,6 +8,7 @@ export * from "./certificate";
export * from "./compose";
export * from "./deployment";
export * from "./destination";
export * from "./dns-provider";
export * from "./domain";
export * from "./environment";
export * from "./forward-auth";

View File

@ -16,6 +16,7 @@ export * from "./services/cluster";
export * from "./services/compose";
export * from "./services/deployment";
export * from "./services/destination";
export * from "./services/dns-provider";
export * from "./services/docker";
export * from "./services/docker-volume";
export * from "./services/domain";

View File

@ -49,6 +49,7 @@ export const statements = {
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
} as const;
/**
@ -76,6 +77,7 @@ export const enterpriseOnlyResources = new Set<string>([
"monitoring",
"auditLog",
"vaultProvider",
"dnsProvider",
]);
export const ac = createAccessControl(statements);
@ -116,6 +118,7 @@ export const ownerRole = ac.newRole({
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
});
/**
@ -154,6 +157,7 @@ export const adminRole = ac.newRole({
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
});
/**
@ -198,4 +202,6 @@ export const memberRole = ac.newRole({
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
// Members can see configured DNS providers to pick one when adding a domain
dnsProvider: ["read"],
});

View File

@ -0,0 +1,278 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateDnsProvider,
type DnsProviderConfig,
dnsProvider,
} from "@dokploy/server/db/schema";
import type { DnsRecordInput } from "@dokploy/server/utils/dns";
import { getDnsClient } from "@dokploy/server/utils/dns";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type DnsProvider = typeof dnsProvider.$inferSelect;
export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
};
export const maskDnsProviderConfig = (
config: DnsProviderConfig,
): DnsProviderConfig => {
const masked: Record<string, unknown> = { ...config };
for (const field of SENSITIVE_FIELDS[config.providerType]) {
if (masked[field]) {
masked[field] = DNS_SECRET_MASK;
}
}
return masked as DnsProviderConfig;
};
export const mergeDnsProviderConfig = (
incoming: DnsProviderConfig,
existing: DnsProviderConfig,
): DnsProviderConfig => {
const merged: Record<string, unknown> = { ...incoming };
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
if (merged[field] === DNS_SECRET_MASK) {
if (incoming.providerType !== existing.providerType) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Credentials must be re-entered when changing the provider type",
});
}
merged[field] = (existing as Record<string, unknown>)[field];
}
}
return merged as DnsProviderConfig;
};
const isUniqueNameViolation = (error: unknown) =>
error instanceof Error && error.message.includes("dns_provider_org_name_idx");
export const createDnsProvider = async (
input: z.infer<typeof apiCreateDnsProvider>,
organizationId: string,
) => {
try {
const newProvider = await db
.insert(dnsProvider)
.values({
name: input.name,
providerType: input.config.providerType,
config: input.config,
organizationId,
})
.returning()
.then((value) => value[0]);
if (!newProvider) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the DNS provider",
});
}
return newProvider;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${input.name}" already exists in this organization`,
});
}
throw error;
}
};
export const findDnsProviderById = async (dnsProviderId: string) => {
const provider = await db.query.dnsProvider.findFirst({
where: eq(dnsProvider.dnsProviderId, dnsProviderId),
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return provider;
};
export const findDnsProviderInOrganization = async (
dnsProviderId: string,
organizationId: string,
) => {
const provider = await findDnsProviderById(dnsProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this DNS provider",
});
}
return provider;
};
export const findDnsProvidersByOrganizationId = async (
organizationId: string,
) => {
return await db.query.dnsProvider.findMany({
where: eq(dnsProvider.organizationId, organizationId),
orderBy: (providers, { asc }) => [asc(providers.name)],
});
};
export const updateDnsProvider = async (
dnsProviderId: string,
name: string,
config: DnsProviderConfig,
) => {
const existing = await findDnsProviderById(dnsProviderId);
const mergedConfig = mergeDnsProviderConfig(config, existing.config);
try {
const updated = await db
.update(dnsProvider)
.set({
name,
providerType: mergedConfig.providerType,
config: mergedConfig,
})
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the DNS provider",
});
}
return updated;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${name}" already exists in this organization`,
});
}
throw error;
}
};
export const removeDnsProvider = async (dnsProviderId: string) => {
const removed = await db
.delete(dnsProvider)
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return removed;
};
export const testDnsProviderConnection = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
await client.testConnection(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error connecting to the DNS provider",
});
}
};
export const listDnsProviderZones = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
return await client.listZones(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing zones for this DNS provider",
});
}
};
export const listDnsProviderRecords = async (
config: DnsProviderConfig,
zoneId: string,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.listRecords(config, zoneId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing records for this zone",
});
}
};
export const createDnsProviderRecord = async (
config: DnsProviderConfig,
record: DnsRecordInput,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.upsertRecord(config, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error creating the record",
});
}
};
export const updateDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.updateRecord(config, zoneId, recordId, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error updating the record",
});
}
};
export const deleteDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
) => {
const client = getDnsClient(config.providerType);
try {
await client.deleteRecord(config, zoneId, recordId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error deleting the record",
});
}
};

View File

@ -0,0 +1,143 @@
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type CloudflareConfig = z.infer<typeof cloudflareDnsConfigSchema>;
type CloudflareResponse<T> = {
success: boolean;
errors: { code: number; message: string }[];
result: T;
result_info?: { page: number; per_page: number; total_pages: number };
};
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
const cfFetch = async <T>(
config: CloudflareConfig,
path: string,
init: RequestInit = {},
): Promise<T> => {
const response = await dnsFetch(`${CLOUDFLARE_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${config.apiToken.trim()}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = (await response.json()) as CloudflareResponse<T>;
if (!response.ok || !body.success) {
const detail = body.errors?.map((e) => e.message).join(", ");
throw new Error(
`Cloudflare: request to ${path} failed${detail ? `: ${detail}` : ` (status ${response.status})`}`,
);
}
return body.result;
};
export const cloudflareClient: DnsClient<CloudflareConfig> = {
async listZones(config) {
const zones: { id: string; name: string }[] = [];
let page = 1;
while (true) {
const result = await cfFetch<{ id: string; name: string }[]>(
config,
`/zones?per_page=50&page=${page}`,
);
zones.push(...result.map((zone) => ({ id: zone.id, name: zone.name })));
if (result.length < 50) {
break;
}
page += 1;
}
return zones;
},
async listRecords(config, zoneId) {
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let page = 1;
while (true) {
const result = await cfFetch<
{
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[]
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
records.push(...result);
if (result.length < 50) {
break;
}
page += 1;
}
return records;
},
async upsertRecord(config, record) {
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records/${existingRecord.id}`,
{ method: "PUT", body: JSON.stringify(payload) },
);
return { id: updated.id };
}
const created = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records`,
{ method: "POST", body: JSON.stringify(payload) },
);
return { id: created.id };
},
async updateRecord(config, zoneId, recordId, record) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
}),
},
);
return { id: updated.id };
},
async deleteRecord(config, zoneId, recordId) {
await cfFetch(config, `/zones/${zoneId}/dns_records/${recordId}`, {
method: "DELETE",
});
},
async testConnection(config) {
await cfFetch(config, "/zones?per_page=1");
},
};

View File

@ -0,0 +1,14 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
clients[providerType];
export * from "./types";

View File

@ -0,0 +1,216 @@
import {
ChangeResourceRecordSetsCommand,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
type ResourceRecordSet,
Route53Client,
} from "@aws-sdk/client-route-53";
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { DnsClient } from "./types";
type Route53Config = z.infer<typeof route53DnsConfigSchema>;
const createClient = (config: Route53Config) =>
new Route53Client({
region: "us-east-1",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
const ensureTrailingDot = (name: string) =>
name.endsWith(".") ? name : `${name}.`;
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
const buildRecordId = (type: string, name: string) =>
`${type}:${stripTrailingDot(name)}`;
const parseRecordId = (id: string) => {
const separatorIndex = id.indexOf(":");
if (separatorIndex === -1) {
throw new Error(`Invalid Route53 record id: "${id}"`);
}
return {
type: id.slice(0, separatorIndex),
name: id.slice(separatorIndex + 1),
};
};
const findExactRecordSet = async (
config: Route53Config,
zoneId: string,
type: string,
name: string,
): Promise<ResourceRecordSet | undefined> => {
const client = createClient(config);
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: ensureTrailingDot(name),
StartRecordType: type as ResourceRecordSet["Type"],
MaxItems: 1,
}),
);
const candidate = response.ResourceRecordSets?.[0];
if (
candidate &&
candidate.Type === type &&
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
) {
return candidate;
}
return undefined;
};
const buildRecordSet = (record: {
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}): ResourceRecordSet => ({
Name: ensureTrailingDot(record.name),
Type: record.type,
TTL: record.ttl ?? 300,
ResourceRecords: [{ Value: record.content }],
});
export const route53Client: DnsClient<Route53Config> = {
async listZones(config) {
const client = createClient(config);
const zones: { id: string; name: string }[] = [];
let marker: string | undefined;
do {
const response = await client.send(
new ListHostedZonesCommand({ Marker: marker, MaxItems: 100 }),
);
for (const zone of response.HostedZones ?? []) {
if (zone.Id && zone.Name) {
zones.push({
id: stripZonePrefix(zone.Id),
name: stripTrailingDot(zone.Name),
});
}
}
marker = response.IsTruncated ? response.NextMarker : undefined;
} while (marker);
return zones;
},
async listRecords(config, zoneId) {
const client = createClient(config);
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let nextName: string | undefined;
let nextType: string | undefined;
do {
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: nextName,
StartRecordType: nextType as ResourceRecordSet["Type"] | undefined,
}),
);
for (const set of response.ResourceRecordSets ?? []) {
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
continue;
}
records.push({
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
content: set.ResourceRecords.map((r) => r.Value).join(", "),
ttl: set.TTL ?? 300,
});
}
nextName = response.IsTruncated ? response.NextRecordName : undefined;
nextType = response.IsTruncated ? response.NextRecordType : undefined;
} while (nextName);
return records;
},
async upsertRecord(config, record) {
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
Changes: [
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
],
},
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async updateRecord(config, zoneId, recordIdInput, record) {
const { type: oldType, name: oldName } = parseRecordId(recordIdInput);
const client = createClient(config);
const changes: {
Action: "DELETE" | "UPSERT";
ResourceRecordSet: ResourceRecordSet;
}[] = [];
const sameIdentity =
oldType === record.type &&
stripTrailingDot(oldName) === stripTrailingDot(record.name);
if (!sameIdentity) {
const existing = await findExactRecordSet(
config,
zoneId,
oldType,
oldName,
);
if (existing) {
changes.push({ Action: "DELETE", ResourceRecordSet: existing });
}
}
changes.push({
Action: "UPSERT",
ResourceRecordSet: buildRecordSet(record),
});
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: { Changes: changes },
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async deleteRecord(config, zoneId, recordIdInput) {
const { type, name } = parseRecordId(recordIdInput);
const existing = await findExactRecordSet(config, zoneId, type, name);
if (!existing) {
throw new Error(`Route53: record "${name}" (${type}) not found`);
}
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: {
Changes: [{ Action: "DELETE", ResourceRecordSet: existing }],
},
}),
);
},
async testConnection(config) {
const client = createClient(config);
await client.send(new ListHostedZonesCommand({ MaxItems: 1 }));
},
};

View File

@ -0,0 +1,45 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
export interface DnsZone {
id: string;
name: string;
}
export interface DnsRecordInput {
zoneId: string;
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}
export interface DnsRecord {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {
listZones(config: C): Promise<DnsZone[]>;
listRecords(config: C, zoneId: string): Promise<DnsRecord[]>;
upsertRecord(config: C, record: DnsRecordInput): Promise<{ id: string }>;
updateRecord(
config: C,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
): Promise<{ id: string }>;
deleteRecord(config: C, zoneId: string, recordId: string): Promise<void>;
testConnection(config: C): Promise<void>;
}
export const DNS_REQUEST_TIMEOUT_MS = 15_000;
export const dnsFetch = async (url: string, init: RequestInit = {}) => {
return await fetch(url, {
...init,
signal: AbortSignal.timeout(DNS_REQUEST_TIMEOUT_MS),
});
};

View File

@ -115,6 +115,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-secrets-manager':
specifier: ^3.1097.0
version: 3.1097.0
@ -585,6 +588,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-secrets-manager':
specifier: ^3.1097.0
version: 3.1097.0
@ -877,6 +883,10 @@ packages:
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'}
'@aws-sdk/client-route-53@3.1108.0':
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-secrets-manager@3.1097.0':
resolution: {integrity: sha512-EAyknLfabMWA1kAVgcBO6iTYBWFVnoX8WRrKEbPf6npNZUKv7oJoZBK+I0OvRK0o3P0ynbey64pDlspWXB9dag==}
engines: {node: '>=20.0.0'}
@ -884,15 +894,34 @@ packages:
'@aws-sdk/core@3.977.2':
resolution: {integrity: sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==}
engines: {node: '>=20.0.0'}
deprecated: |-
Deprecated due to Document number parsing bug in JSON, see
https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available.
'@aws-sdk/core@3.977.7':
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.63':
resolution: {integrity: sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.68':
resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.65':
resolution: {integrity: sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.70':
resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.13':
resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.8':
resolution: {integrity: sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==}
engines: {node: '>=20.0.0'}
@ -901,14 +930,30 @@ packages:
resolution: {integrity: sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.75':
resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.74':
resolution: {integrity: sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.79':
resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.63':
resolution: {integrity: sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.68':
resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.12':
resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.7':
resolution: {integrity: sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==}
engines: {node: '>=20.0.0'}
@ -917,26 +962,54 @@ packages:
resolution: {integrity: sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.74':
resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-route53@3.972.24':
resolution: {integrity: sha512-+z3OqGhqLA46vF5KbnjuygzSQo9A2cWsbuDy1rdrbWSPDCNT5CCRC+veIxRnqvxwpj/BY8P8XNz+WwCmX0e8rg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.37':
resolution: {integrity: sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.42':
resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.42':
resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.44':
resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1097.0':
resolution: {integrity: sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1108.0':
resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.2':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.3':
resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.37':
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.38':
resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
@ -3870,14 +3943,30 @@ packages:
resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.32.0':
resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.15':
resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.0':
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.12':
resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.7.0':
resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.10.0':
resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.12':
resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==}
engines: {node: '>=18.0.0'}
@ -3886,10 +3975,18 @@ packages:
resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.0':
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.16.1':
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.17.0':
resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==}
engines: {node: '>=18.0.0'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
@ -9219,6 +9316,18 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
'@aws-sdk/client-route-53@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/credential-provider-node': 3.972.79
'@aws-sdk/middleware-sdk-route53': 3.972.24
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/client-secrets-manager@3.1097.0':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9241,6 +9350,17 @@ snapshots:
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/core@3.977.7':
dependencies:
'@aws-sdk/types': 3.974.3
'@aws-sdk/xml-builder': 3.972.38
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.32.0
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.63':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9249,6 +9369,14 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.68':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.65':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9259,6 +9387,32 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.13':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/credential-provider-env': 3.972.68
'@aws-sdk/credential-provider-http': 3.972.70
'@aws-sdk/credential-provider-login': 3.972.75
'@aws-sdk/credential-provider-process': 3.972.68
'@aws-sdk/credential-provider-sso': 3.973.12
'@aws-sdk/credential-provider-web-identity': 3.972.74
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.8':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9277,11 +9431,20 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.2
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.37
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.0
'@smithy/types': 4.16.1
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.75':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.74':
@ -9298,6 +9461,20 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.79':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.68
'@aws-sdk/credential-provider-http': 3.972.70
'@aws-sdk/credential-provider-ini': 3.973.13
'@aws-sdk/credential-provider-process': 3.972.68
'@aws-sdk/credential-provider-sso': 3.973.12
'@aws-sdk/credential-provider-web-identity': 3.972.74
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.63':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9306,6 +9483,24 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.68':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.12':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/token-providers': 3.1108.0
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.7':
dependencies:
'@aws-sdk/core': 3.977.2
@ -9325,31 +9520,73 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.74':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-route53@3.972.24':
dependencies:
'@aws-sdk/types': 3.974.3
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.37':
dependencies:
'@aws-sdk/core': 3.977.2
'@aws-sdk/core': 3.977.7
'@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.0
'@smithy/fetch-http-handler': 5.6.12
'@smithy/node-http-handler': 4.9.12
'@smithy/types': 4.16.1
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.42':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/signature-v4-multi-region': 3.996.44
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.42':
dependencies:
'@aws-sdk/types': 3.974.2
'@aws-sdk/types': 3.974.3
'@smithy/signature-v4': 5.6.11
'@smithy/types': 4.16.1
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.44':
dependencies:
'@aws-sdk/types': 3.974.3
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1097.0':
dependencies:
'@aws-sdk/core': 3.977.2
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.37
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.0
'@smithy/types': 4.16.1
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/types@3.974.2':
@ -9357,11 +9594,21 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/types@3.974.3':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.38':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.0':
@ -12668,18 +12915,41 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/core@3.32.0':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.15':
dependencies:
'@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.0':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.12':
dependencies:
'@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.7.0':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/node-http-handler@4.10.0':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/node-http-handler@4.9.12':
dependencies:
'@smithy/core': 3.31.0
@ -12692,10 +12962,20 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/signature-v4@5.7.0':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/types@4.16.1':
dependencies:
tslib: 2.8.1
'@smithy/types@4.17.0':
dependencies:
tslib: 2.8.1
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}