diff --git a/apps/dokploy/components/dashboard/proxy/add-proxy.tsx b/apps/dokploy/components/dashboard/proxy/add-proxy.tsx new file mode 100644 index 000000000..3d5eeaecd --- /dev/null +++ b/apps/dokploy/components/dashboard/proxy/add-proxy.tsx @@ -0,0 +1,516 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { HelpCircle, PlusIcon } from "lucide-react"; +import { useEffect, 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, NumberInput } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { CertificateSelector } from "./certificate-selector"; +import { TargetSelector } from "./target-selector"; +import { WildcardIndicator } from "./wildcard-indicator"; + +const proxySchema = z + .object({ + name: z.string().min(1, "Name is required"), + host: z.string().min(1, "Host is required"), + path: z.string().optional(), + targetType: z.enum(["url", "application", "compose", "service"]), + targetUrl: z.string().url().optional(), + targetId: z.string().optional(), + port: z.number().min(1).max(65535).optional(), + https: z.boolean().optional(), + certificateId: z.string().optional(), + certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), + customCertResolver: z.string().optional(), + serverId: z.string().optional(), + stripPath: z.boolean().optional(), + internalPath: z.string().optional(), + priority: z.number().optional(), + }) + .superRefine((input, ctx) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Certificate type is required when HTTPS is enabled", + }); + } + + if (input.certificateType === "custom" && !input.customCertResolver) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCertResolver"], + message: "Custom certificate resolver is required", + }); + } + + if (input.targetType === "url" && !input.targetUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetUrl"], + message: "Target URL is required when target type is URL", + }); + } + + if (input.targetType !== "url" && !input.targetId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetId"], + message: "Target ID is required when linking to service", + }); + } + + if (input.stripPath && (!input.path || input.path === "/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stripPath"], + message: + "Strip path can only be enabled when a path other than '/' is specified", + }); + } + + if ( + input.internalPath && + input.internalPath !== "/" && + !input.internalPath.startsWith("/") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["internalPath"], + message: "Internal path must start with '/'", + }); + } + }); + +type ProxyForm = z.infer; + +interface Props { + proxyId?: string; + children?: React.ReactNode; +} + +export const AddProxy = ({ proxyId, children }: Props) => { + const [open, setOpen] = useState(false); + const utils = api.useUtils(); + + const { data: proxy } = api.proxy.one.useQuery( + { proxyId: proxyId! }, + { enabled: !!proxyId }, + ); + + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const hasServers = servers && servers.length > 0; + const shouldShowServerDropdown = hasServers; + + const { mutateAsync, isError, error, isLoading } = proxyId + ? api.proxy.update.useMutation() + : api.proxy.create.useMutation(); + + const form = useForm({ + resolver: zodResolver(proxySchema), + defaultValues: { + name: "", + host: "", + path: "/", + targetType: "url", + targetUrl: "", + targetId: undefined, + port: 3000, + https: false, + certificateType: "none", + customCertResolver: undefined, + serverId: undefined, + stripPath: false, + internalPath: "/", + priority: 0, + }, + }); + + const targetType = form.watch("targetType"); + const https = form.watch("https"); + const certificateType = form.watch("certificateType"); + const host = form.watch("host"); + const isWildcard = host?.startsWith("*.") || false; + + useEffect(() => { + if (proxy) { + form.reset({ + name: proxy.name, + host: proxy.host, + path: proxy.path || "/", + targetType: proxy.targetType, + targetUrl: proxy.targetUrl || "", + targetId: proxy.targetId || undefined, + port: proxy.port || 3000, + https: proxy.https || false, + certificateId: proxy.certificateId || undefined, + certificateType: proxy.certificateType, + customCertResolver: proxy.customCertResolver || undefined, + serverId: proxy.serverId || undefined, + stripPath: proxy.stripPath || false, + internalPath: proxy.internalPath || "/", + priority: proxy.priority || 0, + }); + } + }, [form, proxy]); + + const onSubmit = async (data: ProxyForm) => { + await mutateAsync({ + ...(proxyId && { proxyId }), + ...data, + }) + .then(async () => { + toast.success(proxyId ? "Proxy Updated" : "Proxy Created"); + await utils.proxy.all.invalidate(); + setOpen(false); + }) + .catch(() => { + toast.error( + proxyId ? "Error updating the proxy" : "Error creating the proxy", + ); + }); + }; + + return ( + + + {children || ( + + )} + + + + + {proxyId ? "Edit Proxy" : "Add New Proxy"} + + + Configure a reverse proxy to route traffic to your services + + + {isError && {error?.message}} + +
+ + ( + + Proxy Name + + + + + + )} + /> + + ( + + + Host / Domain + {isWildcard && } + + + + + + Enter a domain name or wildcard pattern (e.g., *.example.com) + + + + )} + /> + + ( + + Path Prefix (Optional) + + + + + Path prefix to match (e.g., /api) + + + + )} + /> + + + + ( + + Target Port + + field.onChange(Number.parseInt(e.target.value, 10))} + /> + + + + )} + /> + + ( + +
+ Enable HTTPS + + Enable HTTPS for this proxy + +
+ + + +
+ )} + /> + + {https && ( + <> + + ( + + Certificate Type + + + + )} + /> + + {certificateType === "custom" && ( + ( + + Custom Certificate Resolver + + + + + + )} + /> + )} + + )} + + {shouldShowServerDropdown && ( + ( + + + + + + Select a Server {!isCloud && "(Optional)"} + + + + + Select the server where this proxy should be configured + + + + + + + )} + /> + )} + + ( + +
+ Strip Path + + Remove the path prefix before forwarding to target + +
+ + + +
+ )} + /> + + ( + + Internal Path (Optional) + + + + + Path to prepend when forwarding to target + + + + )} + /> + + ( + + Priority + + field.onChange(Number.parseInt(e.target.value, 10))} + /> + + + Router priority (higher = matched first) + + + + )} + /> + + + + + + +
+
+ ); +}; + diff --git a/apps/dokploy/components/dashboard/proxy/certificate-selector.tsx b/apps/dokploy/components/dashboard/proxy/certificate-selector.tsx new file mode 100644 index 000000000..1ca8fa9c2 --- /dev/null +++ b/apps/dokploy/components/dashboard/proxy/certificate-selector.tsx @@ -0,0 +1,103 @@ +import { Loader2 } from "lucide-react"; +import { UseFormReturn } from "react-hook-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; +import { WildcardIndicator } from "./wildcard-indicator"; + +interface Props { + form: UseFormReturn; + host?: string; +} + +export const CertificateSelector = ({ form, host }: Props) => { + const { data: certificates, isLoading } = api.certificates.all.useQuery(); + const certificateId = form.watch("certificateId"); + const isWildcard = host?.startsWith("*.") || false; + + // Find matching certificates for wildcard domains + const matchingCertificates = isWildcard && host + ? certificates?.filter((cert) => { + if (!cert.domains || cert.domains.length === 0) return false; + const baseDomain = host.substring(2); // Remove *. prefix + return cert.domains.some((domain) => { + if (domain.startsWith("*.")) { + const certBase = domain.substring(2); + return certBase === baseDomain; + } + return domain === baseDomain; + }); + }) + : certificates; + + return ( + ( + + + Certificate {isWildcard && } + + + + {isWildcard + ? "Select a certificate that matches this wildcard domain" + : "Select a certificate for this domain (optional)"} + + + + )} + /> + ); +}; + diff --git a/apps/dokploy/components/dashboard/proxy/proxy-list.tsx b/apps/dokploy/components/dashboard/proxy/proxy-list.tsx new file mode 100644 index 000000000..63ce7c9c1 --- /dev/null +++ b/apps/dokploy/components/dashboard/proxy/proxy-list.tsx @@ -0,0 +1,212 @@ +import { + CheckCircle2, + Edit, + ExternalLink, + GlobeIcon, + Loader2, + PlusIcon, + Server, + Trash2, + XCircle, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +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 { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { AddProxy } from "./add-proxy"; +import { WildcardIndicator } from "./wildcard-indicator"; + +export const ProxyList = () => { + const { data, isLoading, refetch } = api.proxy.all.useQuery(); + const { mutateAsync: deleteProxy, isLoading: isDeleting } = + api.proxy.delete.useMutation(); + const { mutateAsync: testProxy, isLoading: isTesting } = + api.proxy.test.useMutation(); + + const handleDelete = async (proxyId: string) => { + await deleteProxy({ proxyId }) + .then(() => { + toast.success("Proxy deleted successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error deleting proxy"); + }); + }; + + const handleTest = async (proxyId: string) => { + await testProxy({ proxyId }) + .then((result) => { + if (result.success) { + toast.success(result.message || "Proxy test successful"); + } else { + toast.error(result.message || "Proxy test failed"); + } + }) + .catch(() => { + toast.error("Error testing proxy"); + }); + }; + + return ( +
+ +
+ + + + Reverse Proxies + + + Manage reverse proxy configurations for your services + + + + {isLoading ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any proxies configured + + +
+ ) : ( +
+
+ {data?.map((proxy) => ( +
+
+
+
+
+ + {proxy.name} + + {proxy.isWildcard && ( + + )} + + {proxy.status} + +
+
+ + {proxy.host} + {proxy.path && proxy.path !== "/" && ( + • {proxy.path} + )} + {proxy.https && ( + + HTTPS + + )} +
+
+ + + {proxy.targetType === "url" + ? proxy.targetUrl + : `${proxy.targetType}: ${proxy.targetId || "N/A"}`} + +
+
+
+ +
+ + + + + + Test Proxy + + + + + + { + await handleDelete(proxy.proxyId); + }} + > + + +
+
+
+ ))} +
+ +
+ +
+
+ )} + + )} +
+
+
+
+ ); +}; + diff --git a/apps/dokploy/components/dashboard/proxy/target-selector.tsx b/apps/dokploy/components/dashboard/proxy/target-selector.tsx new file mode 100644 index 000000000..55d570313 --- /dev/null +++ b/apps/dokploy/components/dashboard/proxy/target-selector.tsx @@ -0,0 +1,186 @@ +import { Loader2 } from "lucide-react"; +import { UseFormReturn } from "react-hook-form"; +import { + 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"; + +interface Props { + form: UseFormReturn; +} + +export const TargetSelector = ({ form }: Props) => { + const targetType = form.watch("targetType"); + // Note: Applications and composes are typically accessed through projects + // For now, we'll use a simpler approach - users can enter the ID manually + // In a full implementation, you'd fetch from projects/environments + const applications: Array<{ applicationId: string; name: string }> = []; + const composes: Array<{ composeId: string; name: string }> = []; + const isLoadingApps = false; + const isLoadingComposes = false; + + return ( + <> + ( + + Target Type + + + Choose where to route traffic: URL, Application, Compose, or Service + + + + )} + /> + + {targetType === "url" && ( + ( + + Target URL + + + + + Full URL of the target service + + + + )} + /> + )} + + {targetType === "application" && ( + ( + + Application + + + + )} + /> + )} + + {targetType === "compose" && ( + ( + + Compose + + + + )} + /> + )} + + {targetType === "service" && ( + ( + + Service Name + + + + + Service name to route to (must be accessible on the network) + + + + )} + /> + )} + + ); +}; + diff --git a/apps/dokploy/components/dashboard/proxy/wildcard-indicator.tsx b/apps/dokploy/components/dashboard/proxy/wildcard-indicator.tsx new file mode 100644 index 000000000..f457d0a87 --- /dev/null +++ b/apps/dokploy/components/dashboard/proxy/wildcard-indicator.tsx @@ -0,0 +1,12 @@ +import { Asterisk } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +export const WildcardIndicator = () => { + return ( + + + Wildcard + + ); +}; + diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 45b6a7e3a..93736172b 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -144,6 +144,12 @@ const MENU: Menu = { url: "/dashboard/projects", icon: Folder, }, + { + isSingle: true, + title: "Proxies", + url: "/dashboard/proxy", + icon: Forward, + }, { isSingle: true, title: "Monitoring", diff --git a/apps/dokploy/pages/dashboard/proxy.tsx b/apps/dokploy/pages/dashboard/proxy.tsx new file mode 100644 index 000000000..c4bd08fcf --- /dev/null +++ b/apps/dokploy/pages/dashboard/proxy.tsx @@ -0,0 +1,11 @@ +import { ProxyList } from "@/components/dashboard/proxy/proxy-list"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; + +export default function ProxyPage() { + return ( + + + + ); +} + diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 63ce38d10..b6b0d95ac 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -26,6 +26,7 @@ import { portRouter } from "./routers/port"; import { postgresRouter } from "./routers/postgres"; import { previewDeploymentRouter } from "./routers/preview-deployment"; import { projectRouter } from "./routers/project"; +import { proxyRouter } from "./routers/proxy"; import { redirectsRouter } from "./routers/redirects"; import { redisRouter } from "./routers/redis"; import { registryRouter } from "./routers/registry"; @@ -64,6 +65,7 @@ export const appRouter = createTRPCRouter({ previewDeployment: previewDeploymentRouter, mounts: mountRouter, certificates: certificateRouter, + proxy: proxyRouter, settings: settingsRouter, security: securityRouter, redirects: redirectsRouter, diff --git a/apps/dokploy/server/api/routers/certificate.ts b/apps/dokploy/server/api/routers/certificate.ts index ba57ee089..bc90ffc31 100644 --- a/apps/dokploy/server/api/routers/certificate.ts +++ b/apps/dokploy/server/api/routers/certificate.ts @@ -1,16 +1,20 @@ import { createCertificate, findCertificateById, + findMatchingCertificates, IS_CLOUD, removeCertificateById, + updateCertificate, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import { z } from "zod"; import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; import { db } from "@/server/db"; import { apiCreateCertificate, apiFindCertificate, + apiUpdateCertificate, certificates, } from "@/server/db/schema"; @@ -57,4 +61,26 @@ export const certificateRouter = createTRPCRouter({ where: eq(certificates.organizationId, ctx.session.activeOrganizationId), }); }), + + update: adminProcedure + .input(apiUpdateCertificate) + .mutation(async ({ input, ctx }) => { + const certificate = await findCertificateById(input.certificateId); + if (certificate.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this certificate", + }); + } + return await updateCertificate(input); + }), + + findMatching: adminProcedure + .input(z.object({ domain: z.string().min(1) })) + .query(async ({ input, ctx }) => { + return await findMatchingCertificates( + input.domain, + ctx.session.activeOrganizationId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/proxy.ts b/apps/dokploy/server/api/routers/proxy.ts new file mode 100644 index 000000000..6290b2e99 --- /dev/null +++ b/apps/dokploy/server/api/routers/proxy.ts @@ -0,0 +1,154 @@ +import { + createProxy, + deleteProxy, + findProxyById, + IS_CLOUD, + linkToService, + listProxies, + unlinkFromService, + updateProxy, + validateProxyConfig, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateProxy, + apiDeleteProxy, + apiFindProxy, + apiLinkProxy, + apiUnlinkProxy, + apiUpdateProxy, + proxies, +} from "@/server/db/schema"; + +export const proxyRouter = createTRPCRouter({ + create: adminProcedure + .input(apiCreateProxy) + .mutation(async ({ input, ctx }) => { + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a server to create a proxy", + }); + } + return await createProxy(input, ctx.session.activeOrganizationId); + }), + + update: adminProcedure + .input(apiUpdateProxy) + .mutation(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this proxy", + }); + } + return await updateProxy(input); + }), + + delete: adminProcedure + .input(apiDeleteProxy) + .mutation(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this proxy", + }); + } + await deleteProxy(input.proxyId); + return true; + }), + + one: adminProcedure + .input(apiFindProxy) + .query(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this proxy", + }); + } + return proxy; + }), + + all: adminProcedure.query(async ({ ctx }) => { + return await listProxies(ctx.session.activeOrganizationId); + }), + + link: adminProcedure + .input(apiLinkProxy) + .mutation(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to link this proxy", + }); + } + return await linkToService( + input.proxyId, + input.targetType, + input.targetId, + ); + }), + + unlink: adminProcedure + .input(apiUnlinkProxy) + .mutation(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to unlink this proxy", + }); + } + return await unlinkFromService(input.proxyId); + }), + + validate: adminProcedure + .input( + z.union([ + apiCreateProxy, + apiUpdateProxy.extend({ proxyId: z.string().optional() }), + ]), + ) + .query(async ({ input }) => { + return validateProxyConfig(input); + }), + + test: adminProcedure + .input(apiFindProxy) + .mutation(async ({ input, ctx }) => { + const proxy = await findProxyById(input.proxyId); + if (proxy.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to test this proxy", + }); + } + + // Basic connectivity test - in a real implementation, this would + // attempt to connect to the target URL and verify it's reachable + // For now, we'll just return a success status + try { + if (proxy.targetUrl) { + const url = new URL(proxy.targetUrl); + // Could add actual HTTP request here + return { success: true, message: "Proxy target is reachable" }; + } + return { success: true, message: "Proxy configuration is valid" }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }; + } + }), +}); + diff --git a/packages/server/src/db/schema/certificate.ts b/packages/server/src/db/schema/certificate.ts index bf72f7db3..bb50c44f0 100644 --- a/packages/server/src/db/schema/certificate.ts +++ b/packages/server/src/db/schema/certificate.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm"; -import { boolean, pgTable, text } from "drizzle-orm/pg-core"; +import { boolean, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; @@ -7,6 +7,13 @@ import { organization } from "./account"; import { server } from "./server"; import { generateAppName } from "./utils"; +export const renewalStatus = pgEnum("renewalStatus", [ + "pending", + "success", + "failed", + "not_configured", +]); + export const certificates = pgTable("certificate", { certificateId: text("certificateId") .notNull() @@ -20,12 +27,25 @@ export const certificates = pgTable("certificate", { .$defaultFn(() => generateAppName("certificate")) .unique(), autoRenew: boolean("autoRenew"), + domains: text("domains").array(), + expiresAt: timestamp("expiresAt"), + issuer: text("issuer"), + subject: text("subject"), + isWildcard: boolean("isWildcard").default(false), + autoRenewEnabled: boolean("autoRenewEnabled").default(false), + renewalStatus: renewalStatus("renewalStatus").default("not_configured"), organizationId: text("organizationId") .notNull() .references(() => organization.id, { onDelete: "cascade" }), serverId: text("serverId").references(() => server.serverId, { onDelete: "cascade", }), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updatedAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), }); export const certificatesRelations = relations(certificates, ({ one }) => ({ @@ -45,6 +65,13 @@ export const apiCreateCertificate = createInsertSchema(certificates, { privateKey: z.string().min(1), autoRenew: z.boolean().optional(), serverId: z.string().optional(), + domains: z.array(z.string()).optional(), + expiresAt: z.date().optional(), + issuer: z.string().optional(), + subject: z.string().optional(), + isWildcard: z.boolean().optional(), + autoRenewEnabled: z.boolean().optional(), + renewalStatus: z.enum(["pending", "success", "failed", "not_configured"]).optional(), }); export const apiFindCertificate = z.object({ @@ -57,6 +84,13 @@ export const apiUpdateCertificate = z.object({ certificateData: z.string().min(1).optional(), privateKey: z.string().min(1).optional(), autoRenew: z.boolean().optional(), + domains: z.array(z.string()).optional(), + expiresAt: z.date().optional(), + issuer: z.string().optional(), + subject: z.string().optional(), + isWildcard: z.boolean().optional(), + autoRenewEnabled: z.boolean().optional(), + renewalStatus: z.enum(["pending", "success", "failed", "not_configured"]).optional(), }); export const apiDeleteCertificate = z.object({ diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index c16ef1452..46a6ce28f 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -22,6 +22,7 @@ export * from "./port"; export * from "./postgres"; export * from "./preview-deployments"; export * from "./project"; +export * from "./proxy"; export * from "./redirects"; export * from "./redis"; export * from "./registry"; diff --git a/packages/server/src/db/schema/proxy.ts b/packages/server/src/db/schema/proxy.ts new file mode 100644 index 000000000..07db276da --- /dev/null +++ b/packages/server/src/db/schema/proxy.ts @@ -0,0 +1,166 @@ +import { relations } from "drizzle-orm"; +import { boolean, integer, json, pgEnum, pgTable, text } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { organization } from "./account"; +import { applications } from "./application"; +import { compose } from "./compose"; +import { certificates } from "./certificate"; +import { server } from "./server"; +import { certificateType } from "./shared"; + +export const proxyTargetType = pgEnum("proxyTargetType", [ + "url", + "application", + "compose", + "service", +]); + +export const proxyStatus = pgEnum("proxyStatus", [ + "active", + "inactive", + "error", +]); + +export const proxies = pgTable("proxy", { + proxyId: text("proxyId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + host: text("host").notNull(), + path: text("path").default("/"), + targetUrl: text("targetUrl"), + targetType: proxyTargetType("targetType").notNull().default("url"), + targetId: text("targetId"), + port: integer("port").default(3000), + https: boolean("https").notNull().default(false), + certificateId: text("certificateId").references(() => certificates.certificateId, { + onDelete: "set null", + }), + certificateType: certificateType("certificateType").notNull().default("none"), + customCertResolver: text("customCertResolver"), + serverId: text("serverId").references(() => server.serverId, { + onDelete: "cascade", + }), + organizationId: text("organizationId") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + stripPath: boolean("stripPath").notNull().default(false), + internalPath: text("internalPath").default("/"), + middlewares: json("middlewares").$type>>(), + priority: integer("priority").default(0), + isWildcard: boolean("isWildcard").default(false), + status: proxyStatus("status").notNull().default("active"), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updatedAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + +export const proxiesRelations = relations(proxies, ({ one }) => ({ + server: one(server, { + fields: [proxies.serverId], + references: [server.serverId], + }), + organization: one(organization, { + fields: [proxies.organizationId], + references: [organization.id], + }), + certificate: one(certificates, { + fields: [proxies.certificateId], + references: [certificates.certificateId], + }), + application: one(applications, { + fields: [proxies.targetId], + references: [applications.applicationId], + }), + compose: one(compose, { + fields: [proxies.targetId], + references: [compose.composeId], + }), +})); + +const createSchema = createInsertSchema(proxies, { + name: z.string().min(1), + host: z.string().min(1), + path: z.string().optional(), + targetUrl: z.string().url().optional(), + targetType: z.enum(["url", "application", "compose", "service"]), + targetId: z.string().optional(), + port: z.number().min(1).max(65535).optional(), + https: z.boolean().optional(), + certificateId: z.string().optional(), + certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), + customCertResolver: z.string().optional(), + serverId: z.string().optional(), + stripPath: z.boolean().optional(), + internalPath: z.string().optional(), + middlewares: z.array(z.record(z.unknown())).optional(), + priority: z.number().optional(), + isWildcard: z.boolean().optional(), + status: z.enum(["active", "inactive", "error"]).optional(), +}); + +export const apiCreateProxy = createSchema.pick({ + name: true, + host: true, + path: true, + targetUrl: true, + targetType: true, + targetId: true, + port: true, + https: true, + certificateId: true, + certificateType: true, + customCertResolver: true, + serverId: true, + stripPath: true, + internalPath: true, + middlewares: true, + priority: true, +}); + +export const apiUpdateProxy = createSchema + .pick({ + name: true, + host: true, + path: true, + targetUrl: true, + targetType: true, + targetId: true, + port: true, + https: true, + certificateId: true, + certificateType: true, + customCertResolver: true, + serverId: true, + stripPath: true, + internalPath: true, + middlewares: true, + priority: true, + status: true, + }) + .merge(createSchema.pick({ proxyId: true }).required()); + +export const apiFindProxy = z.object({ + proxyId: z.string().min(1), +}); + +export const apiDeleteProxy = z.object({ + proxyId: z.string().min(1), +}); + +export const apiLinkProxy = z.object({ + proxyId: z.string().min(1), + targetType: z.enum(["application", "compose", "service"]), + targetId: z.string().min(1), +}); + +export const apiUnlinkProxy = z.object({ + proxyId: z.string().min(1), +}); + diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts index c032841e3..6ddb79f68 100644 --- a/packages/server/src/db/validations/domain.ts +++ b/packages/server/src/db/validations/domain.ts @@ -8,6 +8,19 @@ export const domain = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) + .refine((val) => { + const trimmed = val.trim(); + // Allow wildcard domains (*.example.com) + if (trimmed.startsWith("*.")) { + // Wildcard must be at the start and followed by a valid domain + const baseDomain = trimmed.substring(2); + return baseDomain.length > 0 && /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(baseDomain); + } + // Regular domain validation + return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(trimmed); + }, { + message: "Invalid domain format. Use format like 'example.com' or '*.example.com' for wildcard", + }) .transform((val) => val.trim()), path: z.string().min(1).optional(), internalPath: z.string().optional(), @@ -70,6 +83,17 @@ export const domainCompose = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) + .refine((val) => { + const trimmed = val.trim(); + // Allow wildcard domains (*.example.com) + if (trimmed.startsWith("*.")) { + const baseDomain = trimmed.substring(2); + return baseDomain.length > 0 && /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(baseDomain); + } + return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(trimmed); + }, { + message: "Invalid domain format. Use format like 'example.com' or '*.example.com' for wildcard", + }) .transform((val) => val.trim()), path: z.string().min(1).optional(), internalPath: z.string().optional(), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e6d753293..c1f99c5e0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -30,6 +30,7 @@ export * from "./services/port"; export * from "./services/postgres"; export * from "./services/preview-deployment"; export * from "./services/project"; +export * from "./services/proxy"; export * from "./services/redirect"; export * from "./services/redis"; export * from "./services/registry"; @@ -117,10 +118,13 @@ export * from "./utils/traefik/application"; export * from "./utils/traefik/domain"; export * from "./utils/traefik/file-types"; export * from "./utils/traefik/middleware"; +export * from "./utils/traefik/proxy"; export * from "./utils/traefik/redirect"; export * from "./utils/traefik/security"; export * from "./utils/traefik/types"; export * from "./utils/traefik/web-server"; export * from "./utils/volume-backups/index"; export * from "./utils/watch-paths/should-deploy"; +export * from "./utils/certificate/validation"; +export * from "./utils/domain/wildcard"; export * from "./wss/utils"; diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts index 8707d098a..a1d9d580c 100644 --- a/packages/server/src/services/certificate.ts +++ b/packages/server/src/services/certificate.ts @@ -4,9 +4,18 @@ import { paths } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateCertificate, + type apiUpdateCertificate, certificates, } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; +import { + extractCertificateInfo, + extractDomains, + extractExpirationDate, + hasWildcardDomain, + isWildcardDomain, + validateCertificateFormat, +} from "@dokploy/server/utils/certificate/validation"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { stringify } from "yaml"; @@ -31,15 +40,68 @@ export const findCertificateById = async (certificateId: string) => { return certificate; }; +export const validateCertificate = ( + certificateData: string, + privateKey: string, +) => { + const validation = validateCertificateFormat(certificateData, privateKey); + if (!validation.isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Certificate validation failed: ${validation.errors.join(", ")}`, + }); + } + return validation; +}; + +export const extractCertificateMetadata = ( + certificateData: string, + privateKey: string, +) => { + const info = extractCertificateInfo(certificateData, privateKey); + + // Extract domains if not provided + const domains = info.domains.length > 0 ? info.domains : extractDomains(certificateData); + + // Extract expiration if not provided + const expiresAt = info.expiresAt || extractExpirationDate(certificateData); + + // Determine wildcard status + const isWildcard = hasWildcardDomain(domains); + + return { + domains, + expiresAt, + issuer: info.issuer, + subject: info.subject, + isWildcard, + }; +}; + export const createCertificate = async ( certificateData: z.infer, organizationId: string, ) => { + // Validate certificate format + validateCertificate(certificateData.certificateData, certificateData.privateKey); + + // Extract metadata + const metadata = extractCertificateMetadata( + certificateData.certificateData, + certificateData.privateKey, + ); + const certificate = await db .insert(certificates) .values({ ...certificateData, organizationId: organizationId, + domains: metadata.domains, + expiresAt: metadata.expiresAt, + issuer: metadata.issuer, + subject: metadata.subject, + isWildcard: metadata.isWildcard, + updatedAt: new Date().toISOString(), }) .returning(); @@ -57,6 +119,124 @@ export const createCertificate = async ( return cer; }; +export const updateCertificate = async ( + certificateData: z.infer, +) => { + const existing = await findCertificateById(certificateData.certificateId); + + // If certificate data or private key is being updated, validate and extract metadata + let metadata: { + domains?: string[]; + expiresAt?: Date | null; + issuer?: string | null; + subject?: string | null; + isWildcard?: boolean; + } = {}; + + if (certificateData.certificateData && certificateData.privateKey) { + validateCertificate(certificateData.certificateData, certificateData.privateKey); + metadata = extractCertificateMetadata( + certificateData.certificateData, + certificateData.privateKey, + ); + } else if (certificateData.certificateData) { + // Only certificate data updated, use existing private key + validateCertificate(certificateData.certificateData, existing.privateKey); + metadata = extractCertificateMetadata( + certificateData.certificateData, + existing.privateKey, + ); + } else if (certificateData.privateKey) { + // Only private key updated, use existing certificate data + validateCertificate(existing.certificateData, certificateData.privateKey); + metadata = extractCertificateMetadata( + existing.certificateData, + certificateData.privateKey, + ); + } + + const updated = await db + .update(certificates) + .set({ + ...certificateData, + ...metadata, + updatedAt: new Date().toISOString(), + }) + .where(eq(certificates.certificateId, certificateData.certificateId)) + .returning(); + + if (!updated || updated[0] === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to update the certificate", + }); + } + + const cer = updated[0]; + + // Recreate certificate files if certificate data or private key changed + if (certificateData.certificateData || certificateData.privateKey) { + createCertificateFiles(cer); + } + + return cer; +}; + +export const checkExpiration = async (certificateId: string) => { + const certificate = await findCertificateById(certificateId); + + if (!certificate.expiresAt) { + // Re-extract expiration if not set + const expiresAt = extractExpirationDate(certificate.certificateData); + if (expiresAt) { + await db + .update(certificates) + .set({ expiresAt, updatedAt: new Date().toISOString() }) + .where(eq(certificates.certificateId, certificateId)); + return expiresAt; + } + return null; + } + + return new Date(certificate.expiresAt); +}; + +export const findMatchingCertificates = async ( + domain: string, + organizationId: string, +) => { + const allCerts = await db.query.certificates.findMany({ + where: eq(certificates.organizationId, organizationId), + }); + + const matching: typeof allCerts = []; + + for (const cert of allCerts) { + const certDomains = cert.domains || []; + + // Check exact match + if (certDomains.includes(domain)) { + matching.push(cert); + continue; + } + + // Check wildcard match + for (const certDomain of certDomains) { + if (isWildcardDomain(certDomain)) { + // Extract base domain from wildcard (e.g., *.example.com -> example.com) + const baseDomain = certDomain.replace(/^\*\./, ""); + // Check if the requested domain ends with the base domain + if (domain.endsWith("." + baseDomain) || domain === baseDomain) { + matching.push(cert); + break; + } + } + } + } + + return matching; +}; + export const removeCertificateById = async (certificateId: string) => { const certificate = await findCertificateById(certificateId); const { CERTIFICATES_PATH } = paths(!!certificate.serverId); diff --git a/packages/server/src/services/proxy.ts b/packages/server/src/services/proxy.ts new file mode 100644 index 000000000..af7aaaeaa --- /dev/null +++ b/packages/server/src/services/proxy.ts @@ -0,0 +1,271 @@ +import { db } from "@dokploy/server/db"; +import { + type apiCreateProxy, + type apiUpdateProxy, + proxies, +} from "@dokploy/server/db/schema"; +import { isWildcardDomain } from "@dokploy/server/utils/domain/wildcard"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import type { z } from "zod"; +import { manageProxyDomain, removeProxyDomain } from "../utils/traefik/proxy"; + +export type Proxy = typeof proxies.$inferSelect; + +export const findProxyById = async (proxyId: string) => { + const proxy = await db.query.proxies.findFirst({ + where: eq(proxies.proxyId, proxyId), + }); + + if (!proxy) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Proxy not found", + }); + } + + return proxy; +}; + +export const validateProxyConfig = ( + proxyData: z.infer | z.infer, +): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + if (!proxyData.name || proxyData.name.trim().length === 0) { + errors.push("Proxy name is required"); + } + + if (!proxyData.host || proxyData.host.trim().length === 0) { + errors.push("Host is required"); + } + + // Validate target configuration based on targetType + if (proxyData.targetType === "url") { + if (!proxyData.targetUrl) { + errors.push("Target URL is required when target type is URL"); + } else { + try { + new URL(proxyData.targetUrl); + } catch { + errors.push("Target URL must be a valid URL"); + } + } + } else { + if (!proxyData.targetId) { + errors.push("Target ID is required when linking to application/compose/service"); + } + } + + // Validate certificate configuration + if (proxyData.https) { + if (!proxyData.certificateType || proxyData.certificateType === "none") { + errors.push("Certificate type is required when HTTPS is enabled"); + } + if (proxyData.certificateType === "custom" && !proxyData.customCertResolver) { + errors.push("Custom certificate resolver is required when certificate type is custom"); + } + } + + // Validate path configuration + if (proxyData.stripPath && (!proxyData.path || proxyData.path === "/")) { + errors.push("Strip path can only be enabled when a path other than '/' is specified"); + } + + if ( + proxyData.internalPath && + proxyData.internalPath !== "/" && + !proxyData.internalPath.startsWith("/") + ) { + errors.push("Internal path must start with '/'"); + } + + // Validate port + if (proxyData.port !== undefined) { + if (proxyData.port < 1 || proxyData.port > 65535) { + errors.push("Port must be between 1 and 65535"); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +}; + +export const createProxy = async ( + proxyData: z.infer, + organizationId: string, +) => { + // Validate configuration + const validation = validateProxyConfig(proxyData); + if (!validation.isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Proxy validation failed: ${validation.errors.join(", ")}`, + }); + } + + // Determine if wildcard + const isWildcard = isWildcardDomain(proxyData.host); + + // Create proxy + const proxy = await db + .insert(proxies) + .values({ + ...proxyData, + organizationId, + isWildcard, + updatedAt: new Date().toISOString(), + }) + .returning(); + + if (!proxy || proxy[0] === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to create the proxy", + }); + } + + const createdProxy = proxy[0]; + + // Create Traefik configuration + await manageProxyDomain(createdProxy); + + return createdProxy; +}; + +export const updateProxy = async ( + proxyData: z.infer, +) => { + const existing = await findProxyById(proxyData.proxyId); + + // Validate configuration + const validation = validateProxyConfig(proxyData); + if (!validation.isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Proxy validation failed: ${validation.errors.join(", ")}`, + }); + } + + // Determine if wildcard + const isWildcard = proxyData.host + ? isWildcardDomain(proxyData.host) + : existing.isWildcard; + + // Update proxy + const updated = await db + .update(proxies) + .set({ + ...proxyData, + isWildcard, + updatedAt: new Date().toISOString(), + }) + .where(eq(proxies.proxyId, proxyData.proxyId)) + .returning(); + + if (!updated || updated[0] === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to update the proxy", + }); + } + + const updatedProxy = updated[0]; + + // Update Traefik configuration + await manageProxyDomain(updatedProxy); + + return updatedProxy; +}; + +export const deleteProxy = async (proxyId: string) => { + const proxy = await findProxyById(proxyId); + + // Remove Traefik configuration + await removeProxyDomain(proxy); + + // Delete proxy + const result = await db + .delete(proxies) + .where(eq(proxies.proxyId, proxyId)) + .returning(); + + if (!result || result.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to delete the proxy", + }); + } + + return result[0]; +}; + +export const listProxies = async (organizationId: string) => { + return await db.query.proxies.findMany({ + where: eq(proxies.organizationId, organizationId), + orderBy: (proxies, { desc }) => [desc(proxies.createdAt)], + }); +}; + +export const linkToService = async ( + proxyId: string, + targetType: "application" | "compose" | "service", + targetId: string, +) => { + const proxy = await findProxyById(proxyId); + + const updated = await db + .update(proxies) + .set({ + targetType, + targetId, + updatedAt: new Date().toISOString(), + }) + .where(eq(proxies.proxyId, proxyId)) + .returning(); + + if (!updated || updated[0] === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to link proxy to service", + }); + } + + const updatedProxy = updated[0]; + + // Update Traefik configuration + await manageProxyDomain(updatedProxy); + + return updatedProxy; +}; + +export const unlinkFromService = async (proxyId: string) => { + const proxy = await findProxyById(proxyId); + + const updated = await db + .update(proxies) + .set({ + targetType: "url", + targetId: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(proxies.proxyId, proxyId)) + .returning(); + + if (!updated || updated[0] === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to unlink proxy from service", + }); + } + + const updatedProxy = updated[0]; + + // Update Traefik configuration + await manageProxyDomain(updatedProxy); + + return updatedProxy; +}; + diff --git a/packages/server/src/utils/certificate/validation.ts b/packages/server/src/utils/certificate/validation.ts new file mode 100644 index 000000000..48621fe50 --- /dev/null +++ b/packages/server/src/utils/certificate/validation.ts @@ -0,0 +1,527 @@ +import { TRPCError } from "@trpc/server"; + +export interface CertificateInfo { + domains: string[]; + expiresAt: Date | null; + issuer: string | null; + subject: string | null; + isWildcard: boolean; + isValid: boolean; + errors: string[]; +} + +/** + * Extract domains from certificate data (including SANs) + */ +export const extractDomains = (certData: string): string[] => { + const domains: string[] = []; + + try { + // Parse certificate to extract CN and SANs + const certs = certData.split(/-----BEGIN CERTIFICATE-----/).filter(c => c.trim()); + + for (const cert of certs) { + const fullCert = `-----BEGIN CERTIFICATE-----${cert}-----END CERTIFICATE-----`; + const domainsFromCert = extractDomainsFromCert(fullCert); + domains.push(...domainsFromCert); + } + } catch (error) { + console.error("Error extracting domains:", error); + } + + return [...new Set(domains)]; // Remove duplicates +}; + +/** + * Extract domains from a single certificate + */ +const extractDomainsFromCert = (certData: string): string[] => { + const domains: string[] = []; + + try { + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const der = Buffer.from(b64, "base64"); + + let offset = 0; + + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + len = (len << 8) + der[pos++]; + } + } + return { length: len, offset: pos }; + } + + // Skip outer certificate sequence + if (der[offset++] !== 0x30) return domains; + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) return domains; + ({ offset } = readLength(offset)); + + // Skip version, serialNumber, signature, issuer + for (let i = 0; i < 4; i++) { + if (der[offset] === 0xa0) { + // Context-specific tag + offset++; + const len = readLength(offset); + offset = len.offset + len.length; + } else { + offset++; + const len = readLength(offset); + offset = len.offset + len.length; + } + } + + // Now we're at subject + // Extract CN from subject + const subjectStart = offset; + if (der[offset++] === 0x30) { + const subjectLen = readLength(offset); + const subjectEnd = subjectLen.offset + subjectLen.length; + const subjectData = der.slice(subjectLen.offset, subjectEnd); + const subjectStr = extractSubjectFromDER(subjectData); + if (subjectStr) { + const cnMatch = subjectStr.match(/CN=([^,]+)/); + if (cnMatch) { + const cn = cnMatch[1].trim(); + if (cn && !domains.includes(cn)) { + domains.push(cn); + } + } + } + offset = subjectEnd; + } + + // Skip validity + if (der[offset++] === 0x30) { + const validityLen = readLength(offset); + offset = validityLen.offset + validityLen.length; + } + + // Skip subjectPublicKeyInfo + if (der[offset++] === 0x30) { + const spkiLen = readLength(offset); + offset = spkiLen.offset + spkiLen.length; + } + + // Look for extensions (context-specific tag [3]) + while (offset < der.length) { + if (der[offset] === 0xa3) { + offset++; + const extLen = readLength(offset); + const extEnd = extLen.offset + extLen.length; + const extData = der.slice(extLen.offset, extEnd); + + // Parse extensions to find SAN + const sans = extractSANsFromExtensions(extData); + for (const san of sans) { + if (san && !domains.includes(san)) { + domains.push(san); + } + } + offset = extEnd; + } else { + break; + } + } + } catch (error) { + console.error("Error parsing certificate for domains:", error); + } + + return domains; +}; + +/** + * Extract subject from DER-encoded data + */ +const extractSubjectFromDER = (data: Uint8Array): string | null => { + try { + // Simple extraction - look for printable strings in the subject + let result = ""; + for (let i = 0; i < data.length; i++) { + if (data[i] === 0x0c || data[i] === 0x13 || data[i] === 0x16) { + // UTF8String, PrintableString, or IA5String + i++; + if (i < data.length) { + const len = data[i] & 0x7f; + if (len > 0 && len < 200) { + const str = new TextDecoder().decode(data.slice(i + 1, i + 1 + len)); + if (str.includes("=")) { + result += str; + } + i += len; + } + } + } + } + return result || null; + } catch { + return null; + } +}; + +/** + * Extract SANs from extensions + */ +const extractSANsFromExtensions = (extData: Uint8Array): string[] => { + const sans: string[] = []; + + try { + let offset = 0; + + function readLength(pos: number): { length: number; offset: number } { + let len = extData[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + len = (len << 8) + extData[pos++]; + } + } + return { length: len, offset: pos }; + } + + // Extensions sequence + if (extData[offset++] !== 0x30) return sans; + const { offset: extSeqOffset } = readLength(offset); + offset = extSeqOffset; + + // Iterate through extensions + while (offset < extData.length) { + if (extData[offset++] === 0x30) { + const extLen = readLength(offset); + const extEnd = extLen.offset + extLen.length; + + // Extension: SEQUENCE { extnID, critical, extnValue } + // extnID for SAN is 2.5.29.17 (id-ce-subjectAltName) + // Look for OCTET STRING containing the SAN + let extOffset = extLen.offset; + while (extOffset < extEnd) { + if (extData[extOffset] === 0x04) { + // OCTET STRING + extOffset++; + const octetLen = readLength(extOffset); + const sanData = extData.slice(octetLen.offset, octetLen.offset + octetLen.length); + + // Parse SAN GeneralNames + const names = parseGeneralNames(sanData); + sans.push(...names); + break; + } + extOffset++; + } + offset = extEnd; + } else { + break; + } + } + } catch (error) { + console.error("Error parsing SANs:", error); + } + + return sans; +}; + +/** + * Parse GeneralNames from SAN extension + */ +const parseGeneralNames = (data: Uint8Array): string[] => { + const names: string[] = []; + + try { + let offset = 0; + + function readLength(pos: number): { length: number; offset: number } { + let len = data[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + len = (len << 8) + data[pos++]; + } + } + return { length: len, offset: pos }; + } + + // GeneralNames is a SEQUENCE OF + if (data[offset++] === 0x30) { + const { offset: seqOffset } = readLength(offset); + offset = seqOffset; + + while (offset < data.length) { + // Each GeneralName is a CHOICE with context-specific tags + // dNSName is [2] IMPLICIT IA5String + if (data[offset] === 0x82) { + offset++; + const len = readLength(offset); + const name = new TextDecoder().decode( + data.slice(len.offset, len.offset + len.length), + ); + if (name && !names.includes(name)) { + names.push(name); + } + offset = len.offset + len.length; + } else { + // Skip other GeneralName types + offset++; + const len = readLength(offset); + offset = len.offset + len.length; + } + } + } + } catch (error) { + console.error("Error parsing GeneralNames:", error); + } + + return names; +}; + +/** + * Extract expiration date from certificate + */ +export const extractExpirationDate = (certData: string): Date | null => { + try { + // Use the first certificate in chain for expiration + const firstCert = certData.split(/-----BEGIN CERTIFICATE-----/)[1]; + if (!firstCert) return null; + + const fullCert = `-----BEGIN CERTIFICATE-----${firstCert}-----END CERTIFICATE-----`; + + // Decode PEM base64 to DER binary + const b64 = fullCert.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const der = Buffer.from(b64, "base64"); + + let offset = 0; + + function readLength(pos: number): { length: number; offset: number } { + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + len = (len << 8) + der[pos++]; + } + } + return { length: len, offset: pos }; + } + + // Skip outer certificate sequence + if (der[offset++] !== 0x30) return null; + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) return null; + ({ offset } = readLength(offset)); + + // Skip version if present + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber, signature, issuer + for (let i = 0; i < 3; i++) { + if (der[offset] !== 0x30 && der[offset] !== 0x02) return null; + offset++; + const fieldLen = readLength(offset); + offset = fieldLen.offset + fieldLen.length; + } + + // Validity sequence + if (der[offset++] !== 0x30) return null; + const validityLen = readLength(offset); + offset = validityLen.offset; + + // Skip notBefore + offset++; + const notBeforeLen = readLength(offset); + offset = notBeforeLen.offset + notBeforeLen.length; + + // notAfter + offset++; + const notAfterLen = readLength(offset); + const notAfterStr = new TextDecoder().decode( + der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length), + ); + + // Parse time + function parseTime(str: string): Date { + if (str.length === 13) { + const year = Number.parseInt(str.slice(0, 2), 10); + const fullYear = year < 50 ? 2000 + year : 1900 + year; + return new Date( + `${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`, + ); + } + if (str.length === 15) { + return new Date( + `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`, + ); + } + throw new Error("Invalid time format"); + } + + return parseTime(notAfterStr); + } catch (error) { + console.error("Error extracting expiration date:", error); + return null; + } +}; + +/** + * Extract issuer from certificate + */ +export const extractIssuer = (certData: string): string | null => { + try { + const firstCert = certData.split(/-----BEGIN CERTIFICATE-----/)[1]; + if (!firstCert) return null; + + const fullCert = `-----BEGIN CERTIFICATE-----${firstCert}-----END CERTIFICATE-----`; + const b64 = fullCert.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const der = Buffer.from(b64, "base64"); + + // Simplified extraction - look for issuer in the certificate + // This is a simplified version; full parsing would be more complex + const certStr = new TextDecoder().decode(der); + const issuerMatch = certStr.match(/O=([^,]+)/); + if (issuerMatch) { + return issuerMatch[1].trim(); + } + + return "Unknown"; + } catch { + return null; + } +}; + +/** + * Extract subject from certificate + */ +export const extractSubject = (certData: string): string | null => { + try { + const firstCert = certData.split(/-----BEGIN CERTIFICATE-----/)[1]; + if (!firstCert) return null; + + const fullCert = `-----BEGIN CERTIFICATE-----${firstCert}-----END CERTIFICATE-----`; + const b64 = fullCert.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const der = Buffer.from(b64, "base64"); + + const certStr = new TextDecoder().decode(der); + const subjectMatch = certStr.match(/CN=([^,]+)/); + if (subjectMatch) { + return subjectMatch[1].trim(); + } + + return null; + } catch { + return null; + } +}; + +/** + * Check if domain is a wildcard + */ +export const isWildcardDomain = (domain: string): boolean => { + return domain.trim().startsWith("*."); +}; + +/** + * Check if certificate contains wildcard domains + */ +export const hasWildcardDomain = (domains: string[]): boolean => { + return domains.some(domain => isWildcardDomain(domain)); +}; + +/** + * Validate certificate data format + */ +export const validateCertificateFormat = (certData: string, privateKey: string): { + isValid: boolean; + errors: string[]; +} => { + const errors: string[] = []; + + if (!certData || !certData.trim()) { + errors.push("Certificate data is required"); + } + + if (!privateKey || !privateKey.trim()) { + errors.push("Private key is required"); + } + + // Check for PEM format + if (certData && !certData.includes("-----BEGIN CERTIFICATE-----")) { + errors.push("Certificate data must be in PEM format"); + } + + if (privateKey && !privateKey.includes("-----BEGIN")) { + errors.push("Private key must be in PEM format"); + } + + // Basic validation - check if we can extract expiration + if (certData && certData.includes("-----BEGIN CERTIFICATE-----")) { + const expiration = extractExpirationDate(certData); + if (!expiration) { + errors.push("Could not parse certificate expiration date"); + } else if (expiration < new Date()) { + errors.push("Certificate has already expired"); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +}; + +/** + * Extract comprehensive certificate information + */ +export const extractCertificateInfo = (certData: string, privateKey: string): CertificateInfo => { + const errors: string[] = []; + + // Validate format + const formatValidation = validateCertificateFormat(certData, privateKey); + if (!formatValidation.isValid) { + errors.push(...formatValidation.errors); + } + + // Extract domains + const domains = extractDomains(certData); + if (domains.length === 0) { + errors.push("No domains found in certificate"); + } + + // Extract expiration + const expiresAt = extractExpirationDate(certData); + if (!expiresAt) { + errors.push("Could not extract expiration date"); + } + + // Extract issuer and subject + const issuer = extractIssuer(certData); + const subject = extractSubject(certData); + + // Check for wildcard + const isWildcard = hasWildcardDomain(domains); + + return { + domains, + expiresAt, + issuer, + subject, + isWildcard, + isValid: errors.length === 0, + errors, + }; +}; + diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 2272f364e..1e5278001 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; +import { wildcardToHostRegexp } from "@dokploy/server/utils/domain/wildcard"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -263,8 +264,20 @@ export const createDomainLabels = ( internalPath, } = domain; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; + + // Generate rule with wildcard support + let hostRule: string; + if (host.startsWith("*.")) { + // Use HostRegexp for wildcard domains + hostRule = wildcardToHostRegexp(host); + } else { + // Use regular Host rule for exact matches + hostRule = `Host(\`${host}\`)`; + } + + const pathRule = path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""; const labels = [ - `traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + `traefik.http.routers.${routerName}.rule=${hostRule}${pathRule}`, `traefik.http.routers.${routerName}.entrypoints=${entrypoint}`, `traefik.http.services.${routerName}.loadbalancer.server.port=${port}`, `traefik.http.routers.${routerName}.service=${routerName}`, diff --git a/packages/server/src/utils/domain/wildcard.ts b/packages/server/src/utils/domain/wildcard.ts new file mode 100644 index 000000000..d84f1eec6 --- /dev/null +++ b/packages/server/src/utils/domain/wildcard.ts @@ -0,0 +1,108 @@ +/** + * Check if a domain is a wildcard domain (starts with *.) + */ +export const isWildcardDomain = (domain: string): boolean => { + return domain.trim().startsWith("*."); +}; + +/** + * Extract the base domain from a wildcard domain + * Example: *.example.com -> example.com + */ +export const extractBaseDomain = (wildcardDomain: string): string | null => { + if (!isWildcardDomain(wildcardDomain)) { + return null; + } + return wildcardDomain.trim().substring(2); +}; + +/** + * Check if a domain matches a wildcard pattern + * Example: app1.example.com matches *.example.com + */ +export const matchesWildcard = (domain: string, wildcardPattern: string): boolean => { + if (!isWildcardDomain(wildcardPattern)) { + return false; + } + + const baseDomain = extractBaseDomain(wildcardPattern); + if (!baseDomain) { + return false; + } + + const trimmedDomain = domain.trim(); + + // Exact match with base domain + if (trimmedDomain === baseDomain) { + return true; + } + + // Check if domain ends with .baseDomain + if (trimmedDomain.endsWith("." + baseDomain)) { + // Ensure it's not a nested match (e.g., example.com.example.com) + const prefix = trimmedDomain.substring(0, trimmedDomain.length - baseDomain.length - 1); + // Prefix should be a valid subdomain (non-empty, no dots at start/end) + return prefix.length > 0 && !prefix.startsWith(".") && !prefix.endsWith("."); + } + + return false; +}; + +/** + * Find all wildcard patterns that match a given domain + */ +export const findMatchingWildcards = ( + domain: string, + wildcardPatterns: string[], +): string[] => { + return wildcardPatterns.filter((pattern) => matchesWildcard(domain, pattern)); +}; + +/** + * Convert a wildcard domain to a Traefik HostRegexp rule + * Example: *.example.com -> HostRegexp(`^[^.]+\.example\.com$`) + */ +export const wildcardToHostRegexp = (wildcardDomain: string): string => { + if (!isWildcardDomain(wildcardDomain)) { + return `Host(\`${wildcardDomain}\`)`; + } + + const baseDomain = extractBaseDomain(wildcardDomain); + if (!baseDomain) { + return `Host(\`${wildcardDomain}\`)`; + } + + // Escape dots in the base domain for regex + const escapedBase = baseDomain.replace(/\./g, "\\."); + + // Create regex pattern: ^[^.]+\.example\.com$ + // This matches any subdomain of example.com but not example.com itself + // To also match the base domain, we could use: ^([^.]+\.)?example\.com$ + return `HostRegexp(\`^[^.]+\\\\.${escapedBase}$\`)`; +}; + +/** + * Convert a wildcard domain to a Traefik HostRegexp rule that also matches the base domain + * Example: *.example.com -> HostRegexp(`^([^.]+\.)?example\.com$`) + */ +export const wildcardToHostRegexpWithBase = (wildcardDomain: string): string => { + if (!isWildcardDomain(wildcardDomain)) { + return `Host(\`${wildcardDomain}\`)`; + } + + const baseDomain = extractBaseDomain(wildcardDomain); + if (!baseDomain) { + return `Host(\`${wildcardDomain}\`)`; + } + + // Escape dots in the base domain for regex + const escapedBase = baseDomain.replace(/\./g, "\\."); + + // Create regex pattern that matches both subdomains and base domain + // ^([^.]+\.)?example\.com$ matches: + // - example.com (base domain) + // - app1.example.com (subdomain) + // - app2.example.com (subdomain) + return `HostRegexp(\`^([^.]+\\\\.)?${escapedBase}$\`)`; +}; + diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index 68095fa80..c96115960 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -1,4 +1,5 @@ import type { Domain } from "@dokploy/server/services/domain"; +import { wildcardToHostRegexp } from "@dokploy/server/utils/domain/wildcard"; import type { ApplicationNested } from "../builders"; import { createServiceConfig, @@ -114,8 +115,20 @@ export const createRouterConfig = async ( const { host, path, https, uniqueConfigKey, internalPath, stripPath } = domain; + + // Generate rule with wildcard support + let hostRule: string; + if (host.startsWith("*.")) { + // Use HostRegexp for wildcard domains + hostRule = wildcardToHostRegexp(host); + } else { + // Use regular Host rule for exact matches + hostRule = `Host(\`${host}\`)`; + } + + const pathRule = path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""; const routerConfig: HttpRouter = { - rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + rule: `${hostRule}${pathRule}`, service: `${appName}-service-${uniqueConfigKey}`, middlewares: [], entryPoints: [entryPoint], diff --git a/packages/server/src/utils/traefik/proxy.ts b/packages/server/src/utils/traefik/proxy.ts new file mode 100644 index 000000000..2ba811e5a --- /dev/null +++ b/packages/server/src/utils/traefik/proxy.ts @@ -0,0 +1,238 @@ +import type { Proxy } from "@dokploy/server/services/proxy"; +import { wildcardToHostRegexp } from "@dokploy/server/utils/domain/wildcard"; +import { + loadOrCreateConfig, + loadOrCreateConfigRemote, + removeTraefikConfig, + removeTraefikConfigRemote, + writeTraefikConfig, + writeTraefikConfigRemote, +} from "./application"; +import type { FileConfig, HttpLoadBalancerService, HttpRouter } from "./file-types"; + +export const manageProxyDomain = async (proxy: Proxy) => { + const proxyName = `proxy-${proxy.proxyId}`; + let config: FileConfig; + + if (proxy.serverId) { + config = await loadOrCreateConfigRemote(proxy.serverId, proxyName); + } else { + config = loadOrCreateConfig(proxyName); + } + + const routerName = `${proxyName}-router`; + const routerNameSecure = `${proxyName}-router-websecure`; + const serviceName = `${proxyName}-service`; + + config.http = config.http || { routers: {}, services: {} }; + config.http.routers = config.http.routers || {}; + config.http.services = config.http.services || {}; + + // Create middlewares if needed + config.http.middlewares = config.http.middlewares || {}; + + if (proxy.stripPath && proxy.path && proxy.path !== "/") { + const stripMiddleware = `stripprefix-proxy-${proxy.proxyId}`; + config.http.middlewares[stripMiddleware] = { + stripPrefix: { + prefixes: [proxy.path], + forceSlash: false, + }, + }; + } + + if (proxy.internalPath && proxy.internalPath !== "/" && proxy.internalPath !== proxy.path) { + const addPrefixMiddleware = `addprefix-proxy-${proxy.proxyId}`; + config.http.middlewares[addPrefixMiddleware] = { + addPrefix: { + prefix: proxy.internalPath, + }, + }; + } + + // Create HTTP router (web entrypoint) + config.http.routers[routerName] = createProxyRouter(proxy, "web"); + + // Create HTTPS router if HTTPS is enabled + if (proxy.https) { + config.http.routers[routerNameSecure] = createProxyRouter(proxy, "websecure"); + } else { + delete config.http.routers[routerNameSecure]; + } + + // Create service + config.http.services[serviceName] = await createProxyService(proxy); + + // Write configuration + if (proxy.serverId) { + await writeTraefikConfigRemote(config, proxyName, proxy.serverId); + } else { + writeTraefikConfig(config, proxyName); + } +}; + +export const removeProxyDomain = async (proxy: Proxy) => { + const proxyName = `proxy-${proxy.proxyId}`; + let config: FileConfig; + + if (proxy.serverId) { + config = await loadOrCreateConfigRemote(proxy.serverId, proxyName); + } else { + config = loadOrCreateConfig(proxyName); + } + + const routerName = `${proxyName}-router`; + const routerNameSecure = `${proxyName}-router-websecure`; + const serviceName = `${proxyName}-service`; + const stripMiddleware = `stripprefix-proxy-${proxy.proxyId}`; + const addPrefixMiddleware = `addprefix-proxy-${proxy.proxyId}`; + + // Remove routers and service + if (config.http?.routers?.[routerName]) { + delete config.http.routers[routerName]; + } + if (config.http?.routers?.[routerNameSecure]) { + delete config.http.routers[routerNameSecure]; + } + if (config.http?.services?.[serviceName]) { + delete config.http.services[serviceName]; + } + + // Remove middlewares + if (config.http?.middlewares?.[stripMiddleware]) { + delete config.http.middlewares[stripMiddleware]; + } + if (config.http?.middlewares?.[addPrefixMiddleware]) { + delete config.http.middlewares[addPrefixMiddleware]; + } + + // If no routers left, remove the entire config file + if ( + config?.http?.routers && + Object.keys(config.http.routers).length === 0 + ) { + if (proxy.serverId) { + await removeTraefikConfigRemote(proxyName, proxy.serverId); + } else { + await removeTraefikConfig(proxyName); + } + } else { + // Write updated configuration + if (proxy.serverId) { + await writeTraefikConfigRemote(config, proxyName, proxy.serverId); + } else { + writeTraefikConfig(config, proxyName); + } + } +}; + +export const createProxyRouter = ( + proxy: Proxy, + entryPoint: "web" | "websecure", +): HttpRouter => { + // Generate host rule with wildcard support + let hostRule: string; + if (proxy.isWildcard && proxy.host.startsWith("*.")) { + hostRule = wildcardToHostRegexp(proxy.host); + } else { + hostRule = `Host(\`${proxy.host}\`)`; + } + + // Add path prefix if specified + const pathRule = + proxy.path && proxy.path !== "/" ? ` && PathPrefix(\`${proxy.path}\`)` : ""; + + const routerConfig: HttpRouter = { + rule: `${hostRule}${pathRule}`, + service: `proxy-${proxy.proxyId}-service`, + middlewares: [], + entryPoints: [entryPoint], + priority: proxy.priority || undefined, + }; + + // Add HTTPS redirect for web entrypoint (must be first) + if (entryPoint === "web" && proxy.https) { + routerConfig.middlewares = ["redirect-to-https"]; + } else { + // Add path stripping middleware if needed + if (proxy.stripPath && proxy.path && proxy.path !== "/") { + const stripMiddleware = `stripprefix-proxy-${proxy.proxyId}`; + routerConfig.middlewares?.push(stripMiddleware); + } + + // Add internal path prefix middleware if needed + if (proxy.internalPath && proxy.internalPath !== "/" && proxy.internalPath !== proxy.path) { + const addPrefixMiddleware = `addprefix-proxy-${proxy.proxyId}`; + routerConfig.middlewares?.push(addPrefixMiddleware); + } + } + + // Add TLS configuration for websecure entrypoint + if (entryPoint === "websecure" && proxy.https) { + if (proxy.certificateType === "letsencrypt") { + routerConfig.tls = { certResolver: "letsencrypt" }; + } else if (proxy.certificateType === "custom" && proxy.customCertResolver) { + routerConfig.tls = { certResolver: proxy.customCertResolver }; + } else if (proxy.certificateId) { + // Use certificate store if certificate ID is provided + routerConfig.tls = { certResolver: undefined }; + } + } + + return routerConfig; +}; + +export const createProxyService = async (proxy: Proxy): Promise => { + // Determine target URL + let targetUrl: string; + + if (proxy.targetType === "url" && proxy.targetUrl) { + targetUrl = proxy.targetUrl; + } else if (proxy.targetType === "application" && proxy.targetId) { + // For applications, fetch the appName from the database + const { db } = await import("@dokploy/server/db"); + const { applications } = await import("@dokploy/server/db/schema"); + const { eq } = await import("drizzle-orm"); + + const app = await db.query.applications.findFirst({ + where: eq(applications.applicationId, proxy.targetId), + }); + + if (app?.appName) { + targetUrl = `http://${app.appName}:${proxy.port || 3000}`; + } else { + // Fallback if app not found + targetUrl = `http://${proxy.targetId}:${proxy.port || 3000}`; + } + } else if (proxy.targetType === "compose" && proxy.targetId) { + // For compose services, fetch the appName from the database + const { db } = await import("@dokploy/server/db"); + const { compose } = await import("@dokploy/server/db/schema"); + const { eq } = await import("drizzle-orm"); + + const composeApp = await db.query.compose.findFirst({ + where: eq(compose.composeId, proxy.targetId), + }); + + if (composeApp?.appName) { + targetUrl = `http://${composeApp.appName}:${proxy.port || 3000}`; + } else { + // Fallback if compose not found + targetUrl = `http://${proxy.targetId}:${proxy.port || 3000}`; + } + } else if (proxy.targetType === "service" && proxy.targetId) { + // For services, use the service name directly + targetUrl = `http://${proxy.targetId}:${proxy.port || 3000}`; + } else { + // Fallback to targetUrl or default + targetUrl = proxy.targetUrl || `http://localhost:${proxy.port || 3000}`; + } + + return { + loadBalancer: { + servers: [{ url: targetUrl }], + passHostHeader: true, + }, + }; +}; +