From 77b1ec473343bb4606577a631a2c1b9f766c70fa Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Wed, 30 Apr 2025 01:08:08 -0600 Subject: [PATCH] Add DnsHelperModal component for DNS configuration guidance and integrate it into ShowDomainsCompose. Enhance domain validation functionality with server IP checks and improve UI with tooltips for better user experience. --- .../compose/domains/dns-helper-modal.tsx | 109 ++++++ .../compose/domains/show-domains.tsx | 358 ++++++++++++++---- apps/dokploy/server/api/routers/domain.ts | 12 + packages/server/src/services/domain.ts | 84 ++++ 4 files changed, 492 insertions(+), 71 deletions(-) create mode 100644 apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx 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"} + + + copyToClipboard(serverIp || "")} + disabled={!serverIp} + > + + + + + + + + + 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 && ( - Add Domain + Add Domain )} - + {data?.length === 0 ? ( - + - + To access to the application it is required to set at least 1 domain - Add Domain + Add 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", + }; + } +};
+ 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"} +
+ After configuring your DNS records: +
URL path for this service
Container port exposed
+ {item.https + ? "Secure HTTPS connection" + : "Standard HTTP connection"} +
SSL Certificate Provider
+ Error: +
{validationState.error}