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
@@ -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",
);
});
});
@@ -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",
});
});
});
+288
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",
);
});
});
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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;
+8
View File
@@ -19,6 +19,7 @@ import {
Forward, Forward,
GalleryVerticalEnd, GalleryVerticalEnd,
GitBranch, GitBranch,
Globe,
House, House,
Key, Key,
KeyRound, KeyRound,
@@ -370,6 +371,13 @@ const MENU: Menu = {
icon: Vault, icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create, isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
}, },
{
isSingle: true,
title: "DNS Providers",
url: "/dashboard/settings/dns",
icon: Globe,
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
},
{ {
isSingle: true, isSingle: true,
title: "S3 Destinations", title: "S3 Destinations",
@@ -175,6 +175,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description: description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced", "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 */ /** Descriptions for each action within a resource */
@@ -447,6 +452,25 @@ const ACTION_META: Record<
}, },
delete: { label: "Delete", description: "Remove secret providers" }, 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) */ /** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -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
+7
View File
@@ -1296,6 +1296,13 @@
"when": 1786500135945, "when": 1786500135945,
"tag": "0184_wonderful_absorbing_man", "tag": "0184_wonderful_absorbing_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 185,
"version": "7",
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -40,7 +40,6 @@
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts" "generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@ai-sdk/anthropic": "^3.0.44", "@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30", "@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21", "@ai-sdk/cohere": "^3.0.21",
@@ -48,6 +47,8 @@
"@ai-sdk/mistral": "^3.0.20", "@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@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/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23", "@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23", "@better-auth/scim": "1.6.23",
@@ -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(),
},
};
}
+2
View File
@@ -9,6 +9,7 @@ import { clusterRouter } from "./routers/cluster";
import { composeRouter } from "./routers/compose"; import { composeRouter } from "./routers/compose";
import { deploymentRouter } from "./routers/deployment"; import { deploymentRouter } from "./routers/deployment";
import { destinationRouter } from "./routers/destination"; import { destinationRouter } from "./routers/destination";
import { dnsProviderRouter } from "./routers/dns-provider";
import { dockerRouter } from "./routers/docker"; import { dockerRouter } from "./routers/docker";
import { dockerVolumeRouter } from "./routers/docker-volume"; import { dockerVolumeRouter } from "./routers/docker-volume";
import { domainRouter } from "./routers/domain"; import { domainRouter } from "./routers/domain";
@@ -69,6 +70,7 @@ export const appRouter = createTRPCRouter({
compose: composeRouter, compose: composeRouter,
deployment: deploymentRouter, deployment: deploymentRouter,
destination: destinationRouter, destination: destinationRouter,
dnsProvider: dnsProviderRouter,
docker: dockerRouter, docker: dockerRouter,
dockerVolume: dockerVolumeRouter, dockerVolume: dockerVolumeRouter,
domain: domainRouter, domain: domainRouter,
@@ -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;
}),
});
+1
View File
@@ -37,6 +37,7 @@
"@ai-sdk/mistral": "^3.0.20", "@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1097.0", "@aws-sdk/client-secrets-manager": "^3.1097.0",
"@better-auth/api-key": "1.6.23", "@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23", "@better-auth/passkey": "1.6.23",
+2 -1
View File
@@ -93,4 +93,5 @@ export type AuditResourceType =
| "application" | "application"
| "compose" | "compose"
| "network" | "network"
| "vaultProvider"; | "vaultProvider"
| "dnsProvider";
@@ -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),
});
+1
View File
@@ -8,6 +8,7 @@ export * from "./certificate";
export * from "./compose"; export * from "./compose";
export * from "./deployment"; export * from "./deployment";
export * from "./destination"; export * from "./destination";
export * from "./dns-provider";
export * from "./domain"; export * from "./domain";
export * from "./environment"; export * from "./environment";
export * from "./forward-auth"; export * from "./forward-auth";
+1
View File
@@ -16,6 +16,7 @@ export * from "./services/cluster";
export * from "./services/compose"; export * from "./services/compose";
export * from "./services/deployment"; export * from "./services/deployment";
export * from "./services/destination"; export * from "./services/destination";
export * from "./services/dns-provider";
export * from "./services/docker"; export * from "./services/docker";
export * from "./services/docker-volume"; export * from "./services/docker-volume";
export * from "./services/domain"; export * from "./services/domain";
@@ -49,6 +49,7 @@ export const statements = {
monitoring: ["read"], monitoring: ["read"],
auditLog: ["read"], auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"], vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
} as const; } as const;
/** /**
@@ -76,6 +77,7 @@ export const enterpriseOnlyResources = new Set<string>([
"monitoring", "monitoring",
"auditLog", "auditLog",
"vaultProvider", "vaultProvider",
"dnsProvider",
]); ]);
export const ac = createAccessControl(statements); export const ac = createAccessControl(statements);
@@ -116,6 +118,7 @@ export const ownerRole = ac.newRole({
monitoring: ["read"], monitoring: ["read"],
auditLog: ["read"], auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"], vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
}); });
/** /**
@@ -154,6 +157,7 @@ export const adminRole = ac.newRole({
monitoring: ["read"], monitoring: ["read"],
auditLog: ["read"], auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"], vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
}); });
/** /**
@@ -198,4 +202,6 @@ export const memberRole = ac.newRole({
auditLog: [], auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed // Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"], vaultProvider: ["read"],
// Members can see configured DNS providers to pick one when adding a domain
dnsProvider: ["read"],
}); });
@@ -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",
});
}
};
+143
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");
},
};
+14
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";
+216
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 }));
},
};
+45
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),
});
};
+296 -16
View File
@@ -115,6 +115,9 @@ importers:
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':
specifier: ^2.0.30 specifier: ^2.0.30
version: 2.0.30([email protected]) version: 2.0.30([email protected])
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-secrets-manager': '@aws-sdk/client-secrets-manager':
specifier: ^3.1097.0 specifier: ^3.1097.0
version: 3.1097.0 version: 3.1097.0
@@ -585,6 +588,9 @@ importers:
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':
specifier: ^2.0.30 specifier: ^2.0.30
version: 2.0.30([email protected]) version: 2.0.30([email protected])
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-secrets-manager': '@aws-sdk/client-secrets-manager':
specifier: ^3.1097.0 specifier: ^3.1097.0
version: 3.1097.0 version: 3.1097.0
@@ -877,6 +883,10 @@ packages:
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-EAyknLfabMWA1kAVgcBO6iTYBWFVnoX8WRrKEbPf6npNZUKv7oJoZBK+I0OvRK0o3P0ynbey64pDlspWXB9dag==} resolution: {integrity: sha512-EAyknLfabMWA1kAVgcBO6iTYBWFVnoX8WRrKEbPf6npNZUKv7oJoZBK+I0OvRK0o3P0ynbey64pDlspWXB9dag==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -884,15 +894,34 @@ packages:
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==} resolution: {integrity: sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==}
engines: {node: '>=20.0.0'} 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/[email protected]':
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==} resolution: {integrity: sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==} resolution: {integrity: sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==} resolution: {integrity: sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -901,14 +930,30 @@ packages:
resolution: {integrity: sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==} resolution: {integrity: sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==} resolution: {integrity: sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==} resolution: {integrity: sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==} resolution: {integrity: sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -917,26 +962,54 @@ packages:
resolution: {integrity: sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==} resolution: {integrity: sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-+z3OqGhqLA46vF5KbnjuygzSQo9A2cWsbuDy1rdrbWSPDCNT5CCRC+veIxRnqvxwpj/BY8P8XNz+WwCmX0e8rg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==} resolution: {integrity: sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==} resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==} resolution: {integrity: sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/[email protected]':
resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==}
engines: {node: '>=20.0.0'}
'@aws/[email protected]': '@aws/[email protected]':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3870,14 +3943,30 @@ packages:
resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==} resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==}
engines: {node: '>=18.0.0'}
'@smithy/[email protected]': '@smithy/[email protected]':
resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==} resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
engines: {node: '>=18.0.0'}
'@smithy/[email protected]': '@smithy/[email protected]':
resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==} resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==}
engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==}
engines: {node: '>=18.0.0'}
'@smithy/[email protected]': '@smithy/[email protected]':
resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==} resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3886,10 +3975,18 @@ packages:
resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==} resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
engines: {node: '>=18.0.0'}
'@smithy/[email protected]': '@smithy/[email protected]':
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/[email protected]':
resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==}
engines: {node: '>=18.0.0'}
'@stablelib/[email protected]': '@stablelib/[email protected]':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
@@ -9219,6 +9316,18 @@ snapshots:
escape-html: 1.0.3 escape-html: 1.0.3
xpath: 0.0.32 xpath: 0.0.32
'@aws-sdk/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9241,6 +9350,17 @@ snapshots:
bowser: 2.14.1 bowser: 2.14.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9249,6 +9369,14 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9259,6 +9387,32 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9277,11 +9431,20 @@ snapshots:
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.37 '@aws-sdk/nested-clients': 3.997.37
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.3
'@smithy/core': 3.31.0 '@smithy/core': 3.32.0
'@smithy/types': 4.16.1 '@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]':
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 tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
@@ -9298,6 +9461,20 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9306,6 +9483,24 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]':
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/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.2
@@ -9325,31 +9520,73 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
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/[email protected]':
dependencies:
'@aws-sdk/types': 3.974.3
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.7
'@aws-sdk/signature-v4-multi-region': 3.996.42 '@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.3
'@smithy/core': 3.31.0 '@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.6.12 '@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.9.12 '@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.16.1 '@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]':
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 tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.3
'@smithy/signature-v4': 5.6.11 '@smithy/signature-v4': 5.6.11
'@smithy/types': 4.16.1 '@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]':
dependencies:
'@aws-sdk/types': 3.974.3
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@aws-sdk/core': 3.977.2 '@aws-sdk/core': 3.977.7
'@aws-sdk/nested-clients': 3.997.37 '@aws-sdk/nested-clients': 3.997.37
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.3
'@smithy/core': 3.31.0 '@smithy/core': 3.32.0
'@smithy/types': 4.16.1 '@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]':
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 tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
@@ -9357,11 +9594,21 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/[email protected]': '@aws-sdk/[email protected]':
dependencies: dependencies:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/[email protected]':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws/[email protected]': {} '@aws/[email protected]': {}
'@babel/[email protected]': '@babel/[email protected]':
@@ -12668,18 +12915,41 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/[email protected]': '@smithy/[email protected]':
dependencies: dependencies:
'@smithy/core': 3.31.0 '@smithy/core': 3.31.0
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/[email protected]': '@smithy/[email protected]':
dependencies: dependencies:
'@smithy/core': 3.31.0 '@smithy/core': 3.31.0
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/[email protected]': '@smithy/[email protected]':
dependencies: dependencies:
'@smithy/core': 3.31.0 '@smithy/core': 3.31.0
@@ -12692,10 +12962,20 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/[email protected]': '@smithy/[email protected]':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/[email protected]':
dependencies:
tslib: 2.8.1
'@stablelib/[email protected]': {} '@stablelib/[email protected]': {}
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}