diff --git a/apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx b/apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx new file mode 100644 index 00000000..82c25d0f --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx @@ -0,0 +1,109 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Copy, HelpCircle, Server } from "lucide-react"; +import { toast } from "sonner"; + +interface Props { + domain: { + host: string; + https: boolean; + path?: string; + }; + serverIp?: string; +} + +export const DnsHelperModal = ({ domain, serverIp }: Props) => { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard!"); + }; + + return ( + + + + + + + + + DNS Configuration Guide + + + Follow these steps to configure your DNS records for {domain.host} + + + +
+ + To make your domain accessible, you need to configure your DNS + records with your domain provider (e.g., Cloudflare, GoDaddy, + NameCheap). + + +
+
+

1. Add A Record

+
+

+ Create an A record that points your domain to the server's IP + address: +

+
+
+
+

Type: A

+

+ Name: @ or {domain.host.split(".")[0]} +

+

+ Value: {serverIp || "Your server IP"} +

+
+ +
+
+
+
+ +
+

2. Verify Configuration

+
+

+ After configuring your DNS records: +

+
    +
  • Wait for DNS propagation (usually 15-30 minutes)
  • +
  • + Test your domain by visiting:{" "} + {domain.https ? "https://" : "http://"} + {domain.host} + {domain.path || "/"} +
  • +
  • Use a DNS lookup tool to verify your records
  • +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx b/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx index e6468d6f..d6c0c332 100644 --- a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx @@ -7,17 +7,54 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; -import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react"; +import { + ExternalLink, + GlobeIcon, + PenBoxIcon, + Trash2, + InfoIcon, + Server, + CheckCircle2, + XCircle, + Loader2, + RefreshCw, +} from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; import { AddDomainCompose } from "./add-domain"; +import { Badge } from "@/components/ui/badge"; +import { DnsHelperModal } from "./dns-helper-modal"; +import { useState } from "react"; interface Props { composeId: string; } +type ValidationState = { + isLoading: boolean; + isValid?: boolean; + error?: string; + resolvedIp?: string; +}; + +type ValidationStates = { + [key: string]: ValidationState; +}; + export const ShowDomainsCompose = ({ composeId }: Props) => { + const [validationStates, setValidationStates] = useState( + {}, + ); + + const { data: ip } = api.settings.getIp.useQuery(); + const { data, refetch } = api.domain.byComposeId.useQuery( { composeId, @@ -27,11 +64,57 @@ export const ShowDomainsCompose = ({ composeId }: Props) => { }, ); + const { data: compose } = api.compose.one.useQuery( + { + composeId, + }, + { + enabled: !!composeId, + }, + ); + + const { mutateAsync: validateDomain } = + api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isLoading: isRemoving } = api.domain.delete.useMutation(); + const handleValidateDomain = async (host: string) => { + setValidationStates((prev) => ({ + ...prev, + [host]: { isLoading: true }, + })); + + try { + const result = await validateDomain({ + domain: host, + serverIp: + compose?.server?.ipAddress?.toString() || ip?.toString() || "", + }); + + setValidationStates((prev) => ({ + ...prev, + [host]: { + isLoading: false, + isValid: result.isValid, + error: result.error, + resolvedIp: result.resolvedIp, + }, + })); + } catch (err) { + const error = err as Error; + setValidationStates((prev) => ({ + ...prev, + [host]: { + isLoading: false, + isValid: false, + error: error.message || "Failed to validate domain", + }, + })); + } + }; + return ( -
+
@@ -45,100 +128,233 @@ export const ShowDomainsCompose = ({ composeId }: Props) => { {data && data?.length > 0 && ( )}
- + {data?.length === 0 ? ( -
+
- + To access to the application it is required to set at least 1 domain
) : ( -
+
{data?.map((item) => { + const validationState = validationStates[item.host]; return ( -
-
- - {item.serviceName} - + +
+ {/* Service & Domain Info */} +
+
+ + + {item.serviceName} + + + {item.host} + + +
+
+ {!item.host.includes("traefik.me") && ( + + )} + + + + { + await deleteDomain({ + domainId: item.domainId, + }) + .then((_data) => { + refetch(); + toast.success( + "Domain deleted successfully", + ); + }) + .catch(() => { + toast.error("Error deleting domain"); + }); + }} + > + + +
+
- - {item.host} - - -
+ {/* Domain Details */} +
+ + + + + + Path: {item.path || "/"} + + + +

URL path for this service

+
+
+
-
-
- {item.path} - {item.port} - {item.https ? "HTTPS" : "HTTP"} + + + + + + Port: {item.port} + + + +

Container port exposed

+
+
+
+ + + + + + {item.https ? "HTTPS" : "HTTP"} + + + +

+ {item.https + ? "Secure HTTPS connection" + : "Standard HTTP connection"} +

+
+
+
+ + {item.certificateType && ( + + + + + Cert: {item.certificateType} + + + +

SSL Certificate Provider

+
+
+
+ )} + + + + + + handleValidateDomain(item.host) + } + > + {validationState?.isLoading ? ( + <> + + Checking DNS... + + ) : validationState?.isValid ? ( + <> + + {"DNS Valid"} + + ) : validationState?.error ? ( + <> + + {validationState.error} + + ) : ( + <> + + Validate DNS + + )} + + + + {validationState?.error ? ( +
+

+ Error: +

+

{validationState.error}

+
+ ) : ( + "Click to validate DNS configuration" + )} +
+
+
+
- -
- - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success("Domain deleted successfully"); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - -
-
-
+ + ); })}
diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 9e81bee1..2ade32ae 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -21,6 +21,7 @@ import { removeDomain, removeDomainById, updateDomainById, + validateDomain, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -224,4 +225,15 @@ export const domainRouter = createTRPCRouter({ return result; }), + + validateDomain: protectedProcedure + .input( + z.object({ + domain: z.string(), + serverIp: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + return validateDomain(input.domain, input.serverIp); + }), }); diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index da2d6bc4..4035b567 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -7,6 +7,8 @@ import { type apiCreateDomain, domains } from "../db/schema"; import { findUserById } from "./admin"; import { findApplicationById } from "./application"; import { findServerById } from "./server"; +import dns from "node:dns"; +import { promisify } from "node:util"; export type Domain = typeof domains.$inferSelect; @@ -137,3 +139,85 @@ export const removeDomainById = async (domainId: string) => { export const getDomainHost = (domain: Domain) => { return `${domain.https ? "https" : "http"}://${domain.host}`; }; + +const resolveDns = promisify(dns.resolve4); + +// Cloudflare IP ranges (simplified - these are some common ones) +const CLOUDFLARE_IPS = [ + "172.67.", + "104.21.", + "104.16.", + "104.17.", + "104.18.", + "104.19.", + "104.20.", + "104.22.", + "104.23.", + "104.24.", + "104.25.", + "104.26.", + "104.27.", + "104.28.", +]; + +const isCloudflareIp = (ip: string) => { + return CLOUDFLARE_IPS.some((range) => ip.startsWith(range)); +}; + +export const validateDomain = async ( + domain: string, + expectedIp?: string, +): Promise<{ + isValid: boolean; + resolvedIp?: string; + error?: string; + isCloudflare?: boolean; +}> => { + try { + // Remove protocol and path if present + const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; + + // Resolve the domain to get its IP + const ips = await resolveDns(cleanDomain || ""); + + const resolvedIp = ips[0]; + + // Check if it's a Cloudflare IP + const behindCloudflare = ips.some((ip) => isCloudflareIp(ip)); + + // If behind Cloudflare, we consider it valid but inform the user + if (behindCloudflare) { + return { + isValid: true, + resolvedIp, + isCloudflare: true, + error: + "Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy", + }; + } + + // If we have an expected IP, validate against it + if (expectedIp) { + return { + isValid: resolvedIp === expectedIp, + resolvedIp, + error: + resolvedIp !== expectedIp + ? `Domain resolves to ${resolvedIp} but should point to ${expectedIp}` + : undefined, + }; + } + + // If no expected IP, just return the resolved IP + return { + isValid: true, + resolvedIp, + }; + } catch (error) { + return { + isValid: false, + error: + error instanceof Error ? error.message : "Failed to resolve domain", + }; + } +};