diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index 356d429a4..f2abbc4bb 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -87,6 +87,154 @@ describe("cloudflareClient.listRecords", () => { }); }); +describe("cloudflareClient MX priority", () => { + it("inlines the priority into the content when listing", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "mx-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + ttl: 300, + priority: 20, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.content).toBe("20 mail.example.com"); + }); + + it("splits the priority back out when writing", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "20 mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 20, + }); + }); + + it("falls back to priority 10 when the content has no leading number", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 10, + }); + }); + + it("leaves non-MX content untouched", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "10 not-a-priority", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.content).toBe("10 not-a-priority"); + expect(body.priority).toBeUndefined(); + }); +}); + +describe("cloudflareClient proxy status", () => { + it("sends the proxy status for proxiable types", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBe(true); + }); + + it("omits the proxy status for types Cloudflare cannot proxy", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "hello", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBeUndefined(); + }); + + it("leaves the proxy status untouched when the caller does not set it", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect("proxied" in JSON.parse(init.body as string)).toBe(false); + }); + + it("returns the proxy status when listing records", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "a-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + proxied: true, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.proxied).toBe(true); + }); +}); + describe("cloudflareClient.upsertRecord", () => { it("creates a record when none exists for the name/type", async () => { mockFetch diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx new file mode 100644 index 000000000..20accf405 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx @@ -0,0 +1,32 @@ +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +let lastDepth: number | null = null; + +const depthOf = (path: string) => + path.split("?")[0]!.split("/").filter(Boolean).length; + +export const DnsPageTransition = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { asPath } = useRouter(); + const depth = depthOf(asPath); + const [direction] = useState(() => + lastDepth !== null && depth < lastDepth ? "back" : "forward", + ); + + useEffect(() => { + lastDepth = depth; + }, [depth]); + + return ( +
+ {children} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx new file mode 100644 index 000000000..3f06581a5 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -0,0 +1,375 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Cloud, CloudOff, XIcon } from "lucide-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 { + 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 { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +export const DNS_RECORD_TYPES = [ + "A", + "AAAA", + "CNAME", + "MX", + "TXT", + "NS", + "SRV", + "CAA", + "PTR", +] as const; + +type RecordType = (typeof DNS_RECORD_TYPES)[number]; + +export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"]; + +const valueFields: Record< + RecordType, + { label: string; placeholder: string; hint?: string } +> = { + A: { label: "IPv4 address", placeholder: "203.0.113.10" }, + AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" }, + CNAME: { label: "Target", placeholder: "app.example.com" }, + MX: { + label: "Mail server", + placeholder: "10 mail.example.com", + hint: "Start with the priority, then the mail server.", + }, + TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" }, + NS: { label: "Nameserver", placeholder: "ns1.example.com" }, + SRV: { + label: "Target", + placeholder: "1 10 5269 talk.example.com", + hint: "Priority, weight, port, then target.", + }, + CAA: { + label: "Value", + placeholder: '0 issue "letsencrypt.org"', + hint: "Flags, tag, then the quoted value.", + }, + PTR: { label: "Target", placeholder: "host.example.com" }, +}; + +const DnsRecordSchema = z.object({ + type: z.enum(DNS_RECORD_TYPES), + name: z.string().min(1, { message: "Name is required" }), + content: z.string().min(1, { message: "Content is required" }), + ttl: z.string(), + proxied: z.boolean(), +}); + +type DnsRecordForm = z.infer; + +export interface DnsRecordValue { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied?: boolean; +} + +interface Props { + dnsProviderId: string; + zoneId: string; + zoneName: string; + record: DnsRecordValue | null; + onClose: () => void; +} + +export const DnsRecordPanel = ({ + dnsProviderId, + zoneId, + zoneName, + record, + onClose, +}: Props) => { + const utils = api.useUtils(); + const createRecord = api.dnsProvider.createRecord.useMutation(); + const updateRecord = api.dnsProvider.updateRecord.useMutation(); + const { mutateAsync, isPending, error, isError } = record + ? updateRecord + : createRecord; + + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data: servers } = api.server.all.useQuery(); + const { data: panelPublicIp } = api.server.publicIp.useQuery(); + const { data: panelStoredIp } = api.settings.getIp.useQuery(); + + 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({ + defaultValues: record + ? { + type: (DNS_RECORD_TYPES.includes(record.type as RecordType) + ? record.type + : "A") as RecordType, + name: record.name, + content: record.content, + ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "", + proxied: record.proxied ?? false, + } + : { type: "A", name: "", content: "", ttl: "", proxied: false }, + resolver: zodResolver(DnsRecordSchema), + }); + + const type = form.watch("type"); + const proxied = form.watch("proxied"); + const canProxy = + provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type); + const usesAutomaticTtl = canProxy && proxied; + + const onSubmit = async (data: DnsRecordForm) => { + const name = data.name.trim() === "@" ? zoneName : data.name; + const isProxied = canProxy && data.proxied; + const payload = { + dnsProviderId, + zoneId, + type: data.type, + name, + content: data.content, + ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined, + ...(canProxy && { proxied: data.proxied }), + ...(record && { recordId: record.id }), + }; + await mutateAsync(payload as any) + .then(() => { + toast.success(record ? "Record updated" : "Record created"); + utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); + onClose(); + }) + .catch(() => {}); + }; + + return ( +
+
+
+ + {record ? "Edit record" : "New record"} + + {zoneName} +
+ +
+ + {isError && {error?.message}} + +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + {valueFields[type].label} + + + + {valueFields[type].hint && ( + {valueFields[type].hint} + )} + + + )} + /> + {canProxy && ( + ( + + Proxy status +
+ Proxy status +
+ + {field.value + ? "Traffic runs through Cloudflare and the origin IP stays hidden." + : "Cloudflare only answers the DNS query; traffic reaches the origin directly."} + +
+ )} + /> + )} + ( + + TTL (optional) + + + + {usesAutomaticTtl && ( + + Proxied records always use automatic TTL. + + )} + + + )} + /> +
+ + +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx new file mode 100644 index 000000000..2673bd0c7 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +const recordTypeStyles: Record = { + A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300", + AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300", + CNAME: + "bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300", + MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300", + TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300", + NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300", + SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300", + CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300", + PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300", + SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300", +}; + +interface Props { + type: string; + className?: string; +} + +export const DnsRecordTypeBadge = ({ type, className }: Props) => ( + + {type.toUpperCase()} + +); diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index 67d6a7547..166b00c3e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -33,6 +33,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; const providerLabels = { @@ -180,22 +185,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { return ( - - {dnsProviderId ? ( - - ) : ( - + + + Edit provider + + ) : ( + + - )} - + + )} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx deleted file mode 100644 index 00ab06074..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx +++ /dev/null @@ -1,271 +0,0 @@ -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; - -interface DnsRecordValue { - id: string; - type: string; - name: string; - content: string; - ttl: number; -} - -interface Props { - dnsProviderId: string; - zoneId: string; - zoneName: string; - record?: DnsRecordValue; -} - -export const HandleDnsRecord = ({ - dnsProviderId, - zoneId, - zoneName, - 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({ - 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 name = data.name.trim() === "@" ? zoneName : data.name; - const payload = { - dnsProviderId, - zoneId, - type: data.type, - 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 ( - - - {record ? ( - - ) : ( - - )} - - - - {record ? "Edit Record" : "Add Record"} - - {record - ? "Update this DNS record." - : "Create a new A or CNAME record in this zone."} - - - {isError && {error?.message}} -
- - ( - - Type - - - - )} - /> - ( - - Name - - - - - Use @ for the root domain. - - - - )} - /> - {type === "A" && ipSuggestions.length > 0 && ( - - Fill from server (optional) - - - )} - ( - - - {type === "A" ? "IPv4 Address" : "Target"} - - - - - - - )} - /> - ( - - TTL (optional) - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx deleted file mode 100644 index 2ca61abfa..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx +++ /dev/null @@ -1,218 +0,0 @@ -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; - zoneName: string; -} - -const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: 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 ( -
- {isLoading && ( -
- Loading records... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No records found in this zone. -

- )} - {data?.map((record) => { - const isEditable = record.type === "A" || record.type === "CNAME"; - return ( -
- - {record.type} - - {record.name} - - → {record.content} - - {canWrite && isEditable && ( - - )} - {canDelete && ( - { - 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"); - }); - }} - > - - - )} -
- ); - })} - {canWrite && ( -
- -
- )} -
- ); -}; - -interface Props { - dnsProviderId: string; - providerName: string; -} - -export const ShowDnsProviderZones = ({ - dnsProviderId, - providerName, -}: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [expandedZoneId, setExpandedZoneId] = useState(null); - - const { data, isLoading, isError, error } = - api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen }); - - return ( - { - setIsOpen(open); - if (!open) { - setExpandedZoneId(null); - } - }} - > - - - - - - Domains for {providerName} - - Zones this provider's API token can manage. Click a zone to see and - manage its records. - - - {isLoading && ( -
- Loading... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No zones found for this token. Make sure it has access to at least - one zone. -

- )} - {data && data.length > 0 && ( -
- {data.map((zone) => { - const isExpanded = expandedZoneId === zone.id; - return ( -
- - {isExpanded && ( - - )} -
- ); - })} -
- )} -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx index 9797ab93d..4d511ff35 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -1,8 +1,9 @@ -import { Globe, Loader2, Trash2 } from "lucide-react"; +import { EyeIcon, Globe, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useState } from "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, @@ -11,9 +12,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { HandleDnsProvider } from "./handle-dns-provider"; -import { ShowDnsProviderZones } from "./show-dns-provider-zones"; const providerLabels: Record = { cloudflare: "Cloudflare", @@ -21,121 +27,143 @@ const providerLabels: Record = { }; export const ShowDnsProviders = () => { - const { mutateAsync, isPending: isRemoving } = - api.dnsProvider.remove.useMutation(); + const [removingId, setRemovingId] = useState(null); + const { mutateAsync } = api.dnsProvider.remove.useMutation(); const { data, isPending, refetch } = api.dnsProvider.all.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); return (
- +
- - - - DNS Providers - - - Connect a DNS provider so Dokploy can create the A/CNAME record - for a domain instead of you setting it up by hand. - - - +
+ + + + DNS Providers + + + Connect a DNS provider so Dokploy can create the A/CNAME record + for a domain instead of you setting it up by hand. + + + {permissions?.dnsProvider.create && } +
+ + {isPending ? ( -
- Loading... - +
+ {[0, 1, 2].map((row) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+ + + No DNS providers connected + + + Add Cloudflare or Route53 credentials to manage domain records + without leaving Dokploy. +
) : ( - <> - {data?.length === 0 ? ( -
- - - You don't have any DNS providers configured - - {permissions?.dnsProvider.create && } -
- ) : ( -
-
- {data?.map((provider) => { - const ProviderIcon = - dnsProviderIcons[provider.providerType]; - return ( -
-
-
- -
- - {provider.name} - - - {providerLabels[provider.providerType] ?? - provider.providerType} - -
-
- -
- - {permissions?.dnsProvider.update && ( - - )} - {permissions?.dnsProvider.delete && ( - { - await mutateAsync({ - dnsProviderId: provider.dnsProviderId, - }) - .then(() => { - toast.success("DNS provider deleted"); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting the DNS provider", - ); - }); - }} - > - - - )} -
-
-
- ); - })} -
- - {permissions?.dnsProvider.create && ( -
- +
    + {data?.map((provider) => { + const ProviderIcon = dnsProviderIcons[provider.providerType]; + const href = `/dashboard/settings/dns/${provider.dnsProviderId}`; + return ( +
  • + + +
    + + {provider.name} + + + {providerLabels[provider.providerType] ?? + provider.providerType} +
    - )} -
- )} - + +
+ + + + + View domains + + + {permissions?.dnsProvider.update && ( + + )} + {permissions?.dnsProvider.delete && ( + + { + setRemovingId(provider.dnsProviderId); + await mutateAsync({ + dnsProviderId: provider.dnsProviderId, + }) + .then(() => { + toast.success("DNS provider deleted"); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting the DNS provider", + ); + }) + .finally(() => setRemovingId(null)); + }} + > + + + + + Delete provider + + )} +
+ + ); + })} + )}
diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx new file mode 100644 index 000000000..9431545dc --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx @@ -0,0 +1,566 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowLeft, + ArrowUpDown, + Cloud, + CloudOff, + ListTree, + PenBoxIcon, + PlusIcon, + Search, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { + DNS_RECORD_TYPES, + DnsRecordPanel, + type DnsRecordValue, + PROXIABLE_TYPES, +} from "./dns-record-panel"; +import { DnsRecordTypeBadge } from "./dns-record-type-badge"; + +interface Props { + dnsProviderId: string; + zoneId: string; +} + +const PAGE_SIZES = [10, 20, 50, 100]; + +const SortableHeader = ({ + column, + title, + className, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; + className?: string; +}) => ( + +); + +export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { + const utils = api.useUtils(); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [sorting, setSorting] = useState([ + { id: "name", desc: false }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId }); + const { data, isPending, isError, error } = + api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId }); + const { data: permissions } = api.user.getPermissions.useQuery(); + const { mutateAsync: deleteRecord } = + api.dnsProvider.deleteRecord.useMutation(); + + const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? ""; + const isCloudflare = provider?.providerType === "cloudflare"; + const canWrite = !!permissions?.dnsProvider.update; + const canDelete = !!permissions?.dnsProvider.delete; + + const openEdit = (record: DnsRecordValue) => { + setEditing(record); + setIsPanelOpen(true); + }; + + const availableTypes = useMemo( + () => [...new Set((data ?? []).map((record) => record.type))].sort(), + [data], + ); + + const filteredRecords = useMemo(() => { + const query = search.trim().toLowerCase(); + return (data ?? []).filter((record) => { + if (typeFilter !== "all" && record.type !== typeFilter) return false; + if (!query) return true; + return ( + record.name.toLowerCase().includes(query) || + record.content.toLowerCase().includes(query) || + record.type.toLowerCase().includes(query) + ); + }); + }, [data, search, typeFilter]); + + const handleDelete = async (record: DnsRecordValue) => { + setDeletingId(record.id); + await deleteRecord({ dnsProviderId, zoneId, recordId: record.id }) + .then(() => { + toast.success("Record deleted"); + utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); + if (editing?.id === record.id) setIsPanelOpen(false); + }) + .catch(() => { + toast.error("Error deleting the record"); + }) + .finally(() => setDeletingId(null)); + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: "content", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.content} + + ), + }, + { + accessorKey: "ttl", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.ttl === 1 ? "Auto" : row.original.ttl} + + ), + }, + ...(isCloudflare + ? [ + { + accessorKey: "proxied", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (!PROXIABLE_TYPES.includes(row.original.type)) { + return null; + } + const proxied = !!row.original.proxied; + return ( + + + + {proxied ? ( + + ) : ( + + )} + + {proxied ? "Proxied" : "DNS only"} + + + + + {proxied ? "Proxied" : "DNS only"} + + + ); + }, + } satisfies ColumnDef, + ] + : []), + { + id: "actions", + enableSorting: false, + header: () => Actions, + cell: ({ row }) => { + const record = row.original; + const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes( + record.type, + ); + return ( +
+ {canWrite && isEditable && ( + + + + + Edit record + + )} + {canDelete && ( + + handleDelete(record)} + > + + + + + Delete record + + )} +
+ ); + }, + }, + ], + [canWrite, canDelete, deletingId, editing?.id, isCloudflare], + ); + + const table = useReactTable({ + data: filteredRecords, + columns, + getRowId: (row) => row.id, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+
+
+ + + + {zoneName || "DNS records"} + + + Records managed through {provider?.name ?? "this provider"}. + Changes are written straight to the provider. + + +
+ {canWrite && ( + + )} +
+ + + {isError && {error?.message}} +
+
+ {isPending ? ( + + ) : data?.length === 0 ? ( +
+
+ +
+
+

+ No records in this zone +

+

+ Add an A or CNAME record to point this domain at one of + your servers. +

+
+
+ ) : ( + <> +
+
+ setSearch(e.target.value)} + className="pr-10" + /> + +
+ + + {filteredRecords.length} of {data?.length ?? 0} + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => { + const isEditable = ( + DNS_RECORD_TYPES as readonly string[] + ).includes(row.original.type); + const isSelected = + isPanelOpen && editing?.id === row.original.id; + return ( + openEdit(row.original) + : undefined + } + className={cn( + "duration-150 ease-out", + canWrite && isEditable && "cursor-pointer", + )} + > + {row.getVisibleCells().map((cell) => ( + event.stopPropagation() + : undefined + } + className="px-4 py-2" + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + }) + ) : ( + + + No records match your filters. + + + )} + +
+
+ +
+
+ + Rows per page + + +
+
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {Math.max(table.getPageCount(), 1)} + +
+ + +
+
+
+ + )} +
+ +
+
+
+ {canWrite && ( + setIsPanelOpen(false)} + /> + )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx new file mode 100644 index 000000000..839c2083b --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx @@ -0,0 +1,134 @@ +import { ArrowLeft, Globe } from "lucide-react"; +import Link from "next/link"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { api } from "@/utils/api"; + +interface Props { + dnsProviderId: string; +} + +const RecordCount = ({ + dnsProviderId, + zoneId, +}: { + dnsProviderId: string; + zoneId: string; +}) => { + const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({ + dnsProviderId, + zoneId, + }); + + if (isPending) { + return ; + } + + if (isError) { + return ( + Records unavailable + ); + } + + return ( + + {data.length === 0 + ? "No records" + : `${data.length} record${data.length === 1 ? "" : "s"}`} + + ); +}; + +export const ShowDnsZones = ({ dnsProviderId }: Props) => { + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data, isPending, isError, error } = + api.dnsProvider.listZones.useQuery({ dnsProviderId }); + + return ( +
+ +
+
+
+ + + + {provider?.name ?? "Domains"} + + + Domains this provider's credentials can manage. Open one to + see and edit its DNS records. + + +
+
+ + + {isError && {error?.message}} + {isPending ? ( +
+ {[0, 1, 2, 3, 4, 5].map((card) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+
+ +
+
+

No domains found

+

+ These credentials can't reach any zone. Check that the token + has access to at least one domain. +

+
+
+ ) : ( +
+ {data?.map((zone) => ( + +
+ + + + + {zone.name} + +
+
+ +
+ + ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/settings/dns.tsx b/apps/dokploy/pages/dashboard/settings/dns.tsx index 60aaae76e..0600117b1 100644 --- a/apps/dokploy/pages/dashboard/settings/dns.tsx +++ b/apps/dokploy/pages/dashboard/settings/dns.tsx @@ -3,15 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server"; import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import superjson from "superjson"; +import { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; 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 ( -
+ -
+ ); }; diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx new file mode 100644 index 000000000..105b6d378 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx @@ -0,0 +1,62 @@ +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 { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; + +interface Props { + dnsProviderId: string; +} + +const Page = ({ dnsProviderId }: Props) => { + return ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ dnsProviderId: string }>, +) { + const { req, res, params } = ctx; + const { user, session } = await validateRequest(req); + if (!user || user.role === "member" || !params?.dnsProviderId) { + 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(), + dnsProviderId: params.dnsProviderId, + }, + }; +} diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx new file mode 100644 index 000000000..c307738f1 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx @@ -0,0 +1,69 @@ +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 { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; + +interface Props { + dnsProviderId: string; + zoneId: string; +} + +const Page = ({ dnsProviderId, zoneId }: Props) => { + return ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>, +) { + const { req, res, params } = ctx; + const { user, session } = await validateRequest(req); + if ( + !user || + user.role === "member" || + !params?.dnsProviderId || + !params?.zoneId + ) { + 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(), + dnsProviderId: params.dnsProviderId, + zoneId: params.zoneId, + }, + }; +} diff --git a/apps/dokploy/server/api/routers/dns-provider.ts b/apps/dokploy/server/api/routers/dns-provider.ts index 673c91a21..88796101d 100644 --- a/apps/dokploy/server/api/routers/dns-provider.ts +++ b/apps/dokploy/server/api/routers/dns-provider.ts @@ -162,6 +162,7 @@ export const dnsProviderRouter = createTRPCRouter({ name: input.name, content: input.content, ttl: input.ttl, + proxied: input.proxied, }); await audit(ctx, { action: "create", @@ -188,6 +189,7 @@ export const dnsProviderRouter = createTRPCRouter({ name: input.name, content: input.content, ttl: input.ttl, + proxied: input.proxied, }, ); await audit(ctx, { diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 016513952..4dcc3d697 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -531,3 +531,98 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] { --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } + +:root { + --duration-quick: 150ms; + --duration-fast: 250ms; + --duration-medium: 350ms; + --duration-slow: 400ms; + --ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1); + --distance-base: 8px; + --blur-medium: 3px; + --panel-open-dur: 400ms; + --panel-close-dur: 350ms; + --panel-translate-x: 32px; + --panel-blur: 2px; + --panel-ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +@layer components { + .t-panel-slide-x { + transform: translateX(var(--panel-translate-x)); + opacity: 0; + filter: blur(var(--panel-blur)); + pointer-events: none; + transition: + transform var(--panel-close-dur) var(--panel-ease), + opacity var(--panel-close-dur) var(--panel-ease), + filter var(--panel-close-dur) var(--panel-ease); + will-change: transform, opacity, filter; + } + + .t-panel-slide-x[data-open="true"] { + transform: translateX(0); + opacity: 1; + filter: blur(0); + pointer-events: auto; + transition: + transform var(--panel-open-dur) var(--panel-ease), + opacity var(--panel-open-dur) var(--panel-ease), + filter var(--panel-open-dur) var(--panel-ease); + } + + .t-panel-track { + transition: + grid-template-columns var(--panel-open-dur) var(--panel-ease), + grid-template-rows var(--panel-open-dur) var(--panel-ease); + } + + @media (prefers-reduced-motion: reduce) { + .t-panel-slide-x, + .t-panel-track { + transition: none !important; + } + } +} + +@layer components { + .t-page-enter { + animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both; + } + + .t-page-enter[data-direction="back"] { + animation-name: t-page-enter-back; + } + + @keyframes t-page-enter { + from { + opacity: 0; + transform: translateX(var(--distance-base)); + filter: blur(var(--blur-medium)); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } + } + + @keyframes t-page-enter-back { + from { + opacity: 0; + transform: translateX(calc(var(--distance-base) * -1)); + filter: blur(var(--blur-medium)); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } + } + + @media (prefers-reduced-motion: reduce) { + .t-page-enter { + animation: none; + } + } +} diff --git a/packages/server/src/db/schema/dns-provider.ts b/packages/server/src/db/schema/dns-provider.ts index 50677e646..fa2135173 100644 --- a/packages/server/src/db/schema/dns-provider.ts +++ b/packages/server/src/db/schema/dns-provider.ts @@ -96,11 +96,28 @@ export const apiListDnsRecords = z.object({ zoneId: z.string().min(1), }); +export const dnsRecordTypes = [ + "A", + "AAAA", + "CNAME", + "MX", + "TXT", + "NS", + "SRV", + "CAA", + "PTR", +] as const; + +export type DnsRecordType = (typeof dnsRecordTypes)[number]; + +export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const; + const dnsRecordFieldsSchema = z.object({ - type: z.enum(["A", "CNAME"]), + type: z.enum(dnsRecordTypes), name: z.string().min(1), content: z.string().min(1), ttl: z.number().int().positive().optional(), + proxied: z.boolean().optional(), }); export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({ diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 9fedd00e5..51eeaee31 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -1,4 +1,7 @@ -import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema"; +import { + type cloudflareDnsConfigSchema, + proxiableDnsRecordTypes, +} from "@dokploy/server/db/schema"; import type { z } from "zod"; import { type DnsClient, dnsFetch } from "./types"; @@ -13,6 +16,31 @@ type CloudflareResponse = { const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; +const inlinePriority = (record: { + type: string; + content: string; + priority?: number; +}) => + record.type === "MX" && typeof record.priority === "number" + ? `${record.priority} ${record.content}` + : record.content; + +const proxySettings = (record: { type: string; proxied?: boolean }) => + record.proxied !== undefined && + (proxiableDnsRecordTypes as readonly string[]).includes(record.type) + ? { proxied: record.proxied } + : {}; + +const splitPriority = (record: { type: string; content: string }) => { + if (record.type !== "MX") { + return { content: record.content }; + } + const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); + return match + ? { content: match[2] as string, priority: Number(match[1]) } + : { content: record.content.trim(), priority: 10 }; +}; + const cfFetch = async ( config: CloudflareConfig, path: string, @@ -72,9 +100,20 @@ export const cloudflareClient: DnsClient = { name: string; content: string; ttl: number; + priority?: number; + proxied?: boolean; }[] >(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`); - records.push(...result); + records.push( + ...result.map((record) => ({ + id: record.id, + type: record.type, + name: record.name, + content: inlinePriority(record), + ttl: record.ttl, + proxied: record.proxied, + })), + ); if (result.length < 50) { break; } @@ -92,7 +131,8 @@ export const cloudflareClient: DnsClient = { const payload = { type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }; @@ -123,7 +163,8 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }), }, diff --git a/packages/server/src/utils/dns/route53.ts b/packages/server/src/utils/dns/route53.ts index 112f56689..c00bfbd68 100644 --- a/packages/server/src/utils/dns/route53.ts +++ b/packages/server/src/utils/dns/route53.ts @@ -5,7 +5,10 @@ import { type ResourceRecordSet, Route53Client, } from "@aws-sdk/client-route-53"; -import type { route53DnsConfigSchema } from "@dokploy/server/db/schema"; +import type { + DnsRecordType, + route53DnsConfigSchema, +} from "@dokploy/server/db/schema"; import type { z } from "zod"; import type { DnsClient } from "./types"; @@ -66,13 +69,13 @@ const findExactRecordSet = async ( }; const buildRecordSet = (record: { - type: "A" | "CNAME"; + type: DnsRecordType; name: string; content: string; ttl?: number; }): ResourceRecordSet => ({ Name: ensureTrailingDot(record.name), - Type: record.type, + Type: record.type as ResourceRecordSet["Type"], TTL: record.ttl ?? 300, ResourceRecords: [{ Value: record.content }], }); diff --git a/packages/server/src/utils/dns/types.ts b/packages/server/src/utils/dns/types.ts index c939c2230..e7e800a2a 100644 --- a/packages/server/src/utils/dns/types.ts +++ b/packages/server/src/utils/dns/types.ts @@ -1,4 +1,7 @@ -import type { DnsProviderConfig } from "@dokploy/server/db/schema"; +import type { + DnsProviderConfig, + DnsRecordType, +} from "@dokploy/server/db/schema"; export interface DnsZone { id: string; @@ -7,10 +10,11 @@ export interface DnsZone { export interface DnsRecordInput { zoneId: string; - type: "A" | "CNAME"; + type: DnsRecordType; name: string; content: string; ttl?: number; + proxied?: boolean; } export interface DnsRecord { @@ -19,6 +23,7 @@ export interface DnsRecord { name: string; content: string; ttl: number; + proxied?: boolean; } export interface DnsClient {