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,
GalleryVerticalEnd,
GitBranch,
Globe,
House,
Key,
KeyRound,
@@ -370,6 +371,13 @@ const MENU: Menu = {
icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
},
{
isSingle: true,
title: "DNS Providers",
url: "/dashboard/settings/dns",
icon: Globe,
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
},
{
isSingle: true,
title: "S3 Destinations",
@@ -175,6 +175,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
};
/** Descriptions for each action within a resource */
@@ -447,6 +452,25 @@ const ACTION_META: Record<
},
delete: { label: "Delete", description: "Remove secret providers" },
},
dnsProvider: {
read: {
label: "Read",
description: "View configured DNS providers and their zones/records",
},
create: {
label: "Create",
description:
"Connect new DNS providers, test their connection, and create records",
},
update: {
label: "Update",
description: "Edit provider credentials and update existing records",
},
delete: {
label: "Delete",
description: "Remove DNS providers and delete their records",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -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,
"tag": "0184_wonderful_absorbing_man",
"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"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
@@ -48,6 +47,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
@@ -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 { deploymentRouter } from "./routers/deployment";
import { destinationRouter } from "./routers/destination";
import { dnsProviderRouter } from "./routers/dns-provider";
import { dockerRouter } from "./routers/docker";
import { dockerVolumeRouter } from "./routers/docker-volume";
import { domainRouter } from "./routers/domain";
@@ -69,6 +70,7 @@ export const appRouter = createTRPCRouter({
compose: composeRouter,
deployment: deploymentRouter,
destination: destinationRouter,
dnsProvider: dnsProviderRouter,
docker: dockerRouter,
dockerVolume: dockerVolumeRouter,
domain: domainRouter,
@@ -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;
}),
});