From 33206639a933ee14744b8b38684c0c4902c49ace Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 10 Jun 2026 19:09:06 -0400 Subject: [PATCH 01/14] refactor(web-server): provider-neutral abstraction (Traefik-only, zero behavior change) --- .../provider-neutral-ui-contract.test.ts | 114 ++++++++++++ .../__test__/caddy/web-server-health.test.ts | 83 +++++++++ .../advanced/traefik/show-traefik-config.tsx | 46 ++++- .../file-system/show-traefik-file.tsx | 145 +++++++++------ .../file-system/show-traefik-system.tsx | 55 ++++-- .../components/dashboard/search-command.tsx | 2 +- .../servers/actions/show-traefik-actions.tsx | 173 +++++++++++------- .../settings/servers/show-servers.tsx | 5 +- .../show-traefik-file-system-modal.tsx | 18 +- .../settings/users/add-permissions.tsx | 5 +- .../settings/web-server/update-webserver.tsx | 18 +- apps/dokploy/components/layouts/side.tsx | 4 +- apps/dokploy/components/layouts/user-nav.tsx | 2 +- .../proprietary/roles/manage-custom-roles.tsx | 8 +- packages/server/src/utils/docker/utils.ts | 27 ++- 15 files changed, 535 insertions(+), 170 deletions(-) create mode 100644 apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts create mode 100644 apps/dokploy/__test__/caddy/web-server-health.test.ts diff --git a/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts b/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts new file mode 100644 index 0000000000..f7411927c8 --- /dev/null +++ b/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +describe("provider-neutral Caddy UI contract", () => { + test("labels the account menu file-browser link as web-server files", () => { + const source = readSource("../../components/layouts/user-nav.tsx"); + + expect(source).toContain("permissions?.traefikFiles.read"); + expect(source).toContain('router.push("/dashboard/traefik")'); + expect(source).toContain("Web Server Files"); + expect(source).not.toMatch(/>\s*Traefik\s* { + const source = readSource( + "../../components/proprietary/roles/manage-custom-roles.tsx", + ); + + expect(source).toContain("traefikFiles:"); + expect(source).toContain('label: "Web Server Files"'); + expect(source).toContain( + 'description: "Access to the active web server file browser"', + ); + expect(source).toContain( + 'description: "View active web server configuration files"', + ); + expect(source).toContain( + 'description: "Edit and save active web server configuration files"', + ); + expect(source).not.toContain("Traefik Files"); + expect(source).not.toContain("Traefik file system configuration"); + expect(source).not.toContain("Traefik configuration files"); + }); + + test("uses generic copy while active web-server provider is unresolved", () => { + const actions = readSource( + "../../components/dashboard/settings/servers/actions/show-traefik-actions.tsx", + ); + const envEditor = readSource( + "../../components/dashboard/settings/web-server/edit-web-server-env.tsx", + ); + const fileSystem = readSource( + "../../components/dashboard/file-system/show-traefik-system.tsx", + ); + + expect(actions).toContain("EditWebServerEnv"); + expect(actions).not.toContain("EditTraefikEnv"); + expect(envEditor).toContain("readWebServerEnv"); + expect(envEditor).toContain("writeWebServerEnv"); + expect(envEditor).not.toContain("readTraefikEnv"); + expect(envEditor).not.toContain("writeTraefikEnv"); + expect(actions).toContain(': "Web Server";'); + expect(actions).toMatch( + /const resourceName =[\s\S]*activeProvider === "caddy"[\s\S]*"dokploy-caddy"[\s\S]*activeProvider === "traefik"[\s\S]*"dokploy-traefik"[\s\S]*: null;/, + ); + expect(actions).toContain("!activeProvider ||"); + expect(actions).toContain("{resourceName && ("); + expect(actions).toContain('activeProvider === "traefik" && ('); + expect(fileSystem).toContain('const isTraefik = provider === "traefik";'); + expect(fileSystem).toContain( + "Provider-specific edit controls appear after Dokploy resolves the active provider.", + ); + }); + + test("keeps application advanced web-server config provider-neutral while provider is unresolved", () => { + const source = readSource( + "../../components/dashboard/application/advanced/traefik/show-traefik-config.tsx", + ); + + expect(source).toContain('const isCaddy = activeProvider === "caddy";'); + expect(source).toContain('const isTraefik = activeProvider === "traefik";'); + expect(source).toContain(': "Web Server";'); + expect(source).toContain( + "Provider-specific edit controls appear after Dokploy resolves the active provider.", + ); + expect(source).toContain("{isTraefik && ("); + expect(source).not.toContain('activeProvider !== "caddy"'); + expect(source).not.toContain( + 'activeProvider === "caddy" ? "Caddy" : "Traefik"', + ); + }); + + test("keeps read-only web-server files scrollable", () => { + const editor = readSource("../../components/shared/code-editor.tsx"); + const fileEditor = readSource( + "../../components/dashboard/file-system/show-traefik-file.tsx", + ); + + expect(editor).toContain("pointer-events-none absolute"); + expect(fileEditor).toContain( + 'activeProvider === "caddy" ? "json" : "yaml"', + ); + expect(fileEditor).toContain("disabled={!isTraefik || canEdit}"); + }); + + test("keeps Caddy web-server settings cards padded", () => { + const providerSelector = readSource( + "../../components/dashboard/settings/web-server/web-server-provider-selector.tsx", + ); + const migrationPanel = readSource( + "../../components/dashboard/settings/web-server/caddy-migration-panel.tsx", + ); + + expect(providerSelector).toContain('CardHeader className="pb-3"'); + expect(providerSelector).toContain('CardContent className="space-y-3"'); + expect(migrationPanel).toContain('CardHeader className="pb-3"'); + expect(migrationPanel).toContain('CardContent className="space-y-4"'); + expect(providerSelector).not.toContain('CardHeader className="px-0 pt-0"'); + expect(migrationPanel).not.toContain('CardHeader className="px-0"'); + }); +}); diff --git a/apps/dokploy/__test__/caddy/web-server-health.test.ts b/apps/dokploy/__test__/caddy/web-server-health.test.ts new file mode 100644 index 0000000000..b7509ee9c6 --- /dev/null +++ b/apps/dokploy/__test__/caddy/web-server-health.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const dockerMock = vi.hoisted(() => ({ + getContainer: vi.fn(), + getService: vi.fn(), + listTasks: vi.fn(), +})); + +vi.mock("@dokploy/server/constants", () => ({ + docker: dockerMock, + paths: vi.fn(() => ({})), +})); + +import { + checkTraefikHealth, + checkWebServerHealth, +} from "@dokploy/server/utils/docker/utils"; + +beforeEach(() => { + vi.clearAllMocks(); + dockerMock.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ State: { Running: true } }), + }); + dockerMock.getService.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ + Spec: { Mode: { Replicated: { Replicas: 1 } } }, + }), + }); + dockerMock.listTasks.mockResolvedValue([ + { + Status: { + State: "running", + ContainerStatus: { ContainerID: "container-1" }, + }, + }, + ]); +}); + +test("checks the Caddy resource when the active web server provider is Caddy", async () => { + const result = await checkWebServerHealth("caddy"); + + expect(result).toEqual({ provider: "caddy", status: "healthy" }); + expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-caddy"); + expect(dockerMock.getService).not.toHaveBeenCalled(); +}); + +test("checks the Traefik resource when the active web server provider is Traefik", async () => { + const result = await checkWebServerHealth("traefik"); + + expect(result).toEqual({ provider: "traefik", status: "healthy" }); + expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-traefik"); +}); + +test("falls back to the active Caddy swarm service when no standalone Caddy container exists", async () => { + dockerMock.getContainer.mockReturnValueOnce({ + inspect: vi.fn().mockRejectedValue(new Error("missing container")), + }); + + const result = await checkWebServerHealth("caddy"); + + expect(result).toEqual({ provider: "caddy", status: "healthy" }); + expect(dockerMock.getService).toHaveBeenCalledWith("dokploy-caddy"); + expect(dockerMock.listTasks).toHaveBeenCalledWith({ + filters: JSON.stringify({ + service: ["dokploy-caddy"], + "desired-state": ["running"], + }), + }); +}); + +test("keeps the Traefik-specific helper on the Traefik resource", async () => { + dockerMock.getContainer.mockReturnValueOnce({ + inspect: vi.fn().mockResolvedValue({ State: { Running: false } }), + }); + + const result = await checkTraefikHealth(); + + expect(result).toEqual({ + status: "unhealthy", + message: "Container is not running", + }); + expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-traefik"); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index 94efbc285c..2f0ab08d20 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -1,4 +1,5 @@ import { File, Loader2 } from "lucide-react"; +import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Card, @@ -17,7 +18,23 @@ interface Props { export const ShowTraefikConfig = ({ applicationId }: Props) => { const { data: permissions } = api.user.getPermissions.useQuery(); const canRead = permissions?.traefikFiles.read ?? false; - const { data, isPending } = api.application.readTraefikConfig.useQuery( + const { data: application } = api.application.one.useQuery( + { applicationId }, + { enabled: !!applicationId && canRead }, + ); + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery( + { serverId: application?.serverId || undefined }, + { enabled: canRead && !!application }, + ); + const isCaddy = activeProvider === "caddy"; + const isTraefik = activeProvider === "traefik"; + const providerLabel = isCaddy + ? "Caddy" + : isTraefik + ? "Traefik" + : "Web Server"; + const { data, isPending } = api.application.readWebServerConfig.useQuery( { applicationId, }, @@ -30,15 +47,24 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- Traefik + {providerLabel} - Modify the traefik config, in rare cases you may need to add - specific config, be careful because modifying incorrectly can break - traefik and your application + {isCaddy + ? "Review generated Caddy route fragments for this application. Caddy manages certificates for HTTPS domains; Traefik YAML and custom certificate resolvers do not apply." + : isTraefik + ? "Modify the Traefik config in rare cases. Be careful: invalid Traefik YAML can break the application route." + : "Review the active web server configuration for this application. Provider-specific edit controls appear after Dokploy resolves the active provider."}
+ {isCaddy && ( + + For Caddy, domain changes generate JSON fragments and Caddy handles + ACME certificates automatically. Use Settings → Web Server for the + active Caddy config and migration artifacts. + + )} {isPending ? ( Loading... @@ -48,7 +74,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- No traefik config detected + No {providerLabel} config detected
) : ( @@ -60,9 +86,11 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => { disabled className="font-mono" /> -
- -
+ {isTraefik && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx index 288208fb1b..1a8b4c71b6 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx @@ -22,7 +22,7 @@ import { api } from "@/utils/api"; import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config"; const UpdateServerMiddlewareConfigSchema = z.object({ - traefikConfig: z.string(), + webServerConfig: z.string(), }); type UpdateServerMiddlewareConfig = z.infer< @@ -32,14 +32,24 @@ type UpdateServerMiddlewareConfig = z.infer< interface Props { path: string; serverId?: string; + activeProvider?: "traefik" | "caddy"; } -export const ShowTraefikFile = ({ path, serverId }: Props) => { +export const ShowTraefikFile = ({ path, serverId, activeProvider }: Props) => { + const providerLabel = + activeProvider === "caddy" + ? "Caddy" + : activeProvider === "traefik" + ? "Traefik" + : "Web Server"; + const isTraefik = activeProvider === "traefik"; const { data, refetch, isLoading: isLoadingFile, - } = api.settings.readTraefikFile.useQuery( + error: readError, + isError: isReadError, + } = api.settings.readWebServerFile.useQuery( { path, serverId, @@ -52,51 +62,64 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { mutateAsync, isPending, error, isError } = - api.settings.updateTraefikFile.useMutation(); + api.settings.updateWebServerFile.useMutation(); const form = useForm({ defaultValues: { - traefikConfig: "", + webServerConfig: "", }, - disabled: canEdit, + disabled: !isTraefik || canEdit, resolver: zodResolver(UpdateServerMiddlewareConfigSchema), }); useEffect(() => { form.reset({ - traefikConfig: data || "", + webServerConfig: data || "", }); }, [form, form.reset, data]); const onSubmit = async (data: UpdateServerMiddlewareConfig) => { + if (!isTraefik) { + return; + } if (!skipYamlValidation) { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); + const { valid, error } = validateAndFormatYAML(data.webServerConfig); if (!valid) { - form.setError("traefikConfig", { + form.setError("webServerConfig", { type: "manual", message: error || "Invalid YAML", }); return; } } - form.clearErrors("traefikConfig"); + form.clearErrors("webServerConfig"); await mutateAsync({ - traefikConfig: data.traefikConfig, + webServerConfig: data.webServerConfig, path, serverId, }) .then(async () => { - toast.success("Traefik config Updated"); + toast.success(`${providerLabel} config updated`); refetch(); }) .catch(() => { - toast.error("Error updating the Traefik config"); + toast.error(`Error updating the ${providerLabel} config`); }); }; return (
+ {isReadError && ( + {readError?.message} + )} {isError && {error?.message}} + {activeProvider === "caddy" && ( + + Caddy generated files are read-only from this view. Use Dokploy + settings, domains, and migration controls to change generated Caddy + config. + + )}
{ ) : ( ( - Traefik config + {providerLabel} config {path} -
- -
+ {isTraefik && ( +
+ +
+ )}
)} /> )}
-
-
- - setSkipYamlValidation(checked === true) - } - /> - -
-

- Traefik supports Go templating in dynamic configs (e.g.{" "} - {"{{range}}"}). Configs using - templates will fail standard YAML validation. Check this to save - without validation. -

-
- + {isTraefik && ( +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

+ Traefik supports Go templating in dynamic configs (e.g.{" "} + {"{{range}}"}). Configs using + templates will fail standard YAML validation. Check this to save + without validation. +

+
+ +
-
+ )}
diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx index 94a5c72a6d..1630959989 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx @@ -14,16 +14,39 @@ import { ShowTraefikFile } from "./show-traefik-file"; interface Props { serverId?: string; + activeProvider?: "traefik" | "caddy"; } -export const ShowTraefikSystem = ({ serverId }: Props) => { +export const ShowTraefikSystem = ({ serverId, activeProvider }: Props) => { const [file, setFile] = React.useState(null); + const { data: resolvedProvider } = + api.settings.getActiveWebServerProvider.useQuery( + { serverId }, + { enabled: !activeProvider }, + ); + const provider = activeProvider ?? resolvedProvider; + const providerLabel = + provider === "caddy" + ? "Caddy" + : provider === "traefik" + ? "Traefik" + : "Web Server"; + const isCaddy = provider === "caddy"; + const isTraefik = provider === "traefik"; + + React.useEffect(() => { + setFile(null); + }, []); + + React.useEffect(() => { + setFile(null); + }, [provider]); const { data: directories, isLoading, error, isError, - } = api.settings.readDirectories.useQuery( + } = api.settings.readWebServerDirectories.useQuery( { serverId, }, @@ -39,16 +62,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => { - Traefik File System + {providerLabel} File System - Manage all the files and directories in {"'/etc/dokploy/traefik'"} - . + {isCaddy + ? "Review generated Caddy artifacts such as caddy.json, route fragments, and non-backup migration artifacts." + : provider === "traefik" + ? "Manage files and directories for the active Traefik web server." + : "Review files and directories for the active web server."} - - Adding invalid configuration to existing files, can break your - Traefik instance, preventing access to your applications. + + {isCaddy + ? "Caddy generated config is read-only here. Use Dokploy settings, domains, and migration controls to change generated Caddy config." + : isTraefik + ? "Adding invalid configuration to existing files can break your Traefik instance, preventing access to your applications." + : "Review active web server files here. Provider-specific edit controls appear after Dokploy resolves the active provider."} @@ -70,8 +99,8 @@ export const ShowTraefikSystem = ({ serverId }: Props) => { {directories?.length === 0 && (
- No directories or files detected in{" "} - {"'/etc/dokploy/traefik'"} + No directories or files detected for the active{" "} + {providerLabel} web server.
@@ -87,7 +116,11 @@ export const ShowTraefikSystem = ({ serverId }: Props) => { />
{file ? ( - + ) : (
diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 841728dc10..d24eb3d51a 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -197,7 +197,7 @@ export const SearchCommand = () => { setOpen(false); }} > - Traefik + Web Server Files { diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index 65957a881c..5c6d7867d4 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -13,7 +13,8 @@ import { } from "@/components/ui/dropdown-menu"; import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; -import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; +import { CaddyTrustedProxySettings } from "../../web-server/caddy-trusted-proxy-settings"; +import { EditWebServerEnv } from "../../web-server/edit-web-server-env"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; import { ShowModalLogs } from "../../web-server/show-modal-logs"; @@ -21,16 +22,34 @@ interface Props { serverId?: string; } export const ShowTraefikActions = ({ serverId }: Props) => { - const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } = - api.settings.reloadTraefik.useMutation(); + const { data: activeProvider, isLoading: isLoadingProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const providerLabel = + activeProvider === "caddy" + ? "Caddy" + : activeProvider === "traefik" + ? "Traefik" + : "Web Server"; + const resourceName = + activeProvider === "caddy" + ? "dokploy-caddy" + : activeProvider === "traefik" + ? "dokploy-traefik" + : null; + + const { mutateAsync: reloadWebServer, isPending: reloadTraefikIsLoading } = + api.settings.reloadWebServer.useMutation(); const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } = api.settings.toggleDashboard.useMutation(); - const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } = - api.settings.haveTraefikDashboardPortEnabled.useQuery({ + const { data: webServerDashboardState, refetch: refetchDashboard } = + api.settings.getWebServerDashboardState.useQuery({ serverId, }); + const haveTraefikDashboardPortEnabled = + webServerDashboardState?.provider === "traefik" && + webServerDashboardState.enabled; const { execute: executeWithHealthCheck, @@ -50,7 +69,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => { } = useHealthCheckAfterMutation({ initialDelay: 5000, pollInterval: 4000, - successMessage: "Traefik Reloaded", + successMessage: `${providerLabel} reloaded`, }); return ( @@ -58,6 +77,8 @@ export const ShowTraefikActions = ({ serverId }: Props) => { { > @@ -84,12 +106,12 @@ export const ShowTraefikActions = ({ serverId }: Props) => { onClick={async () => { try { await executeReloadWithHealthCheck(() => - reloadTraefik({ serverId }), + reloadWebServer({ serverId }), ); } catch (error) { const errorMessage = (error as Error)?.message || - "Failed to reload Traefik. Please try again."; + `Failed to reload ${providerLabel}. Please try again.`; toast.error(errorMessage); } }} @@ -98,75 +120,90 @@ export const ShowTraefikActions = ({ serverId }: Props) => { > Reload - - e.preventDefault()} - className="cursor-pointer" + {resourceName && ( + - View Logs - - - + e.preventDefault()} + className="cursor-pointer" + > + View Logs + + + )} + e.preventDefault()} className="cursor-pointer" > Modify Environment - + - - - The Traefik container will be recreated from scratch. This - means the container will be deleted and created again, which - may cause downtime in your applications. - -

- Are you sure you want to{" "} - {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the - Traefik dashboard? -

-
- } - onClick={async () => { - try { - await executeWithHealthCheck(() => - toggleDashboard({ - enableDashboard: !haveTraefikDashboardPortEnabled, - serverId: serverId, - }), - ); - } catch (error) { - const errorMessage = - (error as Error)?.message || - "Failed to toggle dashboard. Please check if port 8080 is available."; - toast.error(errorMessage); + {activeProvider === "caddy" && ( + + e.preventDefault()} + className="cursor-pointer" + > + Trusted Proxies + + + )} + + {activeProvider === "traefik" && ( + - e.preventDefault()} - className="w-full cursor-pointer space-x-3" + description={ +
+ + The Traefik container will be recreated from scratch. This + means the container will be deleted and created again, which + may cause downtime in your applications. + +

+ Are you sure you want to{" "} + {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the + Traefik dashboard? +

+
+ } + onClick={async () => { + try { + await executeWithHealthCheck(() => + toggleDashboard({ + enableDashboard: !haveTraefikDashboardPortEnabled, + serverId: serverId, + }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + } + }} + disabled={toggleDashboardIsLoading || isHealthCheckExecuting} + type="default" > - - {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} - Dashboard - -
-
+ e.preventDefault()} + className="w-full cursor-pointer space-x-3" + > + + {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} + Dashboard + + + + )} e.preventDefault()} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 832d047593..034941e823 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -298,8 +298,9 @@ export const ShowServers = () => {

Configure and initialize your - server with Docker, Traefik, and - other essential services + server with Docker, the web + server, and other essential + services

diff --git a/apps/dokploy/components/dashboard/settings/servers/show-traefik-file-system-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-traefik-file-system-modal.tsx index c7f135acd9..34f88278fc 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-traefik-file-system-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-traefik-file-system-modal.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; import { ShowTraefikSystem } from "../../file-system/show-traefik-system"; interface Props { @@ -9,6 +10,16 @@ interface Props { export const ShowTraefikFileSystemModal = ({ serverId }: Props) => { const [isOpen, setIsOpen] = useState(false); + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery({ + serverId, + }); + const providerLabel = + activeProvider === "caddy" + ? "Caddy" + : activeProvider === "traefik" + ? "Traefik" + : "Web Server"; return ( @@ -17,11 +28,14 @@ export const ShowTraefikFileSystemModal = ({ serverId }: Props) => { className="w-full cursor-pointer " onSelect={(e) => e.preventDefault()} > - Show Traefik File System + Show {providerLabel} File System - + ); diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index e54140bbfa..3b01982432 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -457,9 +457,10 @@ export const AddUserPermissions = ({ userId, role }: Props) => { render={({ field }) => (
- Access to Traefik Files + Access to Web Server Files - Allow the user to access to the Traefik Tab Files + Allow the user to access the active web server file + browser
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx index abeba47c4b..2fd2c45630 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -30,7 +30,10 @@ type ServiceStatus = { type HealthResult = { postgres: ServiceStatus; redis: ServiceStatus; - traefik: ServiceStatus; + webServer: ServiceStatus & { + provider: "traefik" | "caddy"; + }; + traefik?: ServiceStatus; }; type ModalState = "idle" | "checking" | "results" | "updating"; @@ -55,6 +58,11 @@ const ServiceStatusItem = ({ ); +const webServerLabels: Record = { + traefik: "Traefik", + caddy: "Caddy", +}; + export const UpdateWebServer = () => { const [modalState, setModalState] = useState("idle"); const [open, setOpen] = useState(false); @@ -85,7 +93,7 @@ export const UpdateWebServer = () => { healthResult && healthResult.postgres.status === "healthy" && healthResult.redis.status === "healthy" && - healthResult.traefik.status === "healthy"; + healthResult.webServer.status === "healthy"; const checkIsUpdateFinished = async () => { try { @@ -174,7 +182,7 @@ export const UpdateWebServer = () => { {modalState === "checking" && ( - Checking PostgreSQL, Redis and Traefik... + Checking PostgreSQL, Redis and the active web server... )} @@ -190,8 +198,8 @@ export const UpdateWebServer = () => { service={healthResult.redis} /> diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index c9a4d5dfba..f7646ef0d0 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -188,10 +188,10 @@ const MENU: Menu = { }, { isSingle: true, - title: "Traefik File System", + title: "Web Server Files", url: "/dashboard/traefik", icon: GalleryVerticalEnd, - // Only enabled for users with access to Traefik files in non-cloud environments + // `traefikFiles` is the legacy permission key for active web-server files. isEnabled: ({ permissions, isCloud }) => !!(permissions?.traefikFiles.read && !isCloud), }, diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index 4df84bb14d..a9f3b76cb4 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -102,7 +102,7 @@ export const UserNav = () => { router.push("/dashboard/traefik"); }} > - Traefik + Web Server Files )} {permissions?.docker.read && ( diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index 51b31b84c7..07020d6695 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -76,8 +76,8 @@ const RESOURCE_META: Record = { description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)", }, traefikFiles: { - label: "Traefik Files", - description: "Access to the Traefik file system configuration", + label: "Web Server Files", + description: "Access to the active web server file browser", }, api: { label: "API / CLI", @@ -242,11 +242,11 @@ const ACTION_META: Record< traefikFiles: { read: { label: "Read", - description: "View Traefik configuration files", + description: "View active web server configuration files", }, write: { label: "Write", - description: "Edit and save Traefik configuration files", + description: "Edit and save active web server configuration files", }, }, api: { diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 8065b7dd93..fffbc286f9 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -16,6 +16,7 @@ import type { RedisNested } from "../databases/redis"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { spawnAsync } from "../process/spawnAsync"; import { getRemoteDocker } from "../servers/remote-docker"; +import type { WebServerProvider } from "../web-server/providers"; interface RegistryAuth { username: string; @@ -788,7 +789,7 @@ export const getComposeContainer = async ( } }; -type ServiceHealthStatus = { +export type ServiceHealthStatus = { status: "healthy" | "unhealthy"; message?: string; }; @@ -944,10 +945,11 @@ export const checkRedisHealth = async (): Promise => { } }; -export const checkTraefikHealth = async (): Promise => { - // Traefik can run as a standalone container or a swarm service +const checkDockerResourceHealth = async ( + resourceName: string, +): Promise => { try { - const container = docker.getContainer("dokploy-traefik"); + const container = docker.getContainer(resourceName); const info = await container.inspect(); if (!info.State.Running) { return { @@ -958,6 +960,21 @@ export const checkTraefikHealth = async (): Promise => { return { status: "healthy" }; } catch { // Not a standalone container, check as swarm service - return checkSwarmServiceRunning("dokploy-traefik"); + return checkSwarmServiceRunning(resourceName); } }; + +export const checkTraefikHealth = async (): Promise => { + return checkDockerResourceHealth("dokploy-traefik"); +}; + +export const checkWebServerHealth = async ( + provider: WebServerProvider, +): Promise => { + const resourceName = + provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik"; + return { + provider, + ...(await checkDockerResourceHealth(resourceName)), + }; +}; From 95100190cfedea8b355fd11d605eb4671ba72e26 Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 10 Jun 2026 19:09:11 -0400 Subject: [PATCH 02/14] fix(db): fail closed on runtime migration errors --- .../__test__/db/runtime-migration.test.ts | 85 +++++++++++++++++++ apps/dokploy/migration.ts | 24 ++---- apps/dokploy/server/db/migration.ts | 23 +---- apps/dokploy/server/db/run-migrations.ts | 29 +++++++ 4 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 apps/dokploy/__test__/db/runtime-migration.test.ts create mode 100644 apps/dokploy/server/db/run-migrations.ts diff --git a/apps/dokploy/__test__/db/runtime-migration.test.ts b/apps/dokploy/__test__/db/runtime-migration.test.ts new file mode 100644 index 0000000000..b7ddbca00e --- /dev/null +++ b/apps/dokploy/__test__/db/runtime-migration.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const db = { id: "db" }; + const sql = { end: vi.fn().mockResolvedValue(undefined) }; + + return { + db, + drizzle: vi.fn(() => db), + logger: { + error: vi.fn(), + log: vi.fn(), + }, + migrate: vi.fn().mockResolvedValue(undefined), + postgres: vi.fn(() => sql), + sql, + }; +}); + +vi.mock("@dokploy/server/db/constants", () => ({ + dbUrl: "postgres://dokploy:test@localhost:5432/dokploy", +})); + +vi.mock("postgres", () => ({ + default: mocks.postgres, +})); + +vi.mock("drizzle-orm/postgres-js", () => ({ + drizzle: mocks.drizzle, +})); + +vi.mock("drizzle-orm/postgres-js/migrator", () => ({ + migrate: mocks.migrate, +})); + +import { runRuntimeMigrations } from "../../server/db/run-migrations"; + +describe("runtime migrations", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sql.end.mockResolvedValue(undefined); + mocks.migrate.mockResolvedValue(undefined); + }); + + test("runs the default migration folder and closes the connection once", async () => { + await runRuntimeMigrations({ logger: mocks.logger }); + + expect(mocks.postgres).toHaveBeenCalledWith( + "postgres://dokploy:test@localhost:5432/dokploy", + { max: 1 }, + ); + expect(mocks.drizzle).toHaveBeenCalledWith(mocks.sql); + expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, { + migrationsFolder: "drizzle", + }); + expect(mocks.logger.log).toHaveBeenCalledWith("Migration complete"); + expect(mocks.logger.error).not.toHaveBeenCalled(); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); + + test("supports an explicit migration folder", async () => { + await runRuntimeMigrations({ + logger: mocks.logger, + migrationsFolder: "/app/drizzle", + }); + + expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, { + migrationsFolder: "/app/drizzle", + }); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); + + test("rejects migration failures and still closes the connection once", async () => { + const error = new Error("migration boom"); + mocks.migrate.mockRejectedValueOnce(error); + + await expect(runRuntimeMigrations({ logger: mocks.logger })).rejects.toBe( + error, + ); + + expect(mocks.logger.error).toHaveBeenCalledWith("Migration failed", error); + expect(mocks.logger.log).not.toHaveBeenCalled(); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/migration.ts b/apps/dokploy/migration.ts index 984197b2ae..0f077acdfa 100644 --- a/apps/dokploy/migration.ts +++ b/apps/dokploy/migration.ts @@ -1,19 +1,7 @@ -import { dbUrl } from "@dokploy/server/db"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; +import { runRuntimeMigrations } from "./server/db/run-migrations"; -const sql = postgres(dbUrl, { max: 1 }); -const db = drizzle(sql); - -await migrate(db, { migrationsFolder: "drizzle" }) - .then(() => { - console.log("Migration complete"); - sql.end(); - }) - .catch((error) => { - console.log("Migration failed", error); - }) - .finally(() => { - sql.end(); - }); +try { + await runRuntimeMigrations(); +} catch { + process.exit(1); +} diff --git a/apps/dokploy/server/db/migration.ts b/apps/dokploy/server/db/migration.ts index 8a24afdc50..da381cfea4 100644 --- a/apps/dokploy/server/db/migration.ts +++ b/apps/dokploy/server/db/migration.ts @@ -1,20 +1,5 @@ -import { dbUrl } from "@dokploy/server/db"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; +import { runRuntimeMigrations } from "./run-migrations"; -const sql = postgres(dbUrl, { max: 1 }); -const db = drizzle(sql); - -export const migration = async () => - await migrate(db, { migrationsFolder: "drizzle" }) - .then(() => { - console.log("Migration complete"); - sql.end(); - }) - .catch((error) => { - console.log("Migration failed", error); - }) - .finally(() => { - sql.end(); - }); +export const migration = async () => { + await runRuntimeMigrations(); +}; diff --git a/apps/dokploy/server/db/run-migrations.ts b/apps/dokploy/server/db/run-migrations.ts new file mode 100644 index 0000000000..202c5fbf24 --- /dev/null +++ b/apps/dokploy/server/db/run-migrations.ts @@ -0,0 +1,29 @@ +import { dbUrl } from "@dokploy/server/db/constants"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +export type RuntimeMigrationLogger = Pick; + +export type RunRuntimeMigrationsOptions = { + logger?: RuntimeMigrationLogger; + migrationsFolder?: string; +}; + +export const runRuntimeMigrations = async ({ + logger = console, + migrationsFolder = "drizzle", +}: RunRuntimeMigrationsOptions = {}) => { + const sql = postgres(dbUrl, { max: 1 }); + + try { + const db = drizzle(sql); + await migrate(db, { migrationsFolder }); + logger.log("Migration complete"); + } catch (error) { + logger.error("Migration failed", error); + throw error; + } finally { + await sql.end(); + } +}; From 1f4da760a084b1d7ec5ae5eadb5ef83d57a1e1b8 Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 10 Jun 2026 19:09:22 -0400 Subject: [PATCH 03/14] =?UTF-8?q?feat(caddy):=20core=20provider=20?= =?UTF-8?q?=E2=80=94=20setup,=20config=20generation,=20domain/compose=20ro?= =?UTF-8?q?utes,=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../git-provider/git-provider-access.test.ts | 2 +- .../advanced/redirects/handle-redirect.tsx | 5 +- .../advanced/redirects/show-redirects.tsx | 10 +- .../advanced/security/handle-security.tsx | 5 +- .../advanced/security/show-security.tsx | 10 +- .../application/web-server-config-cache.ts | 21 + apps/dokploy/server/api/routers/ai.ts | 19 +- .../dokploy/server/api/routers/application.ts | 42 + apps/dokploy/server/api/routers/compose.ts | 58 +- apps/dokploy/server/api/routers/domain.ts | 234 +++- apps/dokploy/server/api/routers/project.ts | 16 +- apps/dokploy/server/api/routers/settings.ts | 904 +++++++++++++- packages/server/src/constants/index.ts | 15 + packages/server/src/db/schema/domain.ts | 43 +- packages/server/src/db/schema/server.ts | 9 + packages/server/src/db/schema/shared.ts | 5 + .../src/db/schema/web-server-settings.ts | 97 +- packages/server/src/db/validations/domain.ts | 143 +-- packages/server/src/index.ts | 19 + packages/server/src/services/certificate.ts | 106 +- packages/server/src/services/compose.ts | 28 + packages/server/src/services/domain.ts | 153 ++- .../server/src/services/preview-deployment.ts | 96 +- packages/server/src/services/settings.ts | 390 +++++- packages/server/src/services/user.ts | 7 + .../src/services/web-server-settings.ts | 211 +++- packages/server/src/setup/caddy-setup.ts | 332 +++++ packages/server/src/setup/traefik-setup.ts | 73 +- packages/server/src/utils/builders/compose.ts | 6 +- packages/server/src/utils/caddy/compose.ts | 173 +++ packages/server/src/utils/caddy/config.ts | 1107 +++++++++++++++++ packages/server/src/utils/caddy/domain.ts | 210 ++++ packages/server/src/utils/caddy/types.ts | 97 ++ .../src/utils/caddy/upstream-targets.ts | 34 + packages/server/src/utils/caddy/web-server.ts | 100 ++ packages/server/src/utils/cluster/upload.ts | 2 +- packages/server/src/utils/docker/domain.ts | 353 +++++- .../server/src/utils/web-server/domain.ts | 31 + packages/server/src/utils/web-server/paths.ts | 29 + .../server/src/utils/web-server/providers.ts | 20 + 40 files changed, 4930 insertions(+), 285 deletions(-) create mode 100644 apps/dokploy/components/dashboard/application/web-server-config-cache.ts create mode 100644 packages/server/src/setup/caddy-setup.ts create mode 100644 packages/server/src/utils/caddy/compose.ts create mode 100644 packages/server/src/utils/caddy/config.ts create mode 100644 packages/server/src/utils/caddy/domain.ts create mode 100644 packages/server/src/utils/caddy/types.ts create mode 100644 packages/server/src/utils/caddy/upstream-targets.ts create mode 100644 packages/server/src/utils/caddy/web-server.ts create mode 100644 packages/server/src/utils/web-server/domain.ts create mode 100644 packages/server/src/utils/web-server/paths.ts create mode 100644 packages/server/src/utils/web-server/providers.ts diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts index 4ddf36244a..714607538e 100644 --- a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts +++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; import { canEditDeployGitSource, getAccessibleGitProviderIds, } from "@dokploy/server/services/git-provider"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockDb = vi.hoisted(() => ({ query: { diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx index 683e0ebbaf..7774cf8f75 100644 --- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx @@ -36,6 +36,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; +import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache"; const AddRedirectSchema = z.object({ regex: z.string().min(1, "Regex required"), @@ -133,9 +134,7 @@ export const HandleRedirect = ({ applicationId, }); refetch(); - await utils.application.readTraefikConfig.invalidate({ - applicationId, - }); + await invalidateApplicationWebServerConfig(utils, applicationId); onDialogToggle(false); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx index a14074ec59..e057af7677 100644 --- a/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx @@ -10,6 +10,7 @@ import { CardTitle, } from "@/components/ui/card"; import { api } from "@/utils/api"; +import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache"; import { HandleRedirect } from "./handle-redirect"; interface Props { @@ -97,11 +98,12 @@ export const ShowRedirects = ({ applicationId }: Props) => { await deleteRedirect({ redirectId: redirect.redirectId, }) - .then(() => { - refetch(); - utils.application.readTraefikConfig.invalidate({ + .then(async () => { + await refetch(); + await invalidateApplicationWebServerConfig( + utils, applicationId, - }); + ); toast.success("Redirect deleted successfully"); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx index 49a126881a..de1e9e0c37 100644 --- a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache"; const AddSecuritychema = z.object({ username: z.string().min(1, "Username is required"), @@ -85,9 +86,7 @@ export const HandleSecurity = ({ await utils.application.one.invalidate({ applicationId, }); - await utils.application.readTraefikConfig.invalidate({ - applicationId, - }); + await invalidateApplicationWebServerConfig(utils, applicationId); await refetch(); setIsOpen(false); }) diff --git a/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx b/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx index 724953afec..888d54bc5f 100644 --- a/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx @@ -13,6 +13,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; +import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache"; import { HandleSecurity } from "./handle-security"; interface Props { @@ -88,11 +89,12 @@ export const ShowSecurity = ({ applicationId }: Props) => { await deleteSecurity({ securityId: security.securityId, }) - .then(() => { - refetch(); - utils.application.readTraefikConfig.invalidate({ + .then(async () => { + await refetch(); + await invalidateApplicationWebServerConfig( + utils, applicationId, - }); + ); toast.success("Security deleted successfully"); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/web-server-config-cache.ts b/apps/dokploy/components/dashboard/application/web-server-config-cache.ts new file mode 100644 index 0000000000..f2cf9ed234 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/web-server-config-cache.ts @@ -0,0 +1,21 @@ +type ConfigCacheUtils = { + application: { + readTraefikConfig: { + invalidate(input: { applicationId: string }): Promise | unknown; + }; + readWebServerConfig: { + invalidate(input: { applicationId: string }): Promise | unknown; + }; + }; +}; + +export const invalidateApplicationWebServerConfig = async ( + utils: ConfigCacheUtils, + applicationId: string, +) => { + const input = { applicationId }; + await Promise.all([ + utils.application.readTraefikConfig.invalidate(input), + utils.application.readWebServerConfig.invalidate(input), + ]); +}; diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 81e03fe26a..fc8c43ee90 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -5,7 +5,7 @@ import { deploySuggestionSchema, } from "@dokploy/server/db/schema/ai"; import { - createDomain, + createComposeDomain, createMount, findEnvironmentById, } from "@dokploy/server/index"; @@ -352,12 +352,17 @@ ${input.logs}`, if (input.domains && input.domains?.length > 0) { for (const domain of input.domains) { - await createDomain({ - ...domain, - domainType: "compose", - certificateType: "none", - composeId: compose.composeId, - }); + await createComposeDomain( + compose, + { + ...domain, + domainType: "compose", + certificateType: "none", + composeId: compose.composeId, + }, + undefined, + ctx.session.activeOrganizationId, + ); } } if (input.configFiles && input.configFiles?.length > 0) { diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index de847a3014..3b1601be71 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1,8 +1,10 @@ import { clearOldDeployments, createApplication, + createCaddyApplicationRouteFragment, deleteAllMiddlewares, findApplicationById, + findDomainsByApplicationId, findEnvironmentById, findProjectById, getAccessibleServerIds, @@ -18,6 +20,7 @@ import { removeMonitoringDirectory, removeService, removeTraefikConfig, + resolveWebServerProvider, startService, startServiceRemote, stopService, @@ -794,6 +797,45 @@ export const applicationRouter = createTRPCRouter({ } return traefikConfig; }), + readWebServerConfig: protectedProcedure + .input(apiFindOneApplication) + .query(async ({ input, ctx }) => { + await checkServicePermissionAndAccess(ctx, input.applicationId, { + traefikFiles: ["read"], + }); + const application = await findApplicationById(input.applicationId); + const provider = await resolveWebServerProvider( + application.serverId || undefined, + ); + + if (provider === "traefik") { + if (application.serverId) { + return await readRemoteConfig( + application.serverId, + application.appName, + ); + } + return readConfig(application.appName); + } + + const domains = await findDomainsByApplicationId(input.applicationId); + const fragments = domains.map((domain) => + createCaddyApplicationRouteFragment( + application as never, + domain as never, + ), + ); + return `${JSON.stringify( + { + provider, + message: + "Generated Caddy route fragments for this application. Caddy manages HTTPS certificates automatically for HTTPS domains; Traefik custom certificate resolvers do not apply.", + fragments, + }, + null, + 2, + )}\n`; + }), dropDeployment: protectedProcedure .input( diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 126e80b1db..3ac53036b0 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1,11 +1,11 @@ import { - addDomainToCompose, + addDomainToComposeForWebServer, clearOldDeployments, cloneCompose, createCommand, createCompose, createComposeByTemplate, - createDomain, + createComposeDomain, createMount, deleteMount, execAsync, @@ -25,8 +25,8 @@ import { randomizeIsolatedDeploymentComposeFile, removeCompose, removeComposeDirectory, + removeComposeDomainsForWebServer, removeDeploymentsByComposeId, - removeDomainById, startCompose, stopCompose, updateCompose, @@ -409,7 +409,10 @@ export const composeRouter = createTRPCRouter({ }); const compose = await findComposeById(input.composeId); const domains = await findDomainsByComposeId(input.composeId); - const composeFile = await addDomainToCompose(compose, domains); + const composeFile = await addDomainToComposeForWebServer( + compose, + domains, + ); return stringify(composeFile, { lineWidth: 1000, }); @@ -666,13 +669,18 @@ export const composeRouter = createTRPCRouter({ if (generate.domains && generate.domains?.length > 0) { for (const domain of generate.domains) { - await createDomain({ - ...domain, - domainType: "compose", - certificateType: "none", - composeId: compose.composeId, - host: domain.host || "", - }); + await createComposeDomain( + compose, + { + ...domain, + domainType: "compose", + certificateType: "none", + composeId: compose.composeId, + host: domain.host || "", + }, + undefined, + ctx.session.activeOrganizationId, + ); } } @@ -959,9 +967,12 @@ export const composeRouter = createTRPCRouter({ await deleteMount(mount.mountId); } - for (const domain of compose.domains) { - await removeDomainById(domain.domainId); - } + await removeComposeDomainsForWebServer( + compose, + compose.domains, + undefined, + ctx.session.activeOrganizationId, + ); let serverIp = "127.0.0.1"; @@ -1022,13 +1033,18 @@ export const composeRouter = createTRPCRouter({ if (processedTemplate.domains && processedTemplate.domains.length > 0) { for (const domain of processedTemplate.domains) { - await createDomain({ - ...domain, - domainType: "compose", - certificateType: "none", - composeId: compose.composeId, - host: domain.host || "", - }); + await createComposeDomain( + compose, + { + ...domain, + domainType: "compose", + certificateType: "none", + composeId: compose.composeId, + host: domain.host || "", + }, + undefined, + ctx.session.activeOrganizationId, + ); } } diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 8210fcf8a5..0b975afc96 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -1,6 +1,9 @@ import { + assertCaddyDomainSupported, + createComposeDomain, createDomain, findApplicationById, + findComposeById, findDomainById, findDomainsByApplicationId, findDomainsByComposeId, @@ -8,9 +11,11 @@ import { findServerById, generateTraefikMeDomain, getWebServerSettings, - manageDomain, - removeDomain, + manageWebServerDomain, + refreshCaddyComposeRoutes, removeDomainById, + removeWebServerDomain, + resolveWebServerProvider, updateDomainById, validateDomain, } from "@dokploy/server"; @@ -31,6 +36,23 @@ import { apiUpdateDomain, } from "@/server/db/schema"; +const toDomainUpdateFields = ( + domain: Awaited>, +) => ({ + host: domain.host, + https: domain.https, + port: domain.port, + customEntrypoint: domain.customEntrypoint, + path: domain.path, + serviceName: domain.serviceName, + domainType: domain.domainType, + customCertResolver: domain.customCertResolver, + certificateType: domain.certificateType, + internalPath: domain.internalPath, + stripPath: domain.stripPath, + middlewares: domain.middlewares, +}); + export const domainRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateDomain) @@ -40,7 +62,23 @@ export const domainRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { domain: ["create"], }); - } else if (input.domainType === "application" && input.applicationId) { + const compose = await findComposeById(input.composeId); + const provider = await resolveWebServerProvider(compose.serverId); + const domain = await createComposeDomain( + compose, + input, + provider, + ctx.session.activeOrganizationId, + ); + await audit(ctx, { + action: "create", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return domain; + } + if (input.domainType === "application" && input.applicationId) { await checkServicePermissionAndAccess(ctx, input.applicationId, { domain: ["create"], }); @@ -118,6 +156,101 @@ export const domainRouter = createTRPCRouter({ }); } + const nextDomain = { ...currentDomain, ...input }; + if (currentDomain.applicationId) { + const application = await findApplicationById( + currentDomain.applicationId, + ); + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { + assertCaddyDomainSupported(nextDomain); + await manageWebServerDomain(application, nextDomain); + try { + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, currentDomain); + throw error; + } + } + } else if (currentDomain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + currentDomain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { + assertCaddyDomainSupported(nextDomain); + await manageWebServerDomain(application, nextDomain); + try { + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, currentDomain); + throw error; + } + } + } else if (currentDomain.composeId) { + const compose = await findComposeById(currentDomain.composeId); + if ((await resolveWebServerProvider(compose.serverId)) === "caddy") { + assertCaddyDomainSupported(nextDomain); + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + try { + await refreshCaddyComposeRoutes( + compose, + undefined, + "caddy", + ctx.session.activeOrganizationId, + ); + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await updateDomainById( + input.domainId, + toDomainUpdateFields(currentDomain), + ); + await refreshCaddyComposeRoutes( + compose, + undefined, + "caddy", + ctx.session.activeOrganizationId, + ); + throw error; + } + } + } + const result = await updateDomainById(input.domainId, input); const domain = await findDomainById(input.domainId); await audit(ctx, { @@ -128,7 +261,7 @@ export const domainRouter = createTRPCRouter({ }); if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - await manageDomain(application, domain); + await manageWebServerDomain(application, domain); } else if (domain.previewDeploymentId) { const previewDeployment = await findPreviewDeploymentById( domain.previewDeploymentId, @@ -137,7 +270,7 @@ export const domainRouter = createTRPCRouter({ previewDeployment.applicationId, ); application.appName = previewDeployment.appName; - await manageDomain(application, domain); + await manageWebServerDomain(application, domain); } return result; }), @@ -176,6 +309,86 @@ export const domainRouter = createTRPCRouter({ }); } + if (domain.applicationId) { + const application = await findApplicationById(domain.applicationId); + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { + await removeWebServerDomain(application, domain.uniqueConfigKey); + try { + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, domain); + throw error; + } + } + } else if (domain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + domain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { + await removeWebServerDomain(application, domain.uniqueConfigKey); + try { + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, domain); + throw error; + } + } + } else if (domain.composeId) { + const compose = await findComposeById(domain.composeId); + if ((await resolveWebServerProvider(compose.serverId)) === "caddy") { + const domains = await findDomainsByComposeId(compose.composeId); + const remainingDomains = domains.filter( + (item) => item.domainId !== domain.domainId, + ); + try { + await refreshCaddyComposeRoutes( + compose, + remainingDomains, + "caddy", + ctx.session.activeOrganizationId, + ); + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await refreshCaddyComposeRoutes( + compose, + undefined, + "caddy", + ctx.session.activeOrganizationId, + ); + throw error; + } + } + } + const result = await removeDomainById(input.domainId); await audit(ctx, { action: "delete", @@ -186,7 +399,16 @@ export const domainRouter = createTRPCRouter({ if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - await removeDomain(application, domain.uniqueConfigKey); + await removeWebServerDomain(application, domain.uniqueConfigKey); + } else if (domain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + domain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + await removeWebServerDomain(application, domain.uniqueConfigKey); } return result; diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..47af6cde03 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -2,6 +2,7 @@ import { createApplication, createBackup, createCompose, + createComposeDomain, createDomain, createLibsql, createMariadb, @@ -977,11 +978,16 @@ export const projectRouter = createTRPCRouter({ for (const domain of domains) { const { domainId, ...rest } = domain; - await createDomain({ - ...rest, - composeId: newCompose.composeId, - domainType: "compose", - }); + await createComposeDomain( + newCompose, + { + ...rest, + composeId: newCompose.composeId, + domainType: "compose", + }, + undefined, + ctx.session.activeOrganizationId, + ); } break; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index aff6650f17..a21ee258a9 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,10 +1,14 @@ +import { createReadStream, existsSync } from "node:fs"; +import { createInterface } from "node:readline"; import { + ACCESS_LOG_RETAINED_LINES, + applyCaddyMigration as applyCaddyMigrationCutover, CLEANUP_CRON_JOB, checkGPUStatus, checkPortInUse, checkPostgresHealth, checkRedisHealth, - checkTraefikHealth, + checkWebServerHealth, cleanupAll, cleanupAllBackground, cleanupBuilders, @@ -12,19 +16,29 @@ import { cleanupImages, cleanupSystem, cleanupVolumes, + compileWriteAndReloadCaddyConfigSafely, + compileWriteAndValidateCaddyConfigSafely, DEFAULT_UPDATE_DATA, execAsync, findServerById, + getCaddyCompileSettings, + getCaddyTrustedProxySettings, getDockerDiskUsage, getDokployImageTag, getLogCleanupStatus, getUpdateData, + getWebServerPaths, + getWebServerResourceName, getWebServerSettings, IS_CLOUD, + isCaddyReservedAdditionalPort, parseRawConfig, paths, + prepareCaddyMigration as prepareCaddyMigrationDryRun, prepareEnvironmentVariables, processLogs, + readCaddyConfigFileIfExists, + getCaddyMigrationReport as readCaddyMigrationReport, readConfig, readConfigInPath, readDirectory, @@ -33,20 +47,29 @@ import { readMonitoringConfig, readPorts, recreateDirectory, + reloadCaddyAfterValidation, reloadDockerResource, + resolveWebServerProvider, + rollbackCaddyMigration as rollbackCaddyMigrationCutover, sendDockerCleanupNotifications, setupGPUSupport, spawnAsync, startLogCleanup, stopLogCleanup, + updateCaddyTrustedProxySettings, updateLetsEncryptEmail, + updateLocalWebServerProvider, + updateRemoteWebServerProvider, updateServerById, + updateServerCaddy, updateServerTraefik, updateWebServerSettings, + type WebServerProvider, writeConfig, writeMainConfig, writeTraefikConfigInPath, writeTraefikSetup, + writeWebServerSetup, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { checkPermission } from "@dokploy/server/services/permission"; @@ -82,6 +105,349 @@ import { publicProcedure, } from "../trpc"; +const webServerProviderSchema = z.enum(["traefik", "caddy"]); + +const apiWebServerProvider = z.object({ + provider: webServerProviderSchema, + serverId: z.string().optional(), +}); + +const apiWebServerConfig = z.object({ + webServerConfig: z.string().min(1), + serverId: z.string().optional(), +}); + +const apiReadWebServerFile = z.object({ + path: z.string().min(1), + serverId: z.string().optional(), +}); + +const apiModifyWebServerFile = apiReadWebServerFile.extend({ + webServerConfig: z.string().min(1), +}); + +const apiWriteWebServerEnv = z.object({ + env: z.string(), + serverId: z.string().optional(), +}); + +const apiWebServerPorts = z.object({ + serverId: z.string().optional(), + additionalPorts: z.array( + z.object({ + targetPort: z.number(), + publishedPort: z.number(), + protocol: z.enum(["tcp", "udp", "sctp"]), + }), + ), +}); + +const apiCaddyTrustedProxySettings = z.object({ + serverId: z.string().optional(), + mode: z.enum(["disabled", "cloudflare", "static"]), + ranges: z.array(z.string()).optional().nullable(), + clientIpHeaders: z.array(z.string()).optional().nullable(), + strict: z.boolean().optional().nullable(), +}); + +const apiCaddyMigration = z.object({ + serverId: z.string().optional(), +}); + +const apiCaddyMigrationById = apiCaddyMigration.extend({ + migrationId: z.string().min(1), +}); + +const apiApplyCaddyMigration = apiCaddyMigrationById.extend({ + confirmMaintenanceWindow: z.literal(true), +}); + +const ensureServerAccess = async ( + ctx: { session?: { activeOrganizationId?: string | null } | null }, + serverId?: string, +) => { + if (!serverId) return; + const remoteServer = await findServerById(serverId); + if (remoteServer.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } +}; + +const normalizeWebServerPath = (filePath: string) => + filePath.replace(/\\/g, "/").replace(/\/+$/g, ""); + +const isPathWithin = (targetPath: string, basePath: string) => { + const normalizedTarget = normalizeWebServerPath(targetPath); + const normalizedBase = normalizeWebServerPath(basePath); + return ( + normalizedTarget === normalizedBase || + normalizedTarget.startsWith(`${normalizedBase}/`) + ); +}; + +const isCaddyMigrationBackupPath = (filePath: string, serverId?: string) => { + const caddyPaths = paths(!!serverId); + const normalizedPath = normalizeWebServerPath(filePath); + const migrationsPath = normalizeWebServerPath( + caddyPaths.CADDY_MIGRATIONS_PATH, + ); + if (!isPathWithin(normalizedPath, migrationsPath)) { + return false; + } + return normalizedPath + .slice(migrationsPath.length) + .split("/") + .filter(Boolean) + .includes("backups"); +}; + +const assertCaddyReadableFilePath = (filePath: string, serverId?: string) => { + const caddyPaths = paths(!!serverId); + const normalizedPath = normalizeWebServerPath(filePath); + if (isCaddyMigrationBackupPath(normalizedPath, serverId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy migration backups may contain TLS or internal configuration and are not readable from the file editor.", + }); + } + if ( + normalizedPath === normalizeWebServerPath(caddyPaths.CADDY_CONFIG_PATH) || + isPathWithin(normalizedPath, caddyPaths.CADDY_FRAGMENTS_PATH) || + isPathWithin(normalizedPath, caddyPaths.CADDY_MIGRATIONS_PATH) + ) { + return; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy file access is limited to caddy.json, route fragments, and non-backup migration artifacts.", + }); +}; + +type WebServerDirectoryNode = { + id: string; + name: string; + type: "file" | "directory"; + children?: WebServerDirectoryNode[]; +}; + +const pruneCaddyBackupDirectoryNodes = ( + nodes: WebServerDirectoryNode[], + serverId?: string, +): WebServerDirectoryNode[] => + nodes + .filter((node) => !isCaddyMigrationBackupPath(node.id, serverId)) + .map((node) => + node.children + ? { + ...node, + children: pruneCaddyBackupDirectoryNodes(node.children, serverId), + } + : node, + ); + +const readCaddySafeDirectoryTree = async (serverId?: string) => { + const caddyPaths = paths(!!serverId); + const readOptionalDirectory = async (dirPath: string) => { + try { + return await readDirectory(dirPath, serverId); + } catch (error) { + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return []; + } + throw error; + } + }; + return [ + { + id: caddyPaths.CADDY_CONFIG_PATH, + name: "caddy.json", + type: "file" as const, + }, + { + id: caddyPaths.CADDY_FRAGMENTS_PATH, + name: "fragments", + type: "directory" as const, + children: await readOptionalDirectory(caddyPaths.CADDY_FRAGMENTS_PATH), + }, + { + id: caddyPaths.CADDY_MIGRATIONS_PATH, + name: "migrations", + type: "directory" as const, + children: pruneCaddyBackupDirectoryNodes( + await readOptionalDirectory(caddyPaths.CADDY_MIGRATIONS_PATH), + serverId, + ), + }, + ]; +}; + +const resolveWebServerFilePath = ( + filePath: string, + provider: WebServerProvider, + serverId?: string, +) => { + if ( + filePath.includes("../") || + filePath.includes("..\\") || + filePath.includes("\0") || + filePath.includes("\x00") + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid path: path traversal or null bytes are not allowed", + }); + } + + const basePath = getWebServerPaths(provider, !!serverId).basePath; + if (filePath.startsWith("/")) { + if (filePath !== basePath && !filePath.startsWith(`${basePath}/`)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid path: outside of active web server directory", + }); + } + return filePath; + } + + return `${basePath}/${filePath.replace(/^\/+/, "")}`; +}; + +const getMainTraefikConfigPath = (serverId?: string) => + `${paths(!!serverId).MAIN_TRAEFIK_PATH}/traefik.yml`; + +const readProviderMainConfig = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider === "caddy") { + return readCaddyConfigFileIfExists({ serverId }); + } + + if (serverId) { + return readConfigInPath(getMainTraefikConfigPath(serverId), serverId); + } + return readMainConfig(); +}; + +const writeProviderMainConfig = async ( + provider: WebServerProvider, + content: string, + serverId?: string, +) => { + if (provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy caddy.json is generated from route fragments and is read-only. Use Caddy migration/domain actions to update it.", + }); + } + + if (serverId) { + await writeTraefikConfigInPath( + getMainTraefikConfigPath(serverId), + content, + serverId, + ); + return; + } + writeMainConfig(content); +}; + +const reloadWebServerProvider = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider === "caddy") { + await reloadCaddyAfterValidation(serverId); + return; + } + await reloadDockerResource(getWebServerResourceName(provider), serverId); +}; + +const getCaddySetupOptions = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider !== "caddy") return {}; + return getCaddyCompileSettings(serverId); +}; + +const getLocalAccessLogPath = (provider: WebServerProvider) => { + const currentPaths = paths(); + return provider === "caddy" + ? currentPaths.CADDY_ACCESS_LOG_PATH + : `${currentPaths.DYNAMIC_TRAEFIK_PATH}/access.log`; +}; + +const readAccessLogFile = async (filePath: string) => { + if (!existsSync(filePath)) { + return ""; + } + + const recentLines: string[] = []; + const fileStream = createReadStream(filePath, { encoding: "utf8" }); + const readline = createInterface({ + input: fileStream, + crlfDelay: Number.POSITIVE_INFINITY, + }); + for await (const line of readline) { + const trimmed = line.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + recentLines.push(line); + if (recentLines.length > ACCESS_LOG_RETAINED_LINES) { + recentLines.shift(); + } + } + } + return recentLines.length ? `${recentLines.join("\n")}\n` : ""; +}; + +const readActiveRequestAccessLog = async (readAll = false) => { + const provider = await resolveWebServerProvider(); + if (provider === "traefik") { + return (await readMonitoringConfig(readAll)) ?? ""; + } + return readAccessLogFile(getLocalAccessLogPath(provider)); +}; + +const getRequestAnalyticsState = async () => { + if (IS_CLOUD) { + return { + provider: "traefik" as const, + enabled: true, + reloadResourceName: "dokploy-traefik", + accessLogPath: getLocalAccessLogPath("traefik"), + }; + } + + const provider = await resolveWebServerProvider(); + if (provider === "caddy") { + const settings = await getWebServerSettings(); + return { + provider, + enabled: Boolean(settings?.requestLogsEnabled), + reloadResourceName: getWebServerResourceName(provider), + accessLogPath: getLocalAccessLogPath(provider), + }; + } + + const config = readMainConfig(); + const parsedConfig = config + ? (parse(config) as { accessLog?: { filePath?: string } }) + : null; + return { + provider, + enabled: Boolean(parsedConfig?.accessLog?.filePath), + reloadResourceName: getWebServerResourceName(provider), + accessLogPath: getLocalAccessLogPath(provider), + }; +}; + export const settingsRouter = createTRPCRouter({ getWebServerSettings: protectedProcedure.query(async () => { if (IS_CLOUD) { @@ -165,9 +531,223 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + getActiveWebServerProvider: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + return resolveWebServerProvider(input?.serverId); + }), + getCaddyTrustedProxySettings: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + if (IS_CLOUD && !input?.serverId) { + return { + mode: "disabled" as const, + ranges: [], + clientIpHeaders: [], + strict: true, + }; + } + + const settings = await getCaddyTrustedProxySettings(input?.serverId); + return ( + settings ?? { + mode: "disabled" as const, + ranges: [], + clientIpHeaders: [], + strict: true, + } + ); + }), + updateCaddyTrustedProxySettings: adminProcedure + .input(apiCaddyTrustedProxySettings) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy trusted proxy settings are only available for a local or remote web server.", + }); + } + + const previousSettings = await getCaddyTrustedProxySettings( + input.serverId, + ); + const nextSettings = + input.mode === "disabled" + ? null + : { + mode: input.mode, + ranges: input.ranges, + clientIpHeaders: input.clientIpHeaders, + strict: input.strict, + }; + + const provider = await resolveWebServerProvider(input.serverId); + await updateCaddyTrustedProxySettings(nextSettings, input.serverId); + try { + if (provider === "caddy") { + await compileWriteAndReloadCaddyConfigSafely({ + serverId: input.serverId, + ...(await getCaddyCompileSettings(input.serverId)), + }); + } + } catch (error) { + await updateCaddyTrustedProxySettings(previousSettings, input.serverId); + throw error; + } + + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "caddy-trusted-proxy", + }); + return ( + (await getCaddyTrustedProxySettings(input.serverId)) ?? { + mode: "disabled" as const, + ranges: [], + clientIpHeaders: [], + strict: true, + } + ); + }), + updateActiveWebServerProvider: adminProcedure + .input(apiWebServerProvider) + .mutation(async ({ input, ctx }) => { + if (input.provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy can only be activated through the migration apply flow after validation succeeds.", + }); + } + if (input.serverId) { + await ensureServerAccess(ctx, input.serverId); + } + const currentProvider = await resolveWebServerProvider(input.serverId); + if (currentProvider === "caddy" && input.provider === "traefik") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Use the Caddy migration rollback flow to return to Traefik safely.", + }); + } + if (input.serverId) { + await updateRemoteWebServerProvider(input.serverId, input.provider); + } else { + await updateLocalWebServerProvider(input.provider); + } + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-provider", + }); + return true; + }), + reloadWebServer: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + void reloadWebServerProvider(provider, input?.serverId).catch((err) => { + console.error("reloadWebServer background:", err); + }); + await audit(ctx, { + action: "reload", + resourceType: "settings", + resourceName: getWebServerResourceName(provider), + }); + return true; + }), + prepareCaddyMigration: adminProcedure + .input(apiCaddyMigration) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const report = await prepareCaddyMigrationDryRun({ + serverId: input.serverId, + }); + await audit(ctx, { + action: "create", + resourceType: "settings", + resourceName: "caddy-migration-dry-run", + }); + return report; + }), + getCaddyMigrationReport: adminProcedure + .input(apiCaddyMigrationById) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + return readCaddyMigrationReport({ + migrationId: input.migrationId, + serverId: input.serverId, + }); + }), + applyCaddyMigration: adminProcedure + .input(apiApplyCaddyMigration) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const report = await readCaddyMigrationReport({ + migrationId: input.migrationId, + serverId: input.serverId, + }); + if (report.summary.blockingWarnings > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot apply Caddy migration with ${report.summary.blockingWarnings} blocking warning(s)`, + }); + } + if (report.validation.status !== "passed") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Cannot apply Caddy migration because draft validation did not pass", + }); + } + void applyCaddyMigrationCutover({ + migrationId: input.migrationId, + serverId: input.serverId, + }).catch((err) => { + console.error("applyCaddyMigration background:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "caddy-migration-apply", + }); + return { started: true, migrationId: input.migrationId }; + }), + rollbackCaddyMigration: adminProcedure + .input(apiCaddyMigrationById) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + void rollbackCaddyMigrationCutover({ + migrationId: input.migrationId, + serverId: input.serverId, + }).catch((err) => { + console.error("rollbackCaddyMigration background:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "caddy-migration-rollback", + }); + return { started: true, migrationId: input.migrationId }; + }), toggleDashboard: adminProcedure .input(apiEnableDashboard) .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + if (provider !== "traefik") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The dashboard toggle is only available for Traefik. The Caddy admin API is kept local-only and is not exposed through Dokploy.", + }); + } + const ports = await readPorts("dokploy-traefik", input.serverId); const env = await readEnvironmentVariables( "dokploy-traefik", @@ -321,6 +901,14 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } + const previousSettings = await getWebServerSettings(); + if (!previousSettings) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Web server settings not found", + }); + } + const settings = await updateWebServerSettings({ host: input.host, letsEncryptEmail: input.letsEncryptEmail, @@ -335,9 +923,24 @@ export const settingsRouter = createTRPCRouter({ }); } - updateServerTraefik(settings, input.host); - if (input.letsEncryptEmail) { - updateLetsEncryptEmail(input.letsEncryptEmail); + const provider = await resolveWebServerProvider(); + if (provider === "caddy") { + try { + await updateServerCaddy(settings, input.host); + } catch (error) { + await updateWebServerSettings({ + host: previousSettings.host, + letsEncryptEmail: previousSettings.letsEncryptEmail, + certificateType: previousSettings.certificateType, + https: previousSettings.https, + }); + throw error; + } + } else { + updateServerTraefik(settings, input.host); + if (input.letsEncryptEmail) { + updateLetsEncryptEmail(input.letsEncryptEmail); + } } await audit(ctx, { @@ -513,6 +1116,38 @@ export const settingsRouter = createTRPCRouter({ return true; }), + readWebServerConfig: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + if (IS_CLOUD && !input?.serverId) { + return true; + } + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readProviderMainConfig(provider, input?.serverId); + }), + + updateWebServerConfig: adminProcedure + .input(apiWebServerConfig) + .mutation(async ({ input, ctx }) => { + if (IS_CLOUD && !input.serverId) { + return true; + } + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + await writeProviderMainConfig( + provider, + input.webServerConfig, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-config", + }); + return true; + }), + readWebServerTraefikConfig: adminProcedure.query(() => { if (IS_CLOUD) { return true; @@ -608,6 +1243,30 @@ export const settingsRouter = createTRPCRouter({ } }), + readWebServerDirectories: protectedProcedure + .input(apiServerSchema) + .query(async ({ ctx, input }) => { + await checkPermission(ctx, { traefikFiles: ["read"] }); + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + if (provider === "caddy") { + return readCaddySafeDirectoryTree(input?.serverId); + } + const { basePath } = getWebServerPaths(provider, !!input?.serverId); + try { + const result = await readDirectory(basePath, input?.serverId); + return result || []; + } catch (error) { + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return []; + } + throw error; + } + }), + updateTraefikFile: protectedProcedure .input(apiModifyTraefikConfig) .mutation(async ({ input, ctx }) => { @@ -640,6 +1299,53 @@ export const settingsRouter = createTRPCRouter({ return readConfigInPath(input.path, input.serverId); }), + updateWebServerFile: protectedProcedure + .input(apiModifyWebServerFile) + .mutation(async ({ input, ctx }) => { + await checkPermission(ctx, { traefikFiles: ["write"] }); + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + if (provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy generated config files are read-only from the file editor.", + }); + } + const filePath = resolveWebServerFilePath( + input.path, + provider, + input.serverId, + ); + await writeTraefikConfigInPath( + filePath, + input.webServerConfig, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-file", + }); + return true; + }), + + readWebServerFile: protectedProcedure + .input(apiReadWebServerFile) + .query(async ({ input, ctx }) => { + await checkPermission(ctx, { traefikFiles: ["read"] }); + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + const filePath = resolveWebServerFilePath( + input.path, + provider, + input.serverId, + ); + if (provider === "caddy") { + assertCaddyReadableFilePath(filePath, input.serverId); + } + return readConfigInPath(filePath, input.serverId); + }), getIp: protectedProcedure.query(async () => { if (IS_CLOUD) { return ""; @@ -761,6 +1467,16 @@ export const settingsRouter = createTRPCRouter({ ); return envVars; }), + readWebServerEnv: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readEnvironmentVariables( + getWebServerResourceName(provider), + input?.serverId, + ); + }), writeTraefikEnv: adminProcedure .input(z.object({ env: z.string(), serverId: z.string().optional() })) @@ -783,13 +1499,55 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + writeWebServerEnv: adminProcedure + .input(apiWriteWebServerEnv) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + const resourceName = getWebServerResourceName(provider); + const envs = prepareEnvironmentVariables(input.env); + const ports = await readPorts(resourceName, input.serverId); + + void writeWebServerSetup(provider, { + env: envs, + additionalPorts: ports, + serverId: input.serverId, + ...(await getCaddySetupOptions(provider, input.serverId)), + }).catch((err) => { + console.error("writeWebServerEnv background writeWebServerSetup:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-env", + }); + return true; + }), haveTraefikDashboardPortEnabled: adminProcedure .input(apiServerSchema) .query(async ({ input }) => { const ports = await readPorts("dokploy-traefik", input?.serverId); return ports.some((port) => port.targetPort === 8080); }), + getWebServerDashboardState: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + if (provider === "traefik") { + const ports = await readPorts("dokploy-traefik", input?.serverId); + return { + provider, + enabled: ports.some((port) => port.targetPort === 8080), + }; + } + + return { provider, enabled: false }; + }), + getRequestAnalyticsState: protectedProcedure.query(async () => { + return getRequestAnalyticsState(); + }), readStatsLogs: protectedProcedure .meta({ openapi: { @@ -807,7 +1565,7 @@ export const settingsRouter = createTRPCRouter({ totalCount: 0, }; } - const rawConfig = await readMonitoringConfig( + const rawConfig = await readActiveRequestAccessLog( !!input.dateRange?.start && !!input.dateRange?.end, ); @@ -847,26 +1605,14 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return []; } - const rawConfig = await readMonitoringConfig( + const rawConfig = await readActiveRequestAccessLog( !!input?.dateRange?.start || !!input?.dateRange?.end, ); const processedLogs = processLogs(rawConfig as string, input?.dateRange); return processedLogs || []; }), haveActivateRequests: protectedProcedure.query(async () => { - if (IS_CLOUD) { - return true; - } - const config = readMainConfig(); - - if (!config) return false; - const parsedConfig = parse(config) as { - accessLog?: { - filePath: string; - }; - }; - - return !!parsedConfig?.accessLog?.filePath; + return (await getRequestAnalyticsState()).enabled; }), toggleRequests: protectedProcedure .input( @@ -878,6 +1624,31 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } + const provider = await resolveWebServerProvider(); + if (provider === "caddy") { + const previousSettings = await getWebServerSettings(); + const previousEnabled = Boolean(previousSettings?.requestLogsEnabled); + await updateWebServerSettings({ + requestLogsEnabled: input.enable, + }); + try { + await compileWriteAndValidateCaddyConfigSafely( + await getCaddyCompileSettings(), + ); + } catch (error) { + await updateWebServerSettings({ + requestLogsEnabled: previousEnabled, + }); + throw error; + } + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "toggle-requests", + }); + return true; + } + const mainConfig = readMainConfig(); if (!mainConfig) return false; @@ -933,21 +1704,29 @@ export const settingsRouter = createTRPCRouter({ } }), checkInfrastructureHealth: adminProcedure.query(async () => { + const provider = await resolveWebServerProvider(); if (IS_CLOUD) { + const webServer = { provider, status: "healthy" as const }; return { postgres: { status: "healthy" as const }, redis: { status: "healthy" as const }, - traefik: { status: "healthy" as const }, + webServer, + traefik: { status: webServer.status }, }; } - const [postgres, redis, traefik] = await Promise.all([ + const [postgres, redis, webServer] = await Promise.all([ checkPostgresHealth(), checkRedisHealth(), - checkTraefikHealth(), + checkWebServerHealth(provider), ]); - return { postgres, redis, traefik }; + return { + postgres, + redis, + webServer, + traefik: { status: webServer.status, message: webServer.message }, + }; }), setupGPU: adminProcedure .input( @@ -1081,6 +1860,85 @@ export const settingsRouter = createTRPCRouter({ const ports = await readPorts("dokploy-traefik", input?.serverId); return ports; }), + updateWebServerPorts: adminProcedure + .input(apiWebServerPorts) + .mutation(async ({ input, ctx }) => { + try { + await ensureServerAccess(ctx, input.serverId); + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a serverId to update web server ports", + }); + } + const provider = await resolveWebServerProvider(input.serverId); + const resourceName = getWebServerResourceName(provider); + const env = await readEnvironmentVariables( + resourceName, + input.serverId, + ); + + if (provider === "caddy") { + const reservedPort = input.additionalPorts.find( + isCaddyReservedAdditionalPort, + ); + if (reservedPort) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Caddy port mapping ${reservedPort.publishedPort}->${reservedPort.targetPort}/${reservedPort.protocol ?? "tcp"} is reserved and cannot be published.`, + }); + } + } + + for (const port of input.additionalPorts) { + const portCheck = await checkPortInUse( + port.publishedPort, + input.serverId, + ); + if (portCheck.isInUse) { + throw new TRPCError({ + code: "CONFLICT", + message: `Port ${port.publishedPort} is already in use by ${portCheck.conflictingContainer}`, + }); + } + } + const preparedEnv = prepareEnvironmentVariables(env); + + void writeWebServerSetup(provider, { + env: preparedEnv, + additionalPorts: input.additionalPorts, + serverId: input.serverId, + ...(await getCaddySetupOptions(provider, input.serverId)), + }).catch((err) => { + console.error( + "updateWebServerPorts background writeWebServerSetup:", + err, + ); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-ports", + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Error updating web server ports", + cause: error, + }); + } + }), + getWebServerPorts: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readPorts(getWebServerResourceName(provider), input?.serverId); + }), updateLogCleanup: protectedProcedure .input( z.object({ diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index 706a0dbecb..b25c620e52 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -12,6 +12,7 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT : undefined; export const CLEANUP_CRON_JOB = "50 23 * * *"; +export const ACCESS_LOG_RETAINED_LINES = 1000; type DockerSocketCandidate = { label: string; @@ -90,11 +91,25 @@ export const paths = (isServer = false) => { : path.join(process.cwd(), ".docker"); const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`; const DYNAMIC_TRAEFIK_PATH = `${MAIN_TRAEFIK_PATH}/dynamic`; + const MAIN_CADDY_PATH = `${BASE_PATH}/caddy`; + const CADDY_CONFIG_PATH = `${MAIN_CADDY_PATH}/caddy.json`; + const CADDY_FRAGMENTS_PATH = `${MAIN_CADDY_PATH}/fragments`; + const CADDY_ACCESS_LOG_PATH = `${MAIN_CADDY_PATH}/access.log`; + const CADDY_DATA_PATH = `${MAIN_CADDY_PATH}/data`; + const CADDY_CONFIG_DIR_PATH = `${MAIN_CADDY_PATH}/config`; + const CADDY_MIGRATIONS_PATH = `${MAIN_CADDY_PATH}/migrations`; return { BASE_PATH, MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH, + MAIN_CADDY_PATH, + CADDY_CONFIG_PATH, + CADDY_FRAGMENTS_PATH, + CADDY_ACCESS_LOG_PATH, + CADDY_DATA_PATH, + CADDY_CONFIG_DIR_PATH, + CADDY_MIGRATIONS_PATH, LOGS_PATH: `${BASE_PATH}/logs`, APPLICATIONS_PATH: `${BASE_PATH}/applications`, COMPOSE_PATH: `${BASE_PATH}/compose`, diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts index 092275fde2..28ee0d6e71 100644 --- a/packages/server/src/db/schema/domain.ts +++ b/packages/server/src/db/schema/domain.ts @@ -11,7 +11,7 @@ import { import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { domain } from "../validations/domain"; +import { domain, validateDomainSettings } from "../validations/domain"; import { applications } from "./application"; import { compose } from "./compose"; import { previewDeployments } from "./preview-deployments"; @@ -79,24 +79,26 @@ const createSchema = createInsertSchema(domains, { domainType: z.enum(["compose", "application", "preview"]).optional(), }); -export const apiCreateDomain = createSchema.pick({ - host: true, - path: true, - port: true, - customEntrypoint: true, - https: true, - applicationId: true, - certificateType: true, - customCertResolver: true, - composeId: true, - serviceName: true, - domainType: true, - previewDeploymentId: true, - internalPath: true, - stripPath: true, - middlewares: true, - forwardAuthEnabled: true, -}); +export const apiCreateDomain = createSchema + .pick({ + host: true, + path: true, + port: true, + customEntrypoint: true, + https: true, + applicationId: true, + certificateType: true, + customCertResolver: true, + composeId: true, + serviceName: true, + domainType: true, + previewDeploymentId: true, + internalPath: true, + stripPath: true, + middlewares: true, + forwardAuthEnabled: true, + }) + .superRefine(validateDomainSettings); export const apiFindDomain = z.object({ domainId: z.string().min(1), @@ -130,4 +132,5 @@ export const apiUpdateDomain = createSchema middlewares: true, forwardAuthEnabled: true, }) - .merge(createSchema.pick({ domainId: true }).required()); + .merge(createSchema.pick({ domainId: true }).required()) + .superRefine(validateDomainSettings); diff --git a/packages/server/src/db/schema/server.ts b/packages/server/src/db/schema/server.ts index 5a239f00d5..f228e049a6 100644 --- a/packages/server/src/db/schema/server.ts +++ b/packages/server/src/db/schema/server.ts @@ -10,6 +10,7 @@ import { import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; +import type { CaddyTrustedProxySettings } from "../../utils/caddy/types"; import { organization } from "./account"; import { applications } from "./application"; import { certificates } from "./certificate"; @@ -22,6 +23,7 @@ import { mysql } from "./mysql"; import { postgres } from "./postgres"; import { redis } from "./redis"; import { schedules } from "./schedule"; +import { webServerProvider } from "./shared"; import { sshKeys } from "./ssh-key"; import { generateAppName } from "./utils"; export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]); @@ -41,6 +43,12 @@ export const server = pgTable("server", { .notNull() .$defaultFn(() => generateAppName("server")), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), + webServerProvider: webServerProvider("webServerProvider") + .notNull() + .default("traefik"), + caddyTrustedProxyConfig: jsonb("caddyTrustedProxyConfig") + .$type() + .default(null), createdAt: text("createdAt").notNull(), organizationId: text("organizationId") .notNull() @@ -136,6 +144,7 @@ const createSchema = createInsertSchema(server, { name: z.string().min(1), description: z.string().optional(), serverType: z.enum(["deploy", "build"]).optional(), + webServerProvider: z.enum(["traefik", "caddy"]).optional(), }); export const apiCreateServer = createSchema diff --git a/packages/server/src/db/schema/shared.ts b/packages/server/src/db/schema/shared.ts index 6566968c64..2ab58c79bd 100644 --- a/packages/server/src/db/schema/shared.ts +++ b/packages/server/src/db/schema/shared.ts @@ -14,6 +14,11 @@ export const certificateType = pgEnum("certificateType", [ "custom", ]); +export const webServerProvider = pgEnum("webServerProvider", [ + "traefik", + "caddy", +]); + export const triggerType = pgEnum("triggerType", ["push", "tag"]); export const sqldNode = pgEnum("sqldNode", ["primary", "replica"]); diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index a44f1356a6..4d5904da42 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -3,7 +3,8 @@ import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { certificateType } from "./shared"; +import type { CaddyTrustedProxySettings } from "../../utils/caddy/types"; +import { certificateType, webServerProvider } from "./shared"; export const webServerSettings = pgTable("webServerSettings", { id: text("id") @@ -11,6 +12,13 @@ export const webServerSettings = pgTable("webServerSettings", { .primaryKey() .$defaultFn(() => nanoid()), // Web Server Configuration + webServerProvider: webServerProvider("webServerProvider") + .notNull() + .default("traefik"), + caddyTrustedProxyConfig: jsonb("caddyTrustedProxyConfig") + .$type() + .default(null), + requestLogsEnabled: boolean("requestLogsEnabled").notNull().default(false), serverIp: text("serverIp"), certificateType: certificateType("certificateType").notNull().default("none"), https: boolean("https").notNull().default(false), @@ -123,45 +131,58 @@ const createSchema = createInsertSchema(webServerSettings, { id: z.string().min(1), }); -export const apiUpdateWebServerSettings = createSchema.partial().extend({ - serverIp: z.string().optional(), - certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), - https: z.boolean().optional(), - host: z.string().optional(), - letsEncryptEmail: z.string().email().optional().nullable(), - sshPrivateKey: z.string().optional(), - enableDockerCleanup: z.boolean().optional(), - logCleanupCron: z.string().optional().nullable(), - metricsConfig: z - .object({ - server: z.object({ - type: z.enum(["Dokploy", "Remote"]), - refreshRate: z.number(), - port: z.number(), - token: z.string(), - urlCallback: z.string(), - retentionDays: z.number(), - cronJob: z.string(), - thresholds: z.object({ - cpu: z.number(), - memory: z.number(), +export const apiUpdateWebServerSettings = createSchema + .omit({ requestLogsEnabled: true }) + .partial() + .extend({ + webServerProvider: z.enum(["traefik", "caddy"]).optional(), + caddyTrustedProxyConfig: z + .object({ + mode: z.enum(["disabled", "cloudflare", "static"]), + ranges: z.array(z.string()).optional().nullable(), + clientIpHeaders: z.array(z.string()).optional().nullable(), + strict: z.boolean().optional().nullable(), + }) + .optional() + .nullable(), + serverIp: z.string().optional(), + certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), + https: z.boolean().optional(), + host: z.string().optional(), + letsEncryptEmail: z.string().email().optional().nullable(), + sshPrivateKey: z.string().optional(), + enableDockerCleanup: z.boolean().optional(), + logCleanupCron: z.string().optional().nullable(), + metricsConfig: z + .object({ + server: z.object({ + type: z.enum(["Dokploy", "Remote"]), + refreshRate: z.number(), + port: z.number(), + token: z.string(), + urlCallback: z.string(), + retentionDays: z.number(), + cronJob: z.string(), + thresholds: z.object({ + cpu: z.number(), + memory: z.number(), + }), }), - }), - containers: z.object({ - refreshRate: z.number(), - services: z.object({ - include: z.array(z.string()), - exclude: z.array(z.string()), + containers: z.object({ + refreshRate: z.number(), + services: z.object({ + include: z.array(z.string()), + exclude: z.array(z.string()), + }), }), - }), - }) - .optional(), - cleanupCacheApplications: z.boolean().optional(), - cleanupCacheOnPreviews: z.boolean().optional(), - cleanupCacheOnCompose: z.boolean().optional(), - remoteServersOnly: z.boolean().optional(), - enforceSSO: z.boolean().optional(), -}); + }) + .optional(), + cleanupCacheApplications: z.boolean().optional(), + cleanupCacheOnPreviews: z.boolean().optional(), + cleanupCacheOnCompose: z.boolean().optional(), + remoteServersOnly: z.boolean().optional(), + enforceSSO: z.boolean().optional(), + }); export const apiAssignDomain = z .object({ diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts index 62447bee72..fce929d5b5 100644 --- a/packages/server/src/db/validations/domain.ts +++ b/packages/server/src/db/validations/domain.ts @@ -1,5 +1,62 @@ import { z } from "zod"; +type DomainSettingsValidationInput = { + https?: boolean | null; + certificateType?: "letsencrypt" | "none" | "custom" | null; + customCertResolver?: string | null; + stripPath?: boolean | null; + path?: string | null; + internalPath?: string | null; +}; + +export const validateDomainSettings = ( + input: DomainSettingsValidationInput, + ctx: z.RefinementCtx, +) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } + + if ( + input.https && + input.certificateType === "custom" && + !input.customCertResolver + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCertResolver"], + message: "Required when certificate type is custom", + }); + } + + // Validate stripPath requires a valid path + 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", + }); + } + + // Validate internalPath starts with / + if ( + input.internalPath && + input.internalPath !== "/" && + !input.internalPath.startsWith("/") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["internalPath"], + message: "Internal path must start with '/'", + }); + } +}; + export const domain = z .object({ host: z @@ -19,49 +76,10 @@ export const domain = z .optional(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), - customCertResolver: z.string(), + customCertResolver: z.string().optional(), middlewares: z.array(z.string()).optional(), }) - .superRefine((input, ctx) => { - if (input.https && !input.certificateType) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["certificateType"], - message: "Required", - }); - } - - if (input.certificateType === "custom" && !input.customCertResolver) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["customCertResolver"], - message: "Required when certificate type is custom", - }); - } - - // Validate stripPath requires a valid path - 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", - }); - } - - // Validate internalPath starts with / - if ( - input.internalPath && - input.internalPath !== "/" && - !input.internalPath.startsWith("/") - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["internalPath"], - message: "Internal path must start with '/'", - }); - } - }); + .superRefine(validateDomainSettings); export const domainCompose = z .object({ @@ -82,47 +100,8 @@ export const domainCompose = z .optional(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), - customCertResolver: z.string(), + customCertResolver: z.string().optional(), serviceName: z.string().min(1, { message: "Service name is required" }), middlewares: z.array(z.string()).optional(), }) - .superRefine((input, ctx) => { - if (input.https && !input.certificateType) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["certificateType"], - message: "Required", - }); - } - - if (input.certificateType === "custom" && !input.customCertResolver) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["customCertResolver"], - message: "Required when certificate type is custom", - }); - } - - // Validate stripPath requires a valid path - 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", - }); - } - - // Validate internalPath starts with / - if ( - input.internalPath && - input.internalPath !== "/" && - !input.internalPath.startsWith("/") - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["internalPath"], - message: "Internal path must start with '/'", - }); - } - }); + .superRefine(validateDomainSettings); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bda4615ad..1cae91f687 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -50,6 +50,7 @@ export * from "./services/ssh-key"; export * from "./services/user"; export * from "./services/volume-backups"; export * from "./services/web-server-settings"; +export * from "./setup/caddy-setup"; export * from "./setup/config-paths"; export * from "./setup/forward-auth-setup"; export * from "./setup/monitoring-setup"; @@ -87,6 +88,21 @@ export * from "./utils/builders/nixpacks"; export * from "./utils/builders/paketo"; export * from "./utils/builders/static"; export * from "./utils/builders/utils"; +export * from "./utils/caddy/compose"; +export * from "./utils/caddy/config"; +export * from "./utils/caddy/domain"; +export * from "./utils/caddy/migration/apply"; +export * from "./utils/caddy/migration/compose-label-translator"; +export * from "./utils/caddy/migration/dynamic-file-translator"; +export * from "./utils/caddy/migration/files"; +export * from "./utils/caddy/migration/prepare"; +export * from "./utils/caddy/migration/rollback"; +export * from "./utils/caddy/migration/traefik-rule-parser"; +export * from "./utils/caddy/migration/types"; +export * from "./utils/caddy/migration/upstream-preflight"; +export * from "./utils/caddy/types"; +export * from "./utils/caddy/upstream-targets"; +export * from "./utils/caddy/web-server"; export * from "./utils/cluster/upload"; export * from "./utils/crons/enterprise"; export * from "./utils/databases/rebuild"; @@ -137,5 +153,8 @@ 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/web-server/domain"; +export * from "./utils/web-server/paths"; +export * from "./utils/web-server/providers"; export * from "./verification/send-verification-email"; export * from "./wss/utils"; diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts index aa5c3983c2..072650b768 100644 --- a/packages/server/src/services/certificate.ts +++ b/packages/server/src/services/certificate.ts @@ -5,14 +5,17 @@ import { db } from "@dokploy/server/db"; import { type apiCreateCertificate, certificates, + domains, } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; +import { quote } from "shell-quote"; import { stringify } from "yaml"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; import { execAsyncRemote } from "../utils/process/execAsync"; +import { resolveWebServerProvider } from "./web-server-settings"; export type Certificate = typeof certificates.$inferSelect; @@ -31,6 +34,97 @@ export const findCertificateById = async (certificateId: string) => { return certificate; }; +export const findCertificateByPath = async (certificatePath: string) => { + return db.query.certificates.findFirst({ + where: eq(certificates.certificatePath, certificatePath), + }); +}; + +export const assertCertificatePathAvailableForServer = async ( + certificatePath: string, + serverId?: string | null, + organizationId?: string | null, +) => { + if (!organizationId) { + throw new Error( + "Caddy custom certificate validation requires organization context.", + ); + } + + const certificate = await findCertificateByPath(certificatePath); + const expectedServerId = serverId ?? null; + + if ( + !certificate || + (certificate.serverId ?? null) !== expectedServerId || + certificate.organizationId !== organizationId + ) { + throw new Error( + `Caddy custom certificate "${certificatePath}" is not available for this server and organization. Use an uploaded certificate assigned to the same server and project organization.`, + ); + } + + await assertCertificateFilesReadable(certificate); + + return certificate; +}; + +const assertCertificateFilesReadable = async (certificate: Certificate) => { + const { CERTIFICATES_PATH } = paths(!!certificate.serverId); + const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath); + const crtPath = path.join(certDir, "chain.crt"); + const keyPath = path.join(certDir, "privkey.key"); + + try { + if (certificate.serverId) { + await execAsyncRemote( + certificate.serverId, + `test -r ${quote([crtPath])} && test -r ${quote([keyPath])}`, + ); + return; + } + + await fs.promises.access(crtPath, fs.constants.R_OK); + await fs.promises.access(keyPath, fs.constants.R_OK); + } catch { + throw new Error( + `Caddy custom certificate "${certificate.certificatePath}" is missing readable chain.crt or privkey.key files.`, + ); + } +}; + +const findActiveCaddyDomainUsingCertificate = async ( + certificate: Certificate, +) => { + const provider = await resolveWebServerProvider(certificate.serverId); + if (provider !== "caddy") { + return null; + } + + return db.query.domains.findFirst({ + where: and( + eq(domains.customCertResolver, certificate.certificatePath), + eq(domains.certificateType, "custom"), + eq(domains.https, true), + ), + }); +}; + +const assertCertificateNotUsedByActiveCaddyDomain = async ( + certificate: Certificate, + action: "delete" | "update", +) => { + const domain = await findActiveCaddyDomainUsingCertificate(certificate); + if (!domain) { + return; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot ${action} certificate "${certificate.name}" because active Caddy domain "${domain.host}" uses it. Change or remove that domain certificate first.`, + }); +}; + export const createCertificate = async ( certificateData: z.infer, organizationId: string, @@ -52,18 +146,19 @@ export const createCertificate = async ( const cer = certificate[0]; - createCertificateFiles(cer); + await createCertificateFiles(cer); return cer; }; export const removeCertificateById = async (certificateId: string) => { const certificate = await findCertificateById(certificateId); + await assertCertificateNotUsedByActiveCaddyDomain(certificate, "delete"); const { CERTIFICATES_PATH } = paths(!!certificate.serverId); const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath); if (certificate.serverId) { - await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`); + await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`); } else { await removeDirectoryIfExistsContent(certDir); } @@ -135,6 +230,11 @@ export const updateCertificate = async ( privateKey?: string; }, ) => { + const current = await findCertificateById(certificateId); + if (updates.certificateData || updates.privateKey) { + await assertCertificateNotUsedByActiveCaddyDomain(current, "update"); + } + const updated = await db .update(certificates) .set({ diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 7a887cdc41..4e2fc2e1dd 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -11,8 +11,10 @@ import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose"; import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose"; import { cloneCompose, + getCaddyComposeRouteTargetsForWebServer, loadDockerCompose, loadDockerComposeRemote, + writeCaddyComposeRoutesForTargets, } from "@dokploy/server/utils/docker/domain"; import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error"; @@ -271,6 +273,9 @@ export const deployCompose = async ({ } } + const caddyComposeRouteTargets = + await getCaddyComposeRouteTargetsForWebServer(entity, compose.domains); + command = "set -e;"; command += await getBuildComposeCommand(entity); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -280,6 +285,16 @@ export const deployCompose = async ({ await execAsync(commandWithLog); } + if (caddyComposeRouteTargets) { + await writeCaddyComposeRoutesForTargets( + entity, + caddyComposeRouteTargets, + { + organizationId: compose.environment.project.organizationId, + }, + ); + } + await updateDeploymentStatus(deployment.deploymentId, "done"); await updateCompose(composeId, { composeStatus: "done", @@ -385,6 +400,9 @@ export const rebuildCompose = async ({ } } + const caddyComposeRouteTargets = + await getCaddyComposeRouteTargetsForWebServer(compose, compose.domains); + command = "set -e;"; command += await getBuildComposeCommand(compose); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -394,6 +412,16 @@ export const rebuildCompose = async ({ await execAsync(commandWithLog); } + if (caddyComposeRouteTargets) { + await writeCaddyComposeRoutesForTargets( + compose, + caddyComposeRouteTargets, + { + organizationId: compose.environment.project.organizationId, + }, + ); + } + await updateDeploymentStatus(deployment.deploymentId, "done"); await updateCompose(composeId, { composeStatus: "done", diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index e1460fd482..710085b12f 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -1,21 +1,30 @@ import dns from "node:dns"; import { promisify } from "node:util"; import { db } from "@dokploy/server/db"; -import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; +import { + getWebServerSettings, + resolveWebServerProvider, +} from "@dokploy/server/services/web-server-settings"; import { generateRandomDomain } from "@dokploy/server/templates"; -import { manageDomain } from "@dokploy/server/utils/traefik/domain"; +import { + getCaddyComposeRouteTargetsForWebServer, + writeCaddyComposeRoutesForTargets, +} from "@dokploy/server/utils/docker/domain"; +import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain"; +import type { WebServerProvider } from "@dokploy/server/utils/web-server/providers"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { z } from "zod"; import { type apiCreateDomain, domains } from "../db/schema"; import { findApplicationById } from "./application"; import { detectCDNProvider } from "./cdn"; +import type { Compose } from "./compose"; import { findServerById } from "./server"; export type Domain = typeof domains.$inferSelect; export const createDomain = async (input: z.infer) => { - const result = await db.transaction(async (tx) => { + const domain = await db.transaction(async (tx) => { const domain = await tx .insert(domains) .values({ @@ -32,15 +41,20 @@ export const createDomain = async (input: z.infer) => { }); } - if (domain.applicationId) { - const application = await findApplicationById(domain.applicationId); - await manageDomain(application, domain); - } - return domain; }); - return result; + if (domain.applicationId) { + const application = await findApplicationById(domain.applicationId); + try { + await manageWebServerDomain(application, domain); + } catch (error) { + await removeDomainById(domain.domainId).catch(() => undefined); + throw error; + } + } + + return domain; }; export const generateTraefikMeDomain = async ( @@ -140,6 +154,125 @@ export const removeDomainById = async (domainId: string) => { return result[0]; }; +export const refreshCaddyComposeRoutes = async ( + compose: Compose, + domainsInput?: Domain[], + provider?: WebServerProvider, + organizationId?: string | null, +) => { + const domainsArray = + domainsInput ?? (await findDomainsByComposeId(compose.composeId)); + const routeTargets = await getCaddyComposeRouteTargetsForWebServer( + compose, + domainsArray, + provider, + ); + if (routeTargets) { + await writeCaddyComposeRoutesForTargets(compose, routeTargets, { + organizationId, + }); + } +}; + +export const createComposeDomain = async ( + compose: Compose, + input: z.infer, + provider?: WebServerProvider, + organizationId?: string | null, +) => { + const domain = await createDomain(input); + try { + await refreshCaddyComposeRoutes( + compose, + undefined, + provider, + organizationId, + ); + return domain; + } catch (error) { + await removeDomainById(domain.domainId); + await refreshCaddyComposeRoutes( + compose, + undefined, + provider, + organizationId, + ); + throw error; + } +}; + +export const removeComposeDomainsForWebServer = async ( + compose: Compose, + domainsToRemove: Domain[], + provider?: WebServerProvider, + organizationId?: string | null, +) => { + if (domainsToRemove.length === 0) { + return []; + } + + const resolvedProvider = + provider ?? (await resolveWebServerProvider(compose.serverId)); + const currentDomains = await findDomainsByComposeId(compose.composeId); + const domainIdsToRemove = new Set( + domainsToRemove.map((domain) => domain.domainId), + ); + const removableDomains = currentDomains.filter((domain) => + domainIdsToRemove.has(domain.domainId), + ); + + if (removableDomains.length === 0) { + return []; + } + + if (resolvedProvider !== "caddy") { + return db.transaction(async (tx) => + tx + .delete(domains) + .where( + inArray( + domains.domainId, + removableDomains.map((domain) => domain.domainId), + ), + ) + .returning(), + ); + } + + const remainingDomains = currentDomains.filter( + (domain) => !domainIdsToRemove.has(domain.domainId), + ); + + await refreshCaddyComposeRoutes( + compose, + remainingDomains, + resolvedProvider, + organizationId, + ); + + try { + return await db.transaction(async (tx) => + tx + .delete(domains) + .where( + inArray( + domains.domainId, + removableDomains.map((domain) => domain.domainId), + ), + ) + .returning(), + ); + } catch (error) { + await refreshCaddyComposeRoutes( + compose, + currentDomains, + resolvedProvider, + organizationId, + ); + throw error; + } +}; + export const getDomainHost = (domain: Domain) => { return `${domain.https ? "https" : "http"}://${domain.host}`; }; diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 20f64259bc..8acf57d0dd 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -13,10 +13,13 @@ import { removeService } from "../utils/docker/utils"; import { removeDirectoryCode } from "../utils/filesystem/directory"; import { authGithub } from "../utils/providers/github"; import { removeTraefikConfig } from "../utils/traefik/application"; -import { manageDomain } from "../utils/traefik/domain"; +import { + manageWebServerDomain, + removeWebServerDomain, +} from "../utils/web-server/domain"; import { findApplicationById } from "./application"; import { removeDeploymentsByPreviewDeploymentId } from "./deployment"; -import { createDomain } from "./domain"; +import { createDomain, removeDomainById } from "./domain"; import { type Github, getIssueComment } from "./github"; import { getWebServerSettings } from "./web-server-settings"; @@ -55,6 +58,15 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { ); application.appName = previewDeployment.appName; + if (previewDeployment.domain) { + await removeWebServerDomain( + application, + previewDeployment.domain.uniqueConfigKey, + ); + } else { + await removeTraefikConfig(application?.appName, application?.serverId); + } + const cleanupOperations = [ async () => await removeService(application?.appName, application?.serverId), @@ -65,8 +77,6 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { ), async () => await removeDirectoryCode(application?.appName, application?.serverId), - async () => - await removeTraefikConfig(application?.appName, application?.serverId), async () => await db .delete(previewDeployments) @@ -174,32 +184,64 @@ export const createPreviewDeployment = async ( }); } - const newDomain = await createDomain({ - host: generateDomain, - path: application.previewPath, - port: application.previewPort, - https: application.previewHttps, - certificateType: application.previewCertificateType, - customCertResolver: application.previewCustomCertResolver, - domainType: "preview", - previewDeploymentId: previewDeployment.previewDeploymentId, - }); - application.appName = appName; + let newDomain: Awaited> | undefined; + let routeCreated = false; - await manageDomain(application, newDomain); + try { + newDomain = await createDomain({ + host: generateDomain, + path: application.previewPath, + port: application.previewPort, + https: application.previewHttps, + certificateType: application.previewCertificateType, + customCertResolver: application.previewCustomCertResolver, + domainType: "preview", + previewDeploymentId: previewDeployment.previewDeploymentId, + }); - await db - .update(previewDeployments) - .set({ - domainId: newDomain.domainId, - }) - .where( - eq( - previewDeployments.previewDeploymentId, - previewDeployment.previewDeploymentId, - ), - ); + await manageWebServerDomain(application, newDomain); + routeCreated = true; + + await db + .update(previewDeployments) + .set({ + domainId: newDomain.domainId, + }) + .where( + eq( + previewDeployments.previewDeploymentId, + previewDeployment.previewDeploymentId, + ), + ); + } catch (error) { + if (routeCreated && newDomain) { + await removeWebServerDomain(application, newDomain.uniqueConfigKey).catch( + () => undefined, + ); + } + if (newDomain) { + await removeDomainById(newDomain.domainId).catch(() => undefined); + } + await db + .delete(previewDeployments) + .where( + eq( + previewDeployments.previewDeploymentId, + previewDeployment.previewDeploymentId, + ), + ) + .returning() + .catch(() => undefined); + await octokit.rest.issues + .deleteComment({ + owner: application?.owner || "", + repo: application?.repository || "", + comment_id: issue.data.id, + }) + .catch(() => undefined); + throw error; + } return previewDeployment; }; diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index ecfb7f6de3..b358be8ee6 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -7,13 +7,21 @@ import { import { and, eq } from "drizzle-orm"; import semver from "semver"; +import { quote } from "shell-quote"; import { db } from "../db"; import { compose } from "../db/schema"; +import { + type CaddyOptions, + initializeCaddyService, + initializeStandaloneCaddy, +} from "../setup/caddy-setup"; import { initializeStandaloneTraefik, initializeTraefikService, type TraefikOptions, } from "../setup/traefik-setup"; +import type { CaddyMigrationResourceSnapshot } from "../utils/caddy/migration/types"; +import type { WebServerProvider } from "../utils/web-server/providers"; export interface IUpdateData { latestVersion: string | null; updateAvailable: boolean; @@ -311,6 +319,304 @@ export const reloadDockerResource = async ( } }; +const runDockerResourceCommand = async (command: string, serverId?: string) => { + if (serverId) { + return execAsyncRemote(serverId, command); + } + return execAsync(command); +}; + +const readDockerResourceJson = async >( + command: string, + serverId?: string, +): Promise => { + const { stdout } = await runDockerResourceCommand(command, serverId); + const trimmed = stdout.trim(); + return trimmed ? (JSON.parse(trimmed) as T) : undefined; +}; + +const asRecord = (value: unknown): Record | undefined => + value && typeof value === "object" + ? (value as Record) + : undefined; + +const asStringRecord = (value: unknown): Record | undefined => { + const record = asRecord(value); + if (!record) return undefined; + return Object.fromEntries( + Object.entries(record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +}; + +const asRecordArray = ( + value: unknown, +): Array> | undefined => + Array.isArray(value) + ? value.filter( + (item): item is Record => + !!item && typeof item === "object", + ) + : undefined; + +const asStringArray = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : undefined; + +const readDockerResourceImage = async ( + resourceName: string, + resourceType: "service" | "standalone", + serverId?: string, +) => { + const command = + resourceType === "service" + ? `docker service inspect ${resourceName} --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'` + : `docker container inspect ${resourceName} --format '{{.Config.Image}}'`; + const { stdout } = await runDockerResourceCommand(command, serverId); + return stdout.trim() || undefined; +}; + +export const getDockerResourceSnapshot = async ( + resourceName: string, + serverId?: string, +): Promise => { + const resourceType = await getDockerResourceType(resourceName, serverId); + if (resourceType === "unknown") { + return { resourceName, resourceType, running: false }; + } + + if (resourceType === "service") { + const spec = + (await readDockerResourceJson>( + `docker service inspect ${resourceName} --format '{{json .Spec}}'`, + serverId, + ).catch(() => undefined)) ?? {}; + const mode = asRecord(spec.Mode); + const replicated = asRecord(mode?.Replicated); + const replicas = Number(replicated?.Replicas ?? 1); + const taskTemplate = asRecord(spec.TaskTemplate); + const containerSpec = asRecord(taskTemplate?.ContainerSpec); + const endpointSpec = asRecord(spec.EndpointSpec); + return { + resourceName, + resourceType, + replicas, + running: replicas > 0, + env: await readEnvironmentVariables(resourceName, serverId).catch( + () => undefined, + ), + additionalPorts: await readPorts(resourceName, serverId).catch(() => []), + image: await readDockerResourceImage( + resourceName, + resourceType, + serverId, + ).catch(() => undefined), + mounts: asRecordArray(containerSpec?.Mounts), + networks: asRecordArray(taskTemplate?.Networks), + labels: asStringRecord(spec.Labels), + containerLabels: asStringRecord(containerSpec?.Labels), + placement: asRecord(taskTemplate?.Placement), + endpointPorts: asRecordArray(endpointSpec?.Ports), + }; + } + + const inspect = + (await readDockerResourceJson>( + `docker container inspect ${resourceName} --format '{{json .}}'`, + serverId, + ).catch(() => undefined)) ?? {}; + const state = asRecord(inspect.State); + const hostConfig = asRecord(inspect.HostConfig); + const networkSettings = asRecord(inspect.NetworkSettings); + const networksRecord = asRecord(networkSettings?.Networks); + const config = asRecord(inspect.Config); + return { + resourceName, + resourceType, + running: state?.Running === true, + env: await readEnvironmentVariables(resourceName, serverId).catch( + () => undefined, + ), + additionalPorts: await readPorts(resourceName, serverId).catch(() => []), + image: await readDockerResourceImage( + resourceName, + resourceType, + serverId, + ).catch(() => undefined), + binds: asStringArray(hostConfig?.Binds), + networks: networksRecord ? Object.keys(networksRecord) : undefined, + labels: asStringRecord(config?.Labels), + restartPolicy: asRecord(hostConfig?.RestartPolicy), + }; +}; + +export const stopDockerResource = async ( + resourceName: string, + serverId?: string, +) => { + const resourceType = await getDockerResourceType(resourceName, serverId); + if (resourceType === "service") { + await runDockerResourceCommand( + `docker service scale ${resourceName}=0`, + serverId, + ); + return; + } + if (resourceType === "standalone") { + await runDockerResourceCommand(`docker stop ${resourceName}`, serverId); + } +}; + +export const startDockerResourceFromSnapshot = async ( + snapshot: CaddyMigrationResourceSnapshot, + serverId?: string, +) => { + if (!snapshot.running) { + return; + } + if (snapshot.resourceType === "service") { + await runDockerResourceCommand( + `docker service scale ${snapshot.resourceName}=${snapshot.replicas ?? 1}`, + serverId, + ); + return; + } + if (snapshot.resourceType === "standalone") { + await runDockerResourceCommand( + `docker start ${snapshot.resourceName}`, + serverId, + ); + } +}; + +const envStringToArray = (env?: string) => + env && env !== "[redacted]" ? env.split("\n").filter(Boolean) : undefined; + +export const ensureTraefikRunningFromSnapshot = async ( + snapshot?: CaddyMigrationResourceSnapshot, + serverId?: string, +) => { + let restartError: unknown; + if (snapshot?.running) { + try { + await startDockerResourceFromSnapshot(snapshot, serverId); + await waitForDockerResourceRunning("dokploy-traefik", serverId, { + retries: 10, + intervalMs: 1000, + }); + return; + } catch (error) { + restartError = error; + } + } + + const recreateInput: TraefikOptions = { + env: envStringToArray(snapshot?.env), + additionalPorts: snapshot?.additionalPorts ?? [], + image: snapshot?.image, + serverId, + binds: snapshot?.binds, + networks: snapshot?.networks?.filter( + (item): item is string => typeof item === "string", + ), + labels: snapshot?.labels, + restartPolicy: snapshot?.restartPolicy as TraefikOptions["restartPolicy"], + replicas: snapshot?.replicas, + serviceMounts: snapshot?.mounts as TraefikOptions["serviceMounts"], + serviceNetworks: snapshot?.networks?.filter( + (item): item is Record => typeof item === "object", + ) as TraefikOptions["serviceNetworks"], + servicePlacement: snapshot?.placement as TraefikOptions["servicePlacement"], + serviceLabels: snapshot?.labels, + serviceContainerLabels: snapshot?.containerLabels, + serviceEndpointPorts: + snapshot?.endpointPorts as TraefikOptions["serviceEndpointPorts"], + }; + + const runExactRecreate = async () => { + if (snapshot?.resourceType === "service") { + await initializeTraefikService(recreateInput); + await reconnectServicesToTraefik(serverId); + return; + } + if (snapshot?.resourceType === "standalone") { + await initializeStandaloneTraefik(recreateInput); + await reconnectServicesToTraefik(serverId); + return; + } + await writeTraefikSetup(recreateInput); + }; + + try { + try { + await runExactRecreate(); + } catch (exactError) { + if (snapshot?.resourceType === "unknown") { + throw exactError; + } + try { + await writeTraefikSetup(recreateInput); + } catch (fallbackError) { + const exactMessage = + exactError instanceof Error + ? exactError.message + : "exact Traefik recreation failed"; + const fallbackMessage = + fallbackError instanceof Error + ? fallbackError.message + : "generic Traefik recreation failed"; + throw new Error( + `Exact Traefik recreation failed: ${exactMessage}; generic recreation failed: ${fallbackMessage}`, + ); + } + } + await waitForDockerResourceRunning("dokploy-traefik", serverId, { + retries: 20, + intervalMs: 1000, + }); + } catch (error) { + const recreateMessage = + error instanceof Error ? error.message : "Traefik recreation failed"; + if (restartError) { + const restartMessage = + restartError instanceof Error + ? restartError.message + : "Traefik restart failed"; + throw new Error( + `Unable to restore Traefik. Restart failed: ${restartMessage}; recreate failed: ${recreateMessage}`, + ); + } + throw error; + } +}; + +export const waitForDockerResourceRunning = async ( + resourceName: string, + serverId?: string, + options: { retries?: number; intervalMs?: number } = {}, +) => { + const retries = options.retries ?? 20; + const intervalMs = options.intervalMs ?? 1000; + for (let attempt = 0; attempt < retries; attempt++) { + const snapshot = await getDockerResourceSnapshot(resourceName, serverId); + if (snapshot.resourceType === "service" && snapshot.running) { + const { stdout } = await runDockerResourceCommand( + `docker service ps ${resourceName} --filter desired-state=running --format '{{.CurrentState}}' | grep -m1 '^Running' || true`, + serverId, + ); + if (stdout.trim()) { + return snapshot; + } + } else if (snapshot.running) { + return snapshot; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Docker resource ${resourceName} did not become running`); +}; + export const readEnvironmentVariables = async ( resourceName: string, serverId?: string, @@ -414,7 +720,7 @@ export const checkPortInUse = async ( ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { try { // Check if port is in use by a Docker container - const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; + const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -Ev '^(dokploy-traefik|dokploy-caddy)$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; const { stdout: dockerOut } = serverId ? await execAsyncRemote(serverId, dockerCommand) : await execAsync(dockerCommand); @@ -451,33 +757,83 @@ export const checkPortInUse = async ( } }; -export const writeTraefikSetup = async (input: TraefikOptions) => { +export const writeCaddySetup = async (input: CaddyOptions) => { const resourceType = await getDockerResourceType( - "dokploy-traefik", + "dokploy-caddy", input.serverId, ); - - if (resourceType === "service") { - await initializeTraefikService({ + const traefikResourceType = + resourceType === "unknown" + ? await getDockerResourceType("dokploy-traefik", input.serverId) + : resourceType; + const fallbackResourceType = + traefikResourceType === "unknown" + ? await getDockerResourceType("dokploy", input.serverId) + : traefikResourceType; + const setupType = + fallbackResourceType === "service" ? "service" : "standalone"; + + if (setupType === "service") { + await initializeCaddyService({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, + letsEncryptEmail: input.letsEncryptEmail, + trustedProxies: input.trustedProxies, + accessLogs: input.accessLogs, }); - await reconnectServicesToTraefik(input.serverId); - } else if (resourceType === "standalone") { - await initializeStandaloneTraefik({ + await reconnectServicesToWebServer("dokploy-caddy", input.serverId); + } else { + await initializeStandaloneCaddy({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, + letsEncryptEmail: input.letsEncryptEmail, + trustedProxies: input.trustedProxies, + accessLogs: input.accessLogs, }); + await reconnectServicesToWebServer("dokploy-caddy", input.serverId); + } +}; + +export const writeWebServerSetup = async ( + provider: WebServerProvider, + input: TraefikOptions | CaddyOptions, +) => { + if (provider === "caddy") { + return writeCaddySetup(input as CaddyOptions); + } + + return writeTraefikSetup(input as TraefikOptions); +}; + +export const writeTraefikSetup = async (input: TraefikOptions) => { + const resourceType = await getDockerResourceType( + "dokploy-traefik", + input.serverId, + ); + const fallbackResourceType = + resourceType === "unknown" + ? await getDockerResourceType("dokploy", input.serverId) + : resourceType; + const setupType = + fallbackResourceType === "service" ? "service" : "standalone"; + + if (setupType === "service") { + await initializeTraefikService(input); await reconnectServicesToTraefik(input.serverId); } else { - throw new Error("Traefik resource type not found"); + await initializeStandaloneTraefik(input); + + await reconnectServicesToTraefik(input.serverId); } }; -export const reconnectServicesToTraefik = async (serverId?: string) => { +export const reconnectServicesToWebServer = async ( + resourceName: "dokploy-traefik" | "dokploy-caddy", + serverId?: string, +) => { const composeResult = await db.query.compose.findMany({ where: and( ...(serverId ? [eq(compose.serverId, serverId)] : []), @@ -491,7 +847,13 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { let commands = ""; for (const compose of composeResult) { - commands += `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n`; + const networkName = quote([compose.appName]); + const quotedResourceName = quote([resourceName]); + commands += `if docker service inspect ${quotedResourceName} >/dev/null 2>&1; then\n`; + commands += ` docker service inspect ${quotedResourceName} --format '{{range .Spec.TaskTemplate.Networks}}{{println .Target}}{{end}}' | grep -qx ${networkName} || docker service update --network-add ${networkName} ${quotedResourceName} >/dev/null\n`; + commands += "else\n"; + commands += ` docker network connect ${networkName} $(docker ps --filter "name=${resourceName}" -q) >/dev/null 2>&1 || true\n`; + commands += "fi\n"; } if (serverId) { @@ -500,3 +862,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { await execAsync(commands); } }; + +export const reconnectServicesToTraefik = async (serverId?: string) => { + await reconnectServicesToWebServer("dokploy-traefik", serverId); +}; diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts index 2437517052..8ae5529454 100644 --- a/packages/server/src/services/user.ts +++ b/packages/server/src/services/user.ts @@ -199,6 +199,13 @@ export const canAccessToTraefikFiles = async ( return canAccessToTraefikFiles; }; +export const canAccessToWebServerFiles = async ( + userId: string, + organizationId: string, +) => { + return canAccessToTraefikFiles(userId, organizationId); +}; + export const checkServiceAccess = async ( userId: string, serviceId: string, diff --git a/packages/server/src/services/web-server-settings.ts b/packages/server/src/services/web-server-settings.ts index 289d119c99..4cd7252c2a 100644 --- a/packages/server/src/services/web-server-settings.ts +++ b/packages/server/src/services/web-server-settings.ts @@ -1,7 +1,81 @@ import { db } from "@dokploy/server/db"; -import { webServerSettings } from "@dokploy/server/db/schema"; +import { server, webServerSettings } from "@dokploy/server/db/schema"; +import { assertValidCaddyTrustedProxyConfig } from "@dokploy/server/utils/caddy/config"; +import type { + CaddyAccessLogConfig, + CaddyTrustedProxyConfig, + CaddyTrustedProxySettings, +} from "@dokploy/server/utils/caddy/types"; +import { + normalizeWebServerProvider, + type WebServerProvider, +} from "@dokploy/server/utils/web-server/providers"; import { eq } from "drizzle-orm"; +const normalizeStringList = (values: string[] | null | undefined) => + Array.from( + new Set( + (values ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ); + +export const normalizeCaddyTrustedProxySettings = ( + settings: CaddyTrustedProxySettings | null | undefined, +): CaddyTrustedProxySettings | null => { + if (!settings || settings.mode === "disabled") { + return null; + } + + const normalized: CaddyTrustedProxySettings = + settings.mode === "cloudflare" + ? { + mode: "cloudflare", + clientIpHeaders: normalizeStringList(settings.clientIpHeaders), + strict: settings.strict !== false, + } + : { + mode: "static", + ranges: normalizeStringList(settings.ranges), + clientIpHeaders: normalizeStringList(settings.clientIpHeaders), + strict: settings.strict !== false, + }; + + assertValidCaddyTrustedProxyConfig( + caddyTrustedProxySettingsToConfig(normalized), + ); + + return normalized; +}; + +export const caddyTrustedProxySettingsToConfig = ( + settings: CaddyTrustedProxySettings | null | undefined, +): CaddyTrustedProxyConfig | null => { + if (!settings || settings.mode === "disabled") { + return null; + } + + if (settings.mode === "cloudflare") { + return { + source: "cloudflare", + clientIpHeaders: settings.clientIpHeaders?.length + ? settings.clientIpHeaders + : undefined, + strict: settings.strict, + }; + } + + return { + source: "static", + ranges: settings.ranges ?? [], + clientIpHeaders: settings.clientIpHeaders?.length + ? settings.clientIpHeaders + : undefined, + strict: settings.strict, + }; +}; + /** * Get the web server settings (singleton - only one row should exist) */ @@ -42,3 +116,138 @@ export const updateWebServerSettings = async ( return updated; }; + +export const getLocalWebServerProvider = + async (): Promise => { + const settings = await getWebServerSettings(); + return normalizeWebServerProvider(settings?.webServerProvider); + }; + +export const updateLocalWebServerProvider = async ( + provider: WebServerProvider, +) => { + return updateWebServerSettings({ webServerProvider: provider }); +}; + +export const getRemoteWebServerProvider = async ( + serverId: string, +): Promise => { + const remoteServer = await db.query.server.findFirst({ + where: eq(server.serverId, serverId), + columns: { + webServerProvider: true, + }, + }); + + if (!remoteServer) { + throw new Error(`Server not found: ${serverId}`); + } + + return normalizeWebServerProvider(remoteServer.webServerProvider); +}; + +export const updateRemoteWebServerProvider = async ( + serverId: string, + provider: WebServerProvider, +) => { + const [updated] = await db + .update(server) + .set({ webServerProvider: provider }) + .where(eq(server.serverId, serverId)) + .returning(); + + return updated; +}; + +export const getCaddyTrustedProxySettings = async ( + serverId?: string | null, +): Promise => { + if (serverId) { + const remoteServer = await db.query.server.findFirst({ + where: eq(server.serverId, serverId), + columns: { + caddyTrustedProxyConfig: true, + }, + }); + + if (!remoteServer) { + throw new Error(`Server not found: ${serverId}`); + } + + return normalizeCaddyTrustedProxySettings( + remoteServer.caddyTrustedProxyConfig, + ); + } + + const settings = await getWebServerSettings(); + return normalizeCaddyTrustedProxySettings(settings?.caddyTrustedProxyConfig); +}; + +export const getCaddyTrustedProxyConfig = async ( + serverId?: string | null, +): Promise => { + return caddyTrustedProxySettingsToConfig( + await getCaddyTrustedProxySettings(serverId), + ); +}; + +export const getCaddyCompileSettings = async ( + serverId?: string | null, +): Promise<{ + letsEncryptEmail?: string | null; + trustedProxies?: CaddyTrustedProxyConfig | null; + accessLogs?: CaddyAccessLogConfig | null; +}> => { + if (serverId) { + return { + trustedProxies: await getCaddyTrustedProxyConfig(serverId), + }; + } + + const settings = await getWebServerSettings(); + return localWebServerSettingsToCaddyCompileSettings(settings); +}; + +export const localWebServerSettingsToCaddyCompileSettings = ( + settings: typeof webServerSettings.$inferSelect | null | undefined, +): { + letsEncryptEmail?: string | null; + trustedProxies?: CaddyTrustedProxyConfig | null; + accessLogs?: CaddyAccessLogConfig | null; +} => { + return { + letsEncryptEmail: settings?.letsEncryptEmail, + trustedProxies: caddyTrustedProxySettingsToConfig( + normalizeCaddyTrustedProxySettings(settings?.caddyTrustedProxyConfig), + ), + accessLogs: settings?.requestLogsEnabled ? { enabled: true } : null, + }; +}; + +export const updateCaddyTrustedProxySettings = async ( + settings: CaddyTrustedProxySettings | null, + serverId?: string | null, +) => { + const normalized = normalizeCaddyTrustedProxySettings(settings); + if (serverId) { + const [updated] = await db + .update(server) + .set({ caddyTrustedProxyConfig: normalized }) + .where(eq(server.serverId, serverId)) + .returning(); + + return updated; + } + + return updateWebServerSettings({ caddyTrustedProxyConfig: normalized }); +}; + +export const resolveWebServerProvider = async ( + serverId?: string | null, +): Promise => { + if (serverId) { + return getRemoteWebServerProvider(serverId); + } + + return getLocalWebServerProvider(); +}; diff --git a/packages/server/src/setup/caddy-setup.ts b/packages/server/src/setup/caddy-setup.ts new file mode 100644 index 0000000000..611646e3ba --- /dev/null +++ b/packages/server/src/setup/caddy-setup.ts @@ -0,0 +1,332 @@ +import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode"; +import { paths } from "../constants"; +import { + CADDY_METRICS_PORT, + ensureDefaultCaddyConfig, + reloadCaddyAfterValidation, + validateCaddyConfigWithContainer, +} from "../utils/caddy/config"; +import type { + CaddyAccessLogConfig, + CaddyTrustedProxyConfig, +} from "../utils/caddy/types"; +import { getRemoteDocker } from "../utils/servers/remote-docker"; + +export const CADDY_SSL_PORT = + Number.parseInt(process.env.CADDY_SSL_PORT ?? "", 10) || 443; +export const CADDY_PORT = + Number.parseInt(process.env.CADDY_PORT ?? "", 10) || 80; +export const CADDY_HTTP3_PORT = + Number.parseInt(process.env.CADDY_HTTP3_PORT ?? "", 10) || 443; +export const CADDY_ADMIN_PORT = 2019; +const CADDY_RESERVED_TCP_PORTS = new Set([ + CADDY_ADMIN_PORT, + CADDY_METRICS_PORT, +]); +const CADDY_TRAEFIK_CARRY_OVER_TCP_TARGET_PORTS = new Set([ + 8080, + 8082, + ...CADDY_RESERVED_TCP_PORTS, +]); +export const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.4"; + +export interface CaddyOptions { + env?: string[]; + serverId?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + protocol?: string; + }[]; + letsEncryptEmail?: string | null; + trustedProxies?: CaddyTrustedProxyConfig | null; + accessLogs?: CaddyAccessLogConfig | null; +} + +type CaddyAdditionalPort = NonNullable[number]; + +const usesTcp = (port: CaddyAdditionalPort) => + (port.protocol ?? "tcp") === "tcp"; + +export const isCaddyAdminAdditionalPort = (port: CaddyAdditionalPort) => + usesTcp(port) && + (port.targetPort === CADDY_ADMIN_PORT || + port.publishedPort === CADDY_ADMIN_PORT); + +export const isCaddyAdminPort = isCaddyAdminAdditionalPort; +export const isCaddyReservedAdditionalPort = (port: CaddyAdditionalPort) => + usesTcp(port) && + (CADDY_RESERVED_TCP_PORTS.has(port.targetPort) || + CADDY_RESERVED_TCP_PORTS.has(port.publishedPort)); + +export const isTraefikCarryOverPortForCaddyMigration = ( + port: CaddyAdditionalPort, +) => + usesTcp(port) && + (CADDY_TRAEFIK_CARRY_OVER_TCP_TARGET_PORTS.has(port.targetPort) || + CADDY_RESERVED_TCP_PORTS.has(port.publishedPort)); + +export const filterCaddyAdditionalPorts = ( + additionalPorts: CaddyOptions["additionalPorts"] = [], +) => additionalPorts.filter((port) => !isCaddyReservedAdditionalPort(port)); + +export const filterTraefikCarryOverPortsForCaddyMigration = ( + additionalPorts: CaddyOptions["additionalPorts"] = [], +) => + additionalPorts.filter( + (port) => !isTraefikCarryOverPortForCaddyMigration(port), + ); + +const getCaddyMounts = (serverId?: string) => { + const { CADDY_CONFIG_DIR_PATH, CADDY_DATA_PATH, MAIN_CADDY_PATH } = paths( + !!serverId, + ); + const { CERTIFICATES_PATH } = paths(!!serverId); + + return { + MAIN_CADDY_PATH, + binds: [ + `${MAIN_CADDY_PATH}:/etc/caddy`, + `${CADDY_DATA_PATH}:/data`, + `${CADDY_CONFIG_DIR_PATH}:/config`, + `${CERTIFICATES_PATH}:${CERTIFICATES_PATH}:ro`, + ], + serviceMounts: [ + { + Type: "bind" as const, + Source: MAIN_CADDY_PATH, + Target: "/etc/caddy", + }, + { + Type: "bind" as const, + Source: CADDY_DATA_PATH, + Target: "/data", + }, + { + Type: "bind" as const, + Source: CADDY_CONFIG_DIR_PATH, + Target: "/config", + }, + { + Type: "bind" as const, + Source: CERTIFICATES_PATH, + Target: CERTIFICATES_PATH, + ReadOnly: true, + }, + ], + }; +}; + +const buildStandalonePorts = ( + additionalPorts: CaddyOptions["additionalPorts"], +) => { + const exposedPorts: Record = { + [`${CADDY_PORT}/tcp`]: {}, + [`${CADDY_SSL_PORT}/tcp`]: {}, + [`${CADDY_HTTP3_PORT}/udp`]: {}, + }; + + const portBindings: Record> = { + [`${CADDY_PORT}/tcp`]: [{ HostPort: CADDY_PORT.toString() }], + [`${CADDY_SSL_PORT}/tcp`]: [{ HostPort: CADDY_SSL_PORT.toString() }], + [`${CADDY_HTTP3_PORT}/udp`]: [{ HostPort: CADDY_HTTP3_PORT.toString() }], + }; + + for (const port of filterCaddyAdditionalPorts(additionalPorts)) { + const portKey = `${port.targetPort}/${port.protocol ?? "tcp"}`; + exposedPorts[portKey] = {}; + portBindings[portKey] = [{ HostPort: port.publishedPort.toString() }]; + } + + return { exposedPorts, portBindings }; +}; + +const pullImage = async ( + docker: Awaited>, + imageName: string, +) => { + await new Promise((resolve, reject) => { + docker.pull( + imageName, + (error: Error | null, stream?: NodeJS.ReadableStream) => { + if (error) { + reject(error); + return; + } + if (!stream) { + resolve(); + return; + } + docker.modem.followProgress(stream, (progressError?: Error | null) => { + if (progressError) { + reject(progressError); + return; + } + resolve(); + }); + }, + ); + }); +}; + +const buildServicePorts = ( + additionalPorts: CaddyOptions["additionalPorts"], +) => [ + { + TargetPort: 443, + PublishedPort: CADDY_SSL_PORT, + PublishMode: "host" as const, + Protocol: "tcp" as const, + }, + { + TargetPort: 443, + PublishedPort: CADDY_HTTP3_PORT, + PublishMode: "host" as const, + Protocol: "udp" as const, + }, + { + TargetPort: 80, + PublishedPort: CADDY_PORT, + PublishMode: "host" as const, + Protocol: "tcp" as const, + }, + ...filterCaddyAdditionalPorts(additionalPorts).map((port) => ({ + TargetPort: port.targetPort, + PublishedPort: port.publishedPort, + Protocol: port.protocol as "tcp" | "udp" | "sctp" | undefined, + PublishMode: "host" as const, + })), +]; + +export const initializeStandaloneCaddy = async ({ + env, + serverId, + additionalPorts = [], + letsEncryptEmail, + trustedProxies, + accessLogs, +}: CaddyOptions = {}) => { + await ensureDefaultCaddyConfig({ + serverId, + letsEncryptEmail, + trustedProxies, + accessLogs, + }); + const imageName = `caddy:${CADDY_VERSION}`; + const containerName = "dokploy-caddy"; + const { binds } = getCaddyMounts(serverId); + const { exposedPorts, portBindings } = buildStandalonePorts(additionalPorts); + + const settings: ContainerCreateOptions = { + name: containerName, + Image: imageName, + Cmd: ["caddy", "run", "--config", "/etc/caddy/caddy.json"], + NetworkingConfig: { + EndpointsConfig: { + "dokploy-network": {}, + }, + }, + ExposedPorts: exposedPorts, + HostConfig: { + RestartPolicy: { + Name: "always", + }, + Binds: binds, + PortBindings: portBindings, + }, + Env: env, + }; + + const docker = await getRemoteDocker(serverId); + try { + await pullImage(docker, imageName); + console.log("Caddy Image Pulled ✅"); + } catch (error) { + console.log("Caddy Image Not Found: Pulling ", error); + } + try { + const container = docker.getContainer(containerName); + await container.remove({ force: true }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } catch {} + + await docker.createContainer(settings); + const newContainer = docker.getContainer(containerName); + await newContainer.start(); + await validateCaddyConfigWithContainer(serverId); + console.log("Caddy Started ✅"); +}; + +export const initializeCaddyService = async ({ + env, + additionalPorts = [], + serverId, + letsEncryptEmail, + trustedProxies, + accessLogs, +}: CaddyOptions) => { + await ensureDefaultCaddyConfig({ + serverId, + letsEncryptEmail, + trustedProxies, + accessLogs, + }); + const imageName = `caddy:${CADDY_VERSION}`; + const appName = "dokploy-caddy"; + const { serviceMounts } = getCaddyMounts(serverId); + + const settings: CreateServiceOptions = { + Name: appName, + TaskTemplate: { + ContainerSpec: { + Image: imageName, + Command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"], + Env: env, + Mounts: serviceMounts, + }, + Networks: [{ Target: "dokploy-network" }], + Placement: { + Constraints: ["node.role==manager"], + }, + }, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + EndpointSpec: { + Ports: buildServicePorts(additionalPorts), + }, + }; + const docker = await getRemoteDocker(serverId); + try { + await pullImage(docker, imageName); + console.log("Caddy Image Pulled ✅"); + } catch (error) { + console.log("Caddy Image Not Found: Pulling ", error); + } + try { + const service = docker.getService(appName); + const inspect = await service.inspect(); + + await service.update({ + version: inspect.Version.Index, + ...settings, + TaskTemplate: { + ...settings.TaskTemplate, + ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1, + }, + }); + console.log("Caddy Updated ✅"); + } catch { + await docker.createService(settings); + console.log("Caddy Started ✅"); + } +}; + +export const createDefaultCaddyConfig = async (options: CaddyOptions = {}) => { + await ensureDefaultCaddyConfig(options); +}; + +export const validateCaddyConfig = validateCaddyConfigWithContainer; +export const reloadCaddy = reloadCaddyAfterValidation; diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 9fcebe5408..5212aa8f14 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -22,23 +22,57 @@ export const TRAEFIK_HTTP3_PORT = Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443; export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7"; +type TraefikServiceTaskTemplate = NonNullable< + CreateServiceOptions["TaskTemplate"] +>; +type TraefikServiceContainerTaskTemplate = Extract< + TraefikServiceTaskTemplate, + { ContainerSpec?: unknown } +>; +type TraefikServiceContainerSpec = NonNullable< + TraefikServiceContainerTaskTemplate["ContainerSpec"] +>; +type TraefikServiceEndpointSpec = NonNullable< + CreateServiceOptions["EndpointSpec"] +>; +type TraefikStandaloneHostConfig = NonNullable< + ContainerCreateOptions["HostConfig"] +>; + export interface TraefikOptions { env?: string[]; serverId?: string; + image?: string; additionalPorts?: { targetPort: number; publishedPort: number; protocol?: string; }[]; + binds?: string[]; + networks?: string[]; + labels?: Record; + restartPolicy?: TraefikStandaloneHostConfig["RestartPolicy"]; + replicas?: number; + serviceMounts?: TraefikServiceContainerSpec["Mounts"]; + serviceNetworks?: TraefikServiceTaskTemplate["Networks"]; + servicePlacement?: TraefikServiceTaskTemplate["Placement"]; + serviceLabels?: Record; + serviceContainerLabels?: Record; + serviceEndpointPorts?: TraefikServiceEndpointSpec["Ports"]; } export const initializeStandaloneTraefik = async ({ env, serverId, + image, additionalPorts = [], + binds, + networks, + labels, + restartPolicy, }: TraefikOptions = {}) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); - const imageName = `traefik:v${TRAEFIK_VERSION}`; + const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`; const containerName = "dokploy-traefik"; const exposedPorts: Record = { @@ -58,6 +92,12 @@ export const initializeStandaloneTraefik = async ({ const enableDashboard = additionalPorts.some( (port) => port.targetPort === 8080, ); + const endpointNetworks = Object.fromEntries( + (networks?.length ? networks : ["dokploy-network"]).map((network) => [ + network, + {}, + ]), + ); if (enableDashboard) { exposedPorts["8080/tcp"] = {}; @@ -74,16 +114,14 @@ export const initializeStandaloneTraefik = async ({ name: containerName, Image: imageName, NetworkingConfig: { - EndpointsConfig: { - "dokploy-network": {}, - }, + EndpointsConfig: endpointNetworks, }, ExposedPorts: exposedPorts, HostConfig: { - RestartPolicy: { + RestartPolicy: restartPolicy ?? { Name: "always", }, - Binds: [ + Binds: binds ?? [ `${MAIN_TRAEFIK_PATH}/traefik.yml:/etc/traefik/traefik.yml`, `${DYNAMIC_TRAEFIK_PATH}:/etc/dokploy/traefik/dynamic`, "/var/run/docker.sock:/var/run/docker.sock", @@ -91,6 +129,7 @@ export const initializeStandaloneTraefik = async ({ PortBindings: portBindings, }, Env: env, + Labels: labels, }; const docker = await getRemoteDocker(serverId); @@ -119,20 +158,30 @@ export const initializeStandaloneTraefik = async ({ export const initializeTraefikService = async ({ env, + image, additionalPorts = [], serverId, + serviceMounts, + serviceNetworks, + servicePlacement, + serviceLabels, + serviceContainerLabels, + serviceEndpointPorts, + replicas, }: TraefikOptions) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); - const imageName = `traefik:v${TRAEFIK_VERSION}`; + const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`; const appName = "dokploy-traefik"; const settings: CreateServiceOptions = { Name: appName, + Labels: serviceLabels, TaskTemplate: { ContainerSpec: { Image: imageName, Env: env, - Mounts: [ + Labels: serviceContainerLabels, + Mounts: serviceMounts ?? [ { Type: "bind", Source: `${MAIN_TRAEFIK_PATH}/traefik.yml`, @@ -150,18 +199,18 @@ export const initializeTraefikService = async ({ }, ], }, - Networks: [{ Target: "dokploy-network" }], - Placement: { + Networks: serviceNetworks ?? [{ Target: "dokploy-network" }], + Placement: servicePlacement ?? { Constraints: ["node.role==manager"], }, }, Mode: { Replicated: { - Replicas: 1, + Replicas: replicas ?? 1, }, }, EndpointSpec: { - Ports: [ + Ports: serviceEndpointPorts ?? [ { TargetPort: 443, PublishedPort: TRAEFIK_SSL_PORT, diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 790116cb6d..46ac77ed86 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -1,5 +1,6 @@ import { dirname, join } from "node:path"; import { paths } from "@dokploy/server/constants"; +import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; import type { InferResultType } from "@dokploy/server/types/with"; import boxen from "boxen"; import { quote } from "shell-quote"; @@ -9,6 +10,7 @@ import { getEnvironmentVariablesObject, prepareEnvironmentVariables, } from "../docker/utils"; +import { getWebServerResourceName } from "../web-server/providers"; export type ComposeNested = InferResultType< "compose", @@ -23,6 +25,8 @@ export const getBuildComposeCommand = async (compose: ComposeNested) => { const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const exportEnvCommand = getExportEnvCommand(compose); + const provider = await resolveWebServerProvider(compose.serverId); + const webServerResourceName = getWebServerResourceName(provider); const newCompose = await writeDomainsToCompose(compose, domains); const logContent = ` App Name: ${appName} @@ -55,7 +59,7 @@ Compose Type: ${composeType} ✅`; ${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""} env -i PATH="$PATH" HOME="$HOME" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; } - ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""} + ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=${webServerResourceName}" -q) >/dev/null 2>&1` : ""} echo "Docker Compose Deployed: ✅"; } || { diff --git a/packages/server/src/utils/caddy/compose.ts b/packages/server/src/utils/caddy/compose.ts new file mode 100644 index 0000000000..374e3542ff --- /dev/null +++ b/packages/server/src/utils/caddy/compose.ts @@ -0,0 +1,173 @@ +import type { Compose } from "@dokploy/server/services/compose"; +import type { Domain } from "@dokploy/server/services/domain"; +import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings"; +import { + compileWriteAndReloadCaddyConfigSafely, + readCaddyRouteFragments, + removeCaddyRouteFragment, + restoreCaddyRouteFragments, + writeCaddyRouteFragment, +} from "./config"; +import { + assertCaddyDomainCertificateAvailable, + assertCaddyDomainSupported, + getCaddyCustomCertificateFiles, +} from "./domain"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { getCaddyComposeRuntimeTarget } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const getCaddyComposeFragmentPrefix = (appName: string) => + `compose.${appName}.`; + +export const getCaddyComposeFragmentId = ( + appName: string, + uniqueConfigKey: number, +) => `${getCaddyComposeFragmentPrefix(appName)}${uniqueConfigKey}`; + +const createCaddyRouteId = (appName: string, uniqueConfigKey: number) => + `${appName}-compose-route-${uniqueConfigKey}`; + +export const createCaddyComposeRouteIntent = ( + compose: Pick< + Compose, + "appName" | "composeType" | "isolatedDeployment" | "serverId" + >, + domain: Domain, + finalServiceName: string, + options: { + upstreamServiceName?: string; + upstreamNetwork?: string | null; + } = {}, +): CaddyRouteIntent => { + assertCaddyDomainSupported(domain); + const publicPath = domain.path && domain.path !== "/" ? domain.path : null; + const internalPath = + domain.internalPath && + domain.internalPath !== "/" && + domain.internalPath !== domain.path + ? domain.internalPath + : null; + const runtimeTarget = getCaddyComposeRuntimeTarget(compose, finalServiceName); + const upstreamServiceName = options.upstreamServiceName ?? runtimeTarget.host; + const upstreamNetwork = options.upstreamNetwork ?? runtimeTarget.network; + + return { + id: createCaddyRouteId(compose.appName, domain.uniqueConfigKey), + source: "dokploy-compose", + hosts: [toPunycode(domain.host)], + pathPrefix: publicPath, + https: domain.https && !domain.customEntrypoint, + upstreams: [`http://${upstreamServiceName}:${domain.port || 80}`], + upstreamNetwork, + tlsCertificate: + domain.https && + domain.certificateType === "custom" && + domain.customCertResolver + ? getCaddyCustomCertificateFiles( + compose.serverId, + domain.customCertResolver, + ) + : null, + transforms: { + stripPrefix: domain.stripPath ? publicPath : null, + addPrefix: internalPath, + }, + }; +}; + +export const createCaddyComposeRouteFragment = ( + compose: Pick< + Compose, + "appName" | "composeType" | "isolatedDeployment" | "serverId" + >, + domain: Domain, + finalServiceName: string, + options: { + upstreamServiceName?: string; + upstreamNetwork?: string | null; + } = {}, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey), + source: "dokploy-compose", + description: `Dokploy compose route for ${compose.appName}:${domain.uniqueConfigKey}`, + routes: [ + createCaddyComposeRouteIntent(compose, domain, finalServiceName, options), + ], +}); + +export const writeCaddyComposeRouteFragments = async ( + compose: Compose, + domains: Array<{ domain: Domain; finalServiceName: string }>, + options: { + organizationId?: string | null; + } = {}, +) => { + const serverId = compose.serverId || undefined; + const organizationId = + options.organizationId ?? + ( + compose as Compose & { + environment?: { project?: { organizationId?: string | null } }; + } + ).environment?.project?.organizationId; + for (const { domain } of domains) { + await assertCaddyDomainCertificateAvailable( + serverId, + domain, + organizationId, + ); + } + const routeFragmentOptions = { serverId }; + const fragmentPrefix = getCaddyComposeFragmentPrefix(compose.appName); + const nextFragmentIds = new Set( + domains.map(({ domain }) => + getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey), + ), + ); + let changed = false; + + const existingFragments = await readCaddyRouteFragments(routeFragmentOptions); + try { + for (const fragment of existingFragments) { + if ( + fragment.source === "dokploy-compose" && + fragment.id.startsWith(fragmentPrefix) && + !nextFragmentIds.has(fragment.id) + ) { + await removeCaddyRouteFragment(fragment.id, routeFragmentOptions); + changed = true; + } + } + + for (const { domain, finalServiceName } of domains) { + await writeCaddyRouteFragment( + createCaddyComposeRouteFragment(compose, domain, finalServiceName), + routeFragmentOptions, + ); + changed = true; + } + + if (!changed) { + return; + } + + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + ...(await getCaddyCompileSettings(serverId)), + }); + } catch (error) { + await restoreCaddyRouteFragments(existingFragments, routeFragmentOptions); + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/config.ts b/packages/server/src/utils/caddy/config.ts new file mode 100644 index 0000000000..bdf2ede28f --- /dev/null +++ b/packages/server/src/utils/caddy/config.ts @@ -0,0 +1,1107 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { isIP } from "node:net"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import type { + CaddyCompileOptions, + CaddyFragmentStoreOptions, + CaddyHeaderMap, + CaddyJsonObject, + CaddyRouteFragment, + CaddyRouteIntent, +} from "./types"; + +const CADDY_FRAGMENT_VERSION = 1; +const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.4"; +const CADDY_ACCESS_LOG_CONTAINER_PATH = "/etc/caddy/access.log"; +export const CADDY_METRICS_PORT = 2020; +export const CLOUDFLARE_TRUSTED_PROXY_RANGES = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +] as const; + +const DEFAULT_CLIENT_IP_HEADERS = ["X-Forwarded-For"]; +const CLOUDFLARE_CLIENT_IP_HEADERS = ["CF-Connecting-IP", "X-Forwarded-For"]; + +const assertSafeFragmentId = (id: string) => { + if ( + !/^[a-zA-Z0-9_.-]+$/.test(id) || + id === "." || + id === ".." || + id.split(".").some((segment) => segment === "") + ) { + throw new Error( + `Invalid Caddy fragment id "${id}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`, + ); + } +}; + +const assertPathWithinBase = (basePath: string, targetPath: string) => { + const resolvedBase = path.resolve(basePath); + const resolvedTarget = path.resolve(targetPath); + if ( + resolvedTarget !== resolvedBase && + !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`) + ) { + throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`); + } +}; + +export const getCaddyFragmentFilePath = ( + fragmentId: string, + isServer = false, +) => { + assertSafeFragmentId(fragmentId); + const fragmentsPath = paths(isServer).CADDY_FRAGMENTS_PATH; + const filePath = path.join(fragmentsPath, `${fragmentId}.json`); + assertPathWithinBase(fragmentsPath, filePath); + return filePath; +}; + +export const getCaddyActiveConfigPath = (isServer = false) => { + return paths(isServer).CADDY_CONFIG_PATH; +}; + +export const validateCaddyRouteIntent = (route: CaddyRouteIntent) => { + if (!route.id) { + throw new Error("Caddy route intent requires an id"); + } + if (!route.hosts.length) { + throw new Error(`Caddy route "${route.id}" requires at least one host`); + } + if ( + !route.upstreams.length && + !route.redirectScheme && + !route.staticResponse + ) { + throw new Error(`Caddy route "${route.id}" requires at least one upstream`); + } + if (route.staticResponse) { + const statusCode = route.staticResponse.statusCode; + if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) { + throw new Error( + `Caddy route "${route.id}" has invalid static response status code`, + ); + } + } + for (const upstream of route.upstreams) { + if (!upstream.trim()) { + throw new Error(`Caddy route "${route.id}" has an empty upstream`); + } + try { + parseCaddyUpstream(upstream); + } catch (error) { + const message = + error instanceof Error ? error.message : "invalid upstream format"; + throw new Error( + `Caddy route "${route.id}" has invalid upstream "${upstream}": ${message}`, + ); + } + } +}; + +export const validateCaddyRouteFragment = (fragment: CaddyRouteFragment) => { + assertSafeFragmentId(fragment.id); + if (fragment.version !== CADDY_FRAGMENT_VERSION) { + throw new Error( + `Unsupported Caddy fragment version for "${fragment.id}": ${fragment.version}`, + ); + } + for (const route of fragment.routes) { + validateCaddyRouteIntent(route); + } +}; + +const normalizePathPrefix = (prefix?: string | null) => { + if (!prefix || prefix === "/") { + return undefined; + } + return prefix.startsWith("/") ? prefix : `/${prefix}`; +}; + +const normalizeExactPath = (exactPath?: string | null) => { + if (!exactPath) { + return undefined; + } + return exactPath.startsWith("/") ? exactPath : `/${exactPath}`; +}; + +const getPathSpecificity = (route: CaddyRouteIntent) => { + return (route.pathExact ?? route.pathPrefix ?? "").length; +}; + +const routeSourcePrecedence: Record = { + manual: 0, + "traefik-dynamic-file": 0, + "traefik-compose-label": 0, + "dokploy-application": 1, + "dokploy-compose": 1, + "dokploy-dashboard": 1, +}; + +export const sortCaddyRouteIntents = (routes: CaddyRouteIntent[]) => { + return [...routes].sort((a, b) => { + const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + const specificityDiff = getPathSpecificity(b) - getPathSpecificity(a); + if (specificityDiff !== 0) return specificityDiff; + + const sourcePrecedenceDiff = + routeSourcePrecedence[a.source] - routeSourcePrecedence[b.source]; + if (sourcePrecedenceDiff !== 0) return sourcePrecedenceDiff; + + const sourceDiff = a.source.localeCompare(b.source); + if (sourceDiff !== 0) return sourceDiff; + + return a.id.localeCompare(b.id); + }); +}; + +const createRouteMatcher = (route: CaddyRouteIntent) => { + const matcher: CaddyJsonObject = { + host: route.hosts, + }; + const exactPath = normalizeExactPath(route.pathExact); + const pathPrefix = normalizePathPrefix(route.pathPrefix); + + if (exactPath) { + matcher.path = [exactPath]; + } else if (pathPrefix) { + matcher.path = [`${pathPrefix}*`]; + } + if (route.allowedRemoteIps?.length) { + matcher.remote_ip = { + ranges: route.allowedRemoteIps, + }; + } + + return [matcher]; +}; + +const createRouteMatcherWithoutRemoteIp = (route: CaddyRouteIntent) => { + const { allowedRemoteIps: _allowedRemoteIps, ...unrestrictedRoute } = route; + return createRouteMatcher(unrestrictedRoute); +}; + +const normalizeHeaderValues = (headers: CaddyHeaderMap) => { + return Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + Array.isArray(value) ? value : [value], + ]), + ); +}; + +const assertValidTrustedProxyRange = (range: string) => { + const [address, prefix, ...extra] = range.split("/"); + if (!address || !prefix || extra.length > 0) { + throw new Error(`Invalid Caddy trusted proxy CIDR range "${range}"`); + } + const ipVersion = isIP(address); + if (!ipVersion) { + throw new Error(`Invalid Caddy trusted proxy address "${address}"`); + } + if (!/^\d+$/.test(prefix)) { + throw new Error(`Invalid Caddy trusted proxy prefix "${prefix}"`); + } + const prefixNumber = Number(prefix); + const maxPrefix = ipVersion === 4 ? 32 : 128; + if (prefixNumber < 0 || prefixNumber > maxPrefix) { + throw new Error( + `Invalid Caddy trusted proxy prefix "${prefix}" for ${address}`, + ); + } +}; + +const normalizeClientIpHeaders = (headers: string[]) => { + if (!headers.length) { + throw new Error( + "Caddy trusted proxies require at least one client IP header", + ); + } + const seen = new Set(); + return headers.map((header) => { + const normalized = header.trim(); + if (!normalized) { + throw new Error("Caddy trusted proxy client IP header cannot be empty"); + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + throw new Error( + `Duplicate Caddy trusted proxy client IP header "${normalized}"`, + ); + } + seen.add(key); + return normalized; + }); +}; + +const createTrustedProxyServerOptions = ( + trustedProxies: CaddyCompileOptions["trustedProxies"], +) => { + if (!trustedProxies) { + return {}; + } + const ranges = + trustedProxies.source === "cloudflare" + ? [...CLOUDFLARE_TRUSTED_PROXY_RANGES] + : (trustedProxies.ranges ?? []); + if (!ranges.length) { + throw new Error("Caddy trusted proxies require at least one CIDR range"); + } + for (const range of ranges) { + assertValidTrustedProxyRange(range); + } + + const clientIpHeaders = normalizeClientIpHeaders( + trustedProxies.clientIpHeaders ?? + (trustedProxies.source === "cloudflare" + ? CLOUDFLARE_CLIENT_IP_HEADERS + : DEFAULT_CLIENT_IP_HEADERS), + ); + + return { + trusted_proxies: { + source: "static", + ranges, + }, + client_ip_headers: clientIpHeaders, + ...(trustedProxies.strict === false + ? {} + : { trusted_proxies_strict: true }), + }; +}; + +export const assertValidCaddyTrustedProxyConfig = ( + trustedProxies: CaddyCompileOptions["trustedProxies"], +) => { + createTrustedProxyServerOptions(trustedProxies); +}; + +const collectManualTlsCertificates = (routes: CaddyRouteIntent[]) => { + const seen = new Set(); + const certificates = []; + for (const route of routes) { + if (!route.tlsCertificate) { + continue; + } + const key = `${route.tlsCertificate.certificate}\0${route.tlsCertificate.key}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + certificates.push({ + certificate: route.tlsCertificate.certificate, + key: route.tlsCertificate.key, + }); + } + return certificates; +}; + +const createTlsAppConfig = ( + letsEncryptEmail: string | null | undefined, + manualCertificates: Array<{ certificate: string; key: string }>, +) => { + const tlsApp: CaddyJsonObject = {}; + if (manualCertificates.length) { + tlsApp.certificates = { + load_files: manualCertificates, + }; + } + if (letsEncryptEmail) { + tlsApp.automation = { + policies: [ + { + issuers: [ + { + module: "acme", + email: letsEncryptEmail, + }, + ], + }, + ], + }; + } + return Object.keys(tlsApp).length ? { tls: tlsApp } : {}; +}; + +const createCaddyAccessLogConfig = ( + accessLogs: CaddyCompileOptions["accessLogs"], +) => { + if (!accessLogs?.enabled) { + return {}; + } + + return { + logging: { + logs: { + "dokploy-requests": { + writer: { + output: "file", + filename: accessLogs.filename || CADDY_ACCESS_LOG_CONTAINER_PATH, + }, + encoder: { + format: "json", + }, + include: ["http.log.access"], + }, + }, + }, + }; +}; + +const createCaddyServerLogConfig = ( + accessLogs: CaddyCompileOptions["accessLogs"], +) => (accessLogs?.enabled ? { logs: {} } : {}); + +const createHeaderHandler = (route: CaddyRouteIntent) => { + const requestHeaders = route.transforms?.requestHeaders; + const responseHeaders = route.transforms?.responseHeaders; + const hasRequestHeaders = + requestHeaders && Object.keys(requestHeaders).length > 0; + const hasResponseHeaders = + responseHeaders && Object.keys(responseHeaders).length > 0; + if (!hasRequestHeaders && !hasResponseHeaders) { + return undefined; + } + + return { + handler: "headers", + ...(hasRequestHeaders && { + request: { + set: normalizeHeaderValues(requestHeaders ?? {}), + }, + }), + ...(hasResponseHeaders && { + response: { + set: normalizeHeaderValues(responseHeaders ?? {}), + }, + }), + }; +}; + +const createRewriteHandlers = (route: CaddyRouteIntent) => { + const handlers: CaddyJsonObject[] = []; + const stripPrefix = normalizePathPrefix(route.transforms?.stripPrefix); + const addPrefix = normalizePathPrefix(route.transforms?.addPrefix); + + if (stripPrefix) { + handlers.push({ + handler: "rewrite", + strip_path_prefix: stripPrefix, + }); + } + + if (addPrefix) { + handlers.push({ + handler: "rewrite", + uri: `${addPrefix}{http.request.uri.path}`, + }); + } + + return handlers; +}; + +const createBasicAuthHandler = (route: CaddyRouteIntent) => { + if (!route.basicAuth?.length) { + return undefined; + } + + return { + handler: "authentication", + providers: { + http_basic: { + accounts: route.basicAuth.map((account) => ({ + username: account.username, + password: account.hash, + })), + }, + }, + }; +}; + +const httpUrlPrefixRegex = /^https?:\/\//i; + +const stripUrlPath = (value: string) => value.split(/[/?#]/, 1)[0] ?? ""; + +const getAuthorityPort = (authority: string) => { + const withoutCredentials = authority.includes("@") + ? authority.slice(authority.lastIndexOf("@") + 1) + : authority; + + if (withoutCredentials.startsWith("[")) { + const closingBracketIndex = withoutCredentials.indexOf("]"); + if (closingBracketIndex === -1) { + return { host: withoutCredentials, port: "" }; + } + const host = withoutCredentials.slice(0, closingBracketIndex + 1); + const remainder = withoutCredentials.slice(closingBracketIndex + 1); + return { + host, + port: remainder.startsWith(":") ? remainder.slice(1) : "", + }; + } + + const separatorIndex = withoutCredentials.lastIndexOf(":"); + if (separatorIndex === -1) { + return { host: withoutCredentials, port: "" }; + } + + return { + host: withoutCredentials.slice(0, separatorIndex), + port: withoutCredentials.slice(separatorIndex + 1), + }; +}; + +const validateExplicitPort = (port: string) => { + if (!port) { + throw new Error("upstream must include an explicit port"); + } + if (!/^\d+$/.test(port)) { + throw new Error(`upstream port "${port}" must be numeric`); + } + const portNumber = Number(port); + if (portNumber < 1 || portNumber > 65535) { + throw new Error(`upstream port "${port}" must be between 1 and 65535`); + } + return port; +}; + +export const parseCaddyUpstream = (upstream: string) => { + const trimmed = upstream.trim(); + if (httpUrlPrefixRegex.test(trimmed)) { + const authority = stripUrlPath(trimmed.replace(httpUrlPrefixRegex, "")); + const { port } = getAuthorityPort(authority); + const explicitPort = validateExplicitPort(port); + const url = new URL(trimmed); + const scheme = url.protocol.replace(":", ""); + if (scheme !== "http" && scheme !== "https") { + throw new Error(`unsupported upstream scheme "${scheme}"`); + } + return { + dial: `${url.hostname}:${explicitPort}`, + scheme, + }; + } + + const { host, port } = getAuthorityPort(trimmed); + const explicitPort = validateExplicitPort(port); + if (!host) { + throw new Error("upstream must include a host"); + } + if (trimmed.includes("/") || trimmed.includes("?") || trimmed.includes("#")) { + throw new Error("raw upstream dials must be host:port only"); + } + return { + dial: `${host}:${explicitPort}`, + scheme: "http", + }; +}; + +const createReverseProxyHandler = (route: CaddyRouteIntent) => { + const parsedUpstreams = route.upstreams.map(parseCaddyUpstream); + const usesTlsTransport = parsedUpstreams.some( + (upstream) => upstream.scheme === "https", + ); + + return { + handler: "reverse_proxy", + upstreams: parsedUpstreams.map((upstream) => ({ dial: upstream.dial })), + ...(usesTlsTransport && { + transport: { + protocol: "http", + tls: {}, + }, + }), + }; +}; + +const createProxyRoute = (route: CaddyRouteIntent) => { + const handlers = [ + createHeaderHandler(route), + createBasicAuthHandler(route), + ...createRewriteHandlers(route), + createReverseProxyHandler(route), + ].filter(Boolean) as CaddyJsonObject[]; + + return { + match: createRouteMatcher(route), + handle: handlers, + terminal: true, + }; +}; + +const createStaticResponseRoute = (route: CaddyRouteIntent) => { + const response = route.staticResponse; + const handlers = [ + createHeaderHandler(route), + { + handler: "static_response", + status_code: response?.statusCode ?? 404, + ...(response?.body && { body: response.body }), + ...(response?.headers && { + headers: normalizeHeaderValues(response.headers), + }), + }, + ].filter(Boolean) as CaddyJsonObject[]; + + return { + match: createRouteMatcher(route), + handle: handlers, + terminal: true, + }; +}; + +const createForbiddenRoute = (route: CaddyRouteIntent) => { + return { + match: createRouteMatcherWithoutRemoteIp(route), + handle: [ + { + handler: "static_response", + status_code: 403, + }, + ], + terminal: true, + }; +}; + +const createRedirectLocation = (route: CaddyRouteIntent) => { + const scheme = route.redirectScheme?.scheme || "https"; + const port = route.redirectScheme?.port + ? `:${route.redirectScheme.port}` + : ""; + return `${scheme}://{http.request.host}${port}{http.request.uri}`; +}; + +const createRedirectRoute = (route: CaddyRouteIntent) => { + return { + match: createRouteMatcher(route), + handle: [ + { + handler: "static_response", + status_code: route.redirectScheme?.permanent === false ? 302 : 308, + headers: { + Location: [createRedirectLocation(route)], + }, + }, + ], + terminal: true, + }; +}; + +const createHttpsRedirectRoute = (route: CaddyRouteIntent) => { + return createRedirectRoute({ + ...route, + allowedRemoteIps: null, + redirectScheme: { scheme: "https", permanent: true }, + }); +}; + +const createCaddyMetricsServer = () => ({ + listen: [`:${CADDY_METRICS_PORT}`], + routes: [ + { + handle: [ + { + handler: "metrics", + }, + ], + terminal: true, + }, + ], +}); + +export const flattenCaddyFragments = (fragments: CaddyRouteFragment[] = []) => { + for (const fragment of fragments) { + validateCaddyRouteFragment(fragment); + } + return fragments.flatMap((fragment) => fragment.routes); +}; + +export const compileCaddyConfig = ({ + fragments = [], + routes = [], + letsEncryptEmail, + trustedProxies, + accessLogs, +}: CaddyCompileOptions = {}) => { + const allRoutes = sortCaddyRouteIntents([ + ...flattenCaddyFragments(fragments), + ...routes, + ]); + for (const route of allRoutes) { + validateCaddyRouteIntent(route); + } + + const httpRoutes = allRoutes.map((route) => { + if (route.redirectScheme) { + return createRedirectRoute(route); + } + if (route.staticResponse && !route.https) { + return createStaticResponseRoute(route); + } + return route.https + ? createHttpsRedirectRoute(route) + : createProxyRoute(route); + }); + const httpsRoutes = allRoutes + .filter((route) => route.https && !route.redirectScheme) + .flatMap((route) => { + const proxyRoute = route.staticResponse + ? createStaticResponseRoute(route) + : createProxyRoute(route); + return route.allowedRemoteIps?.length + ? [proxyRoute, createForbiddenRoute(route)] + : [proxyRoute]; + }); + const trustedProxyServerOptions = + createTrustedProxyServerOptions(trustedProxies); + const manualTlsCertificates = collectManualTlsCertificates(allRoutes); + + return { + admin: { + listen: "localhost:2019", + }, + ...createCaddyAccessLogConfig(accessLogs), + apps: { + ...createTlsAppConfig(letsEncryptEmail, manualTlsCertificates), + http: { + servers: { + http: { + listen: [":80"], + ...createCaddyServerLogConfig(accessLogs), + ...trustedProxyServerOptions, + routes: httpRoutes, + }, + https: { + listen: [":443"], + ...createCaddyServerLogConfig(accessLogs), + ...trustedProxyServerOptions, + routes: httpsRoutes, + }, + metrics: createCaddyMetricsServer(), + }, + }, + }, + }; +}; + +const encodeBase64 = (content: string) => + Buffer.from(content, "utf8").toString("base64"); + +const remoteWriteFileAtomic = async ( + serverId: string, + filePath: string, + content: string, +) => { + const tempPath = `${filePath}.tmp-${Date.now()}`; + const command = [ + `mkdir -p ${quote([path.posix.dirname(filePath)])}`, + `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([tempPath])}`, + `mv ${quote([tempPath])} ${quote([filePath])}`, + ].join(" && "); + await execAsyncRemote(serverId, command); +}; + +export const writeCaddyConfigContent = async ( + content: string, + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + await remoteWriteFileAtomic(options.serverId, filePath, content); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp-${Date.now()}`; + writeFileSync(tempPath, content, "utf8"); + renameSync(tempPath, filePath); +}; + +export const writeCaddyConfigFile = async ( + config: CaddyJsonObject, + options: CaddyFragmentStoreOptions = {}, +) => { + await writeCaddyConfigContent( + `${JSON.stringify(config, null, 2)}\n`, + options, + ); +}; + +export const writeCaddyRouteFragment = async ( + fragment: CaddyRouteFragment, + options: CaddyFragmentStoreOptions = {}, +) => { + validateCaddyRouteFragment(fragment); + const filePath = getCaddyFragmentFilePath(fragment.id, !!options.serverId); + const content = `${JSON.stringify(fragment, null, 2)}\n`; + if (options.serverId) { + await remoteWriteFileAtomic(options.serverId, filePath, content); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, "utf8"); +}; + +export const removeCaddyRouteFragment = async ( + fragmentId: string, + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyFragmentFilePath(fragmentId, !!options.serverId); + if (options.serverId) { + await execAsyncRemote(options.serverId, `rm -f ${quote([filePath])}`); + return; + } + rmSync(filePath, { force: true }); +}; + +export const restoreCaddyRouteFragments = async ( + fragments: CaddyRouteFragment[], + options: CaddyFragmentStoreOptions = {}, +) => { + const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH; + if (options.serverId) { + await execAsyncRemote( + options.serverId, + `rm -rf ${quote([fragmentsPath])} && mkdir -p ${quote([fragmentsPath])}`, + ); + } else { + rmSync(fragmentsPath, { recursive: true, force: true }); + mkdirSync(fragmentsPath, { recursive: true }); + } + + for (const fragment of fragments) { + await writeCaddyRouteFragment(fragment, options); + } +}; + +const parseFragmentContent = (fileName: string, content: string) => { + try { + const parsed = JSON.parse(content) as CaddyRouteFragment; + validateCaddyRouteFragment(parsed); + return parsed; + } catch (error) { + throw new Error( + `Invalid Caddy fragment ${fileName}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +}; + +export const readCaddyRouteFragments = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH; + if (options.serverId) { + const command = `if [ -d ${quote([fragmentsPath])} ]; then find ${quote([fragmentsPath])} -maxdepth 1 -type f -name '*.json' | sort | while IFS= read -r file; do printf '%s\n' "---DOKPLOY-CADDY-FILE:$file"; cat "$file"; printf '\n'; done; fi`; + const { stdout } = await execAsyncRemote(options.serverId, command); + const fragments: CaddyRouteFragment[] = []; + for (const block of stdout.split("---DOKPLOY-CADDY-FILE:")) { + if (!block.trim()) continue; + const newlineIndex = block.indexOf("\n"); + if (newlineIndex === -1) continue; + const fileName = block.slice(0, newlineIndex).trim(); + const content = block.slice(newlineIndex + 1).trim(); + fragments.push(parseFragmentContent(fileName, content)); + } + return fragments; + } + + if (!existsSync(fragmentsPath)) { + return []; + } + + return readdirSync(fragmentsPath) + .filter((fileName) => fileName.endsWith(".json")) + .sort() + .map((fileName) => { + const filePath = path.join(fragmentsPath, fileName); + return parseFragmentContent(fileName, readFileSync(filePath, "utf8")); + }); +}; + +export const compileAndWriteCaddyConfig = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + trustedProxies?: CaddyCompileOptions["trustedProxies"]; + accessLogs?: CaddyCompileOptions["accessLogs"]; + } = {}, +) => { + const fragments = await readCaddyRouteFragments(options); + const config = compileCaddyConfig({ + fragments, + letsEncryptEmail: options.letsEncryptEmail, + trustedProxies: options.trustedProxies, + accessLogs: options.accessLogs, + }); + await writeCaddyConfigFile(config, options); + return config; +}; + +export const compileWriteAndValidateCaddyConfigSafely = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + trustedProxies?: CaddyCompileOptions["trustedProxies"]; + accessLogs?: CaddyCompileOptions["accessLogs"]; + } = {}, +) => { + const previousConfig = await readCaddyConfigFileIfExists(options); + const config = await compileAndWriteCaddyConfig(options); + try { + await validateCaddyConfigWithContainer(options.serverId); + } catch (error) { + try { + if (previousConfig) { + await writeCaddyConfigContent(previousConfig, options); + } else { + await writeCaddyConfigFile(compileCaddyConfig(), options); + } + await validateCaddyConfigWithContainer(options.serverId); + } catch (restoreError) { + if (error instanceof Error) { + (error as Error & { restoreError?: unknown }).restoreError = + restoreError; + } + console.error("Failed to restore Caddy config:", restoreError); + } + throw error; + } + return config; +}; + +export const readCaddyConfigFile = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + const { stdout } = await execAsyncRemote( + options.serverId, + `cat ${quote([filePath])}`, + ); + return stdout; + } + return readFileSync(filePath, "utf8"); +}; + +export const readCaddyConfigFileIfExists = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + const { stdout } = await execAsyncRemote( + options.serverId, + `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`, + ); + return stdout || null; + } + if (!existsSync(filePath)) { + return null; + } + return readFileSync(filePath, "utf8"); +}; + +export const writeAndReloadCaddyConfigSafely = async ( + config: CaddyJsonObject, + options: CaddyFragmentStoreOptions = {}, +) => { + const previousConfig = await readCaddyConfigFileIfExists(options); + await writeCaddyConfigFile(config, options); + try { + await reloadCaddyAfterValidation(options.serverId); + } catch (error) { + try { + if (previousConfig) { + await writeCaddyConfigContent(previousConfig, options); + } else { + await writeCaddyConfigFile(compileCaddyConfig(), options); + } + await reloadCaddyAfterValidation(options.serverId); + } catch (restoreError) { + if (error instanceof Error) { + (error as Error & { restoreError?: unknown }).restoreError = + restoreError; + } + console.error("Failed to restore Caddy config:", restoreError); + } + throw error; + } +}; + +export const compileWriteAndReloadCaddyConfigSafely = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + trustedProxies?: CaddyCompileOptions["trustedProxies"]; + accessLogs?: CaddyCompileOptions["accessLogs"]; + } = {}, +) => { + const fragments = await readCaddyRouteFragments(options); + const config = compileCaddyConfig({ + fragments, + letsEncryptEmail: options.letsEncryptEmail, + trustedProxies: options.trustedProxies, + accessLogs: options.accessLogs, + }); + await writeAndReloadCaddyConfigSafely(config, options); + return config; +}; + +export const ensureDefaultCaddyConfig = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + trustedProxies?: CaddyCompileOptions["trustedProxies"]; + accessLogs?: CaddyCompileOptions["accessLogs"]; + } = {}, +) => { + const caddyPaths = paths(!!options.serverId); + if (options.serverId) { + await execAsyncRemote( + options.serverId, + `mkdir -p ${quote([ + caddyPaths.MAIN_CADDY_PATH, + caddyPaths.CADDY_FRAGMENTS_PATH, + caddyPaths.CADDY_DATA_PATH, + caddyPaths.CADDY_CONFIG_DIR_PATH, + ])}`, + ); + await compileAndWriteCaddyConfig(options); + return; + } + + mkdirSync(caddyPaths.MAIN_CADDY_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_DATA_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_CONFIG_DIR_PATH, { recursive: true }); + await compileAndWriteCaddyConfig(options); +}; + +const getCaddyExecTargetCommand = ` +if docker service inspect dokploy-caddy >/dev/null 2>&1; then + docker ps --filter "label=com.docker.swarm.service.name=dokploy-caddy" --format '{{.ID}}' | head -n 1 +else + echo dokploy-caddy +fi`; + +const execInCaddy = async (serverId: string | undefined, command: string) => { + const { stdout } = serverId + ? await execAsyncRemote(serverId, getCaddyExecTargetCommand) + : await execAsync(getCaddyExecTargetCommand); + const target = stdout.trim(); + if (!target) { + throw new Error("Caddy resource is not running"); + } + const dockerExecCommand = `docker exec ${quote([target])} ${command}`; + if (serverId) { + return execAsyncRemote(serverId, dockerExecCommand); + } + return execAsync(dockerExecCommand); +}; + +export const validateCaddyConfigWithContainer = async (serverId?: string) => { + return execInCaddy(serverId, "caddy validate --config /etc/caddy/caddy.json"); +}; + +export const validateCaddyConfigFileWithImage = async ( + configPath: string, + serverId?: string, +) => { + const imageName = `caddy:${CADDY_VERSION}`; + const validationRoot = path.posix.join( + path.posix.dirname(configPath), + ".validate-runtime", + ); + const validationDataPath = path.posix.join(validationRoot, "data"); + const validationConfigPath = path.posix.join(validationRoot, "config"); + const { CERTIFICATES_PATH } = paths(!!serverId); + const mkdirCommand = `mkdir -p ${quote([ + validationDataPath, + validationConfigPath, + CERTIFICATES_PATH, + ])}`; + const cleanupCommand = `rm -rf ${quote([validationRoot])}`; + const validateCommand = `docker run --rm --network none ${[ + "-v", + `${configPath}:/etc/caddy/caddy.json:ro`, + "-v", + `${validationDataPath}:/data`, + "-v", + `${validationConfigPath}:/config`, + "-v", + `${CERTIFICATES_PATH}:${CERTIFICATES_PATH}:ro`, + imageName, + "caddy", + "validate", + "--config", + "/etc/caddy/caddy.json", + ] + .map((part) => quote([part])) + .join(" ")}`; + if (serverId) { + await execAsyncRemote(serverId, mkdirCommand); + try { + return await execAsyncRemote(serverId, validateCommand); + } finally { + await execAsyncRemote(serverId, cleanupCommand).catch(() => undefined); + } + } + await execAsync(mkdirCommand); + try { + return await execAsync(validateCommand); + } finally { + await execAsync(cleanupCommand).catch(() => undefined); + } +}; + +export const reloadCaddyWithContainer = async (serverId?: string) => { + const { stdout } = serverId + ? await execAsyncRemote(serverId, getCaddyExecTargetCommand) + : await execAsync(getCaddyExecTargetCommand); + const target = stdout.trim(); + if (!target) { + throw new Error("Caddy resource is not running"); + } + const dockerExecCommand = `docker exec -w /etc/caddy ${quote([ + target, + ])} caddy reload --config /etc/caddy/caddy.json`; + if (serverId) { + return execAsyncRemote(serverId, dockerExecCommand); + } + return execAsync(dockerExecCommand); +}; + +export const reloadCaddyAfterValidation = async (serverId?: string) => { + await validateCaddyConfigWithContainer(serverId); + return reloadCaddyWithContainer(serverId); +}; diff --git a/packages/server/src/utils/caddy/domain.ts b/packages/server/src/utils/caddy/domain.ts new file mode 100644 index 0000000000..ae062e1ec0 --- /dev/null +++ b/packages/server/src/utils/caddy/domain.ts @@ -0,0 +1,210 @@ +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { assertCertificatePathAvailableForServer } from "@dokploy/server/services/certificate"; +import type { Domain } from "@dokploy/server/services/domain"; +import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings"; +import type { ApplicationNested } from "../builders"; +import { + compileWriteAndReloadCaddyConfigSafely, + readCaddyRouteFragments, + removeCaddyRouteFragment, + restoreCaddyRouteFragments, + writeCaddyRouteFragment, +} from "./config"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const getCaddyApplicationFragmentId = ( + appName: string, + uniqueConfigKey: number, +) => `application.${appName}.${uniqueConfigKey}`; + +const createCaddyRouteId = (appName: string, uniqueConfigKey: number) => + `${appName}-route-${uniqueConfigKey}`; + +const assertSafeCertificatePath = (certificatePath: string) => { + if ( + !/^[a-zA-Z0-9_.-]+$/.test(certificatePath) || + certificatePath === "." || + certificatePath === ".." || + certificatePath.split(".").some((segment) => segment === "") + ) { + throw new Error( + `Invalid Caddy custom certificate path "${certificatePath}". Use an uploaded certificate from Dokploy.`, + ); + } +}; + +export const getCaddyCustomCertificateFiles = ( + serverId: string | null | undefined, + certificatePath: string, +) => { + assertSafeCertificatePath(certificatePath); + const certDir = path.join( + paths(!!serverId).CERTIFICATES_PATH, + certificatePath, + ); + return { + certificate: path.join(certDir, "chain.crt"), + key: path.join(certDir, "privkey.key"), + }; +}; + +export const getUnsupportedCaddyDomainFieldMessages = (domain: Domain) => { + const messages: string[] = []; + if (domain.customEntrypoint) { + messages.push("custom entrypoints are not supported by Caddy routes"); + } + if ( + domain.https && + domain.customCertResolver && + domain.certificateType !== "custom" + ) { + messages.push("custom certificate resolvers are Traefik-specific"); + } + if ( + domain.https && + domain.certificateType === "custom" && + !domain.customCertResolver + ) { + messages.push("custom certificates require an uploaded certificate"); + } + if (domain.middlewares?.length) { + messages.push("Traefik middlewares are not translated to Caddy yet"); + } + return messages; +}; + +export const assertCaddyDomainSupported = (domain: Domain) => { + const messages = getUnsupportedCaddyDomainFieldMessages(domain); + if (messages.length) { + throw new Error( + `Domain "${domain.host}" uses unsupported Caddy fields: ${messages.join("; ")}`, + ); + } +}; + +export const assertCaddyDomainCertificateAvailable = async ( + serverId: string | null | undefined, + domain: Domain, + organizationId?: string | null, +) => { + if ( + domain.https && + domain.certificateType === "custom" && + domain.customCertResolver + ) { + await assertCertificatePathAvailableForServer( + domain.customCertResolver, + serverId, + organizationId, + ); + } +}; + +export const createCaddyApplicationRouteIntent = ( + app: ApplicationNested, + domain: Domain, +): CaddyRouteIntent => { + assertCaddyDomainSupported(domain); + const publicPath = domain.path && domain.path !== "/" ? domain.path : null; + const internalPath = + domain.internalPath && + domain.internalPath !== "/" && + domain.internalPath !== domain.path + ? domain.internalPath + : null; + + return { + id: createCaddyRouteId(app.appName, domain.uniqueConfigKey), + source: "dokploy-application", + hosts: [toPunycode(domain.host)], + pathPrefix: publicPath, + https: domain.https && !domain.customEntrypoint, + upstreams: [`http://${app.appName}:${domain.port || 80}`], + upstreamNetwork: DOKPLOY_CADDY_NETWORK, + tlsCertificate: + domain.https && + domain.certificateType === "custom" && + domain.customCertResolver + ? getCaddyCustomCertificateFiles( + app.serverId, + domain.customCertResolver, + ) + : null, + transforms: { + stripPrefix: domain.stripPath ? publicPath : null, + addPrefix: internalPath, + }, + }; +}; + +export const createCaddyApplicationRouteFragment = ( + app: ApplicationNested, + domain: Domain, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: getCaddyApplicationFragmentId(app.appName, domain.uniqueConfigKey), + source: "dokploy-application", + description: `Dokploy application route for ${app.appName}:${domain.uniqueConfigKey}`, + routes: [createCaddyApplicationRouteIntent(app, domain)], +}); + +export const manageCaddyDomain = async ( + app: ApplicationNested, + domain: Domain, +) => { + const serverId = app.serverId || undefined; + await assertCaddyDomainCertificateAvailable( + serverId, + domain, + app.environment?.project?.organizationId, + ); + const options = { serverId }; + const previousFragments = await readCaddyRouteFragments(options); + try { + await writeCaddyRouteFragment( + createCaddyApplicationRouteFragment(app, domain), + options, + ); + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + ...(await getCaddyCompileSettings(serverId)), + }); + } catch (error) { + await restoreCaddyRouteFragments(previousFragments, options); + throw error; + } +}; + +export const removeCaddyDomain = async ( + app: ApplicationNested, + uniqueConfigKey: number, +) => { + const serverId = app.serverId || undefined; + const options = { serverId }; + const previousFragments = await readCaddyRouteFragments(options); + try { + await removeCaddyRouteFragment( + getCaddyApplicationFragmentId(app.appName, uniqueConfigKey), + options, + ); + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + ...(await getCaddyCompileSettings(serverId)), + }); + } catch (error) { + await restoreCaddyRouteFragments(previousFragments, options); + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/types.ts b/packages/server/src/utils/caddy/types.ts new file mode 100644 index 0000000000..dbee1a0c5b --- /dev/null +++ b/packages/server/src/utils/caddy/types.ts @@ -0,0 +1,97 @@ +export type CaddyRouteSource = + | "dokploy-application" + | "dokploy-compose" + | "dokploy-dashboard" + | "manual" + | "traefik-dynamic-file" + | "traefik-compose-label"; + +export type CaddyHeaderMap = Record; + +export interface CaddyRouteTransform { + stripPrefix?: string | null; + addPrefix?: string | null; + requestHeaders?: CaddyHeaderMap | null; + responseHeaders?: CaddyHeaderMap | null; +} + +export interface CaddyRouteRedirectScheme { + scheme: string; + permanent?: boolean | null; + port?: string | null; +} + +export interface CaddyStaticResponse { + statusCode: number; + body?: string | null; + headers?: CaddyHeaderMap | null; +} + +export interface CaddyTlsCertificateFile { + certificate: string; + key: string; +} + +export interface CaddyBasicAuthAccount { + username: string; + hash: string; +} + +export interface CaddyRouteIntent { + id: string; + source: CaddyRouteSource; + hosts: string[]; + pathPrefix?: string | null; + pathExact?: string | null; + allowedRemoteIps?: string[] | null; + https?: boolean; + priority?: number | null; + upstreams: string[]; + upstreamNetwork?: string | null; + transforms?: CaddyRouteTransform | null; + basicAuth?: CaddyBasicAuthAccount[] | null; + tlsCertificate?: CaddyTlsCertificateFile | null; + redirectScheme?: CaddyRouteRedirectScheme | null; + staticResponse?: CaddyStaticResponse | null; +} + +export interface CaddyRouteFragment { + version: 1; + id: string; + source: CaddyRouteSource; + description?: string; + routes: CaddyRouteIntent[]; +} + +export interface CaddyTrustedProxyConfig { + source: "static" | "cloudflare"; + ranges?: string[] | null; + clientIpHeaders?: string[] | null; + strict?: boolean | null; +} + +export interface CaddyTrustedProxySettings { + mode: "disabled" | "cloudflare" | "static"; + ranges?: string[] | null; + clientIpHeaders?: string[] | null; + strict?: boolean | null; +} + +export interface CaddyAccessLogConfig { + enabled: boolean; + filename?: string | null; +} + +export interface CaddyCompileOptions { + fragments?: CaddyRouteFragment[]; + routes?: CaddyRouteIntent[]; + letsEncryptEmail?: string | null; + trustedProxies?: CaddyTrustedProxyConfig | null; + accessLogs?: CaddyAccessLogConfig | null; +} + +export type CaddyJsonObject = Record; + +export interface CaddyFragmentStoreOptions { + serverId?: string; +} diff --git a/packages/server/src/utils/caddy/upstream-targets.ts b/packages/server/src/utils/caddy/upstream-targets.ts new file mode 100644 index 0000000000..94a6063fe9 --- /dev/null +++ b/packages/server/src/utils/caddy/upstream-targets.ts @@ -0,0 +1,34 @@ +import type { Compose } from "@dokploy/server/services/compose"; + +export const DOKPLOY_CADDY_NETWORK = "dokploy-network"; + +export const getCaddyComposeNetworkAlias = ( + appName: string, + finalServiceName: string, +) => `${appName}-${finalServiceName}`; + +export const getCaddyComposeRuntimeTarget = ( + compose: Pick, + finalServiceName: string, +) => { + if (compose.composeType === "stack") { + return { + host: `${compose.appName}_${finalServiceName}`, + network: compose.isolatedDeployment + ? compose.appName + : DOKPLOY_CADDY_NETWORK, + }; + } + + if (compose.isolatedDeployment) { + return { + host: finalServiceName, + network: compose.appName, + }; + } + + return { + host: getCaddyComposeNetworkAlias(compose.appName, finalServiceName), + network: DOKPLOY_CADDY_NETWORK, + }; +}; diff --git a/packages/server/src/utils/caddy/web-server.ts b/packages/server/src/utils/caddy/web-server.ts new file mode 100644 index 0000000000..9274d08d85 --- /dev/null +++ b/packages/server/src/utils/caddy/web-server.ts @@ -0,0 +1,100 @@ +import type { webServerSettings } from "@dokploy/server/db/schema/web-server-settings"; +import { localWebServerSettingsToCaddyCompileSettings } from "@dokploy/server/services/web-server-settings"; +import { + compileWriteAndReloadCaddyConfigSafely, + readCaddyRouteFragments, + removeCaddyRouteFragment, + writeCaddyRouteFragment, +} from "./config"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; +const DASHBOARD_FRAGMENT_ID = "dashboard.dokploy"; + +const isSameFragment = ( + first: CaddyRouteFragment | undefined, + second: CaddyRouteFragment | undefined, +) => JSON.stringify(first) === JSON.stringify(second); + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const createCaddyDashboardRouteIntent = ( + settings: typeof webServerSettings.$inferSelect, + host: string, +): CaddyRouteIntent => ({ + id: "dokploy-dashboard", + source: "dokploy-dashboard", + hosts: [toPunycode(host)], + pathPrefix: "/", + https: !!settings.https, + upstreams: [`http://dokploy:${process.env.PORT || 3000}`], + upstreamNetwork: DOKPLOY_CADDY_NETWORK, +}); + +export const createCaddyDashboardRouteFragment = ( + settings: typeof webServerSettings.$inferSelect, + host: string, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: DASHBOARD_FRAGMENT_ID, + source: "dokploy-dashboard", + description: "Dokploy dashboard route", + routes: [createCaddyDashboardRouteIntent(settings, host)], +}); + +export const updateServerCaddy = async ( + settings: typeof webServerSettings.$inferSelect, + newHost: string | null, +) => { + const compileSettings = + localWebServerSettingsToCaddyCompileSettings(settings); + const previousDashboardFragment = (await readCaddyRouteFragments()).find( + (fragment) => fragment.id === DASHBOARD_FRAGMENT_ID, + ); + let writtenDashboardFragment: CaddyRouteFragment | undefined; + try { + if (newHost) { + writtenDashboardFragment = createCaddyDashboardRouteFragment( + settings, + newHost, + ); + await writeCaddyRouteFragment(writtenDashboardFragment); + } else { + await removeCaddyRouteFragment(DASHBOARD_FRAGMENT_ID); + } + + await compileWriteAndReloadCaddyConfigSafely(compileSettings); + } catch (error) { + const currentDashboardFragment = (await readCaddyRouteFragments()).find( + (fragment) => fragment.id === DASHBOARD_FRAGMENT_ID, + ); + const shouldRestorePrevious = newHost + ? isSameFragment(currentDashboardFragment, writtenDashboardFragment) + : !currentDashboardFragment; + + if (shouldRestorePrevious) { + if (previousDashboardFragment) { + await writeCaddyRouteFragment(previousDashboardFragment); + } else { + await removeCaddyRouteFragment(DASHBOARD_FRAGMENT_ID); + } + } else { + try { + await compileWriteAndReloadCaddyConfigSafely(compileSettings); + } catch (resyncError) { + if (error instanceof Error) { + (error as Error & { resyncError?: unknown }).resyncError = + resyncError; + } + } + } + throw error; + } +}; diff --git a/packages/server/src/utils/cluster/upload.ts b/packages/server/src/utils/cluster/upload.ts index b4235c767a..0968afc7bd 100644 --- a/packages/server/src/utils/cluster/upload.ts +++ b/packages/server/src/utils/cluster/upload.ts @@ -1,8 +1,8 @@ import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment"; import { findRegistryByIdWithCredentials, - safeDockerLoginCommand, type Registry, + safeDockerLoginCommand, } from "@dokploy/server/services/registry"; import { createRollback } from "@dokploy/server/services/rollbacks"; import type { ApplicationNested } from "../builders"; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2a..79cbfe3637 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -3,7 +3,11 @@ 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 { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; import { parse, stringify } from "yaml"; +import { writeCaddyComposeRouteFragments } from "../caddy/compose"; +import { assertCaddyDomainSupported } from "../caddy/domain"; +import { getCaddyComposeNetworkAlias } from "../caddy/upstream-targets"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; import { cloneGitRepository } from "../providers/git"; @@ -11,11 +15,13 @@ import { cloneGiteaRepository } from "../providers/gitea"; import { cloneGithubRepository } from "../providers/github"; import { cloneGitlabRepository } from "../providers/gitlab"; import { getCreateComposeFileCommand } from "../providers/raw"; +import type { WebServerProvider } from "../web-server/providers"; import { randomizeDeployableSpecificationFile } from "./collision"; import { randomizeSpecificationFile } from "./compose"; import type { ComposeSpecification, DefinitionsService, + ListOrDict, PropertiesNetworks, } from "./types"; import { encodeBase64 } from "./utils"; @@ -106,12 +112,187 @@ export const readComposeFile = async (compose: Compose) => { return null; }; +export type CaddyComposeRouteTarget = { + domain: Domain; + finalServiceName: string; +}; + +const loadComposeSpecification = async (compose: Compose) => { + if (compose.serverId) { + return loadDockerComposeRemote(compose); + } + return loadDockerCompose(compose); +}; + +const finalizeComposeSpecification = ( + compose: Compose, + composeSpec: ComposeSpecification, +) => { + let result = composeSpec; + + if (compose.isolatedDeployment) { + result = randomizeDeployableSpecificationFile( + result, + compose.isolatedDeploymentsVolume, + compose.suffix || compose.appName, + ); + } else if (compose.randomize) { + result = randomizeSpecificationFile(result, compose.suffix); + } + + return result; +}; + +const resolveFinalServiceName = ( + compose: Compose, + composeSpec: ComposeSpecification, + serviceName: string, +) => { + if (composeSpec.services?.[serviceName]) { + return serviceName; + } + + const suffix = compose.randomize + ? compose.suffix + : compose.isolatedDeployment + ? compose.suffix || compose.appName + : null; + if (suffix) { + const suffixedServiceName = `${serviceName}-${suffix}`; + if (composeSpec.services?.[suffixedServiceName]) { + return suffixedServiceName; + } + } + + return serviceName; +}; + +const addDomainToComposeForCaddyWithRoutes = async ( + compose: Compose, + domains: Domain[], +): Promise<{ + composeSpec: ComposeSpecification | null; + caddyRouteTargets: CaddyComposeRouteTarget[]; +}> => { + const result = await loadComposeSpecification(compose); + if (!result) { + return { composeSpec: null, caddyRouteTargets: [] }; + } + + for (const domain of domains) { + assertCaddyDomainSupported(domain); + } + + const finalizedCompose = finalizeComposeSpecification(compose, result); + const caddyRouteTargets: CaddyComposeRouteTarget[] = []; + + for (const domain of domains) { + const { serviceName } = domain; + if (!serviceName) { + throw new Error(`Domain "${domain.host}" is missing a service name`); + } + + const finalServiceName = resolveFinalServiceName( + compose, + finalizedCompose, + serviceName, + ); + if (!finalizedCompose.services?.[finalServiceName]) { + throw new Error( + `Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`, + ); + } + + const service = finalizedCompose.services[finalServiceName]; + if (compose.composeType === "docker-compose") { + if (service.labels) { + service.labels = removeDokployGeneratedTraefikLabels(service.labels, { + appName: compose.appName, + domains, + }); + } + } else if (service.deploy?.labels) { + service.deploy.labels = removeDokployGeneratedTraefikLabels( + service.deploy.labels, + { + appName: compose.appName, + domains, + }, + ); + } + + if (!compose.isolatedDeployment) { + service.networks = addDokployNetworkToService(service.networks, { + aliases: + compose.composeType === "docker-compose" + ? [getCaddyComposeNetworkAlias(compose.appName, finalServiceName)] + : undefined, + }); + } + + caddyRouteTargets.push({ domain, finalServiceName }); + } + + if (!compose.isolatedDeployment) { + finalizedCompose.networks = addDokployNetworkToRoot( + finalizedCompose.networks, + ); + } + + return { composeSpec: finalizedCompose, caddyRouteTargets }; +}; + +export const getCaddyComposeRouteTargetsForWebServer = async ( + compose: Compose, + domains: Domain[], + provider?: WebServerProvider, +) => { + const resolvedProvider = + provider ?? (await resolveWebServerProvider(compose.serverId)); + if (resolvedProvider !== "caddy") { + return null; + } + + return (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .caddyRouteTargets; +}; + +export const writeCaddyComposeRoutesForTargets = async ( + compose: Compose, + caddyRouteTargets: CaddyComposeRouteTarget[], + options: { + organizationId?: string | null; + } = {}, +) => { + await writeCaddyComposeRouteFragments(compose, caddyRouteTargets, options); +}; + +export const addDomainToComposeForWebServer = async ( + compose: Compose, + domains: Domain[], + provider?: WebServerProvider, +) => { + const resolvedProvider = + provider ?? (await resolveWebServerProvider(compose.serverId)); + if (resolvedProvider === "caddy") { + return (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .composeSpec; + } + + return addDomainToCompose(compose, domains); +}; + export const writeDomainsToCompose = async ( compose: Compose, domains: Domain[], ) => { try { - const composeConverted = await addDomainToCompose(compose, domains); + const provider = await resolveWebServerProvider(compose.serverId); + const composeConverted = + provider === "caddy" + ? (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .composeSpec + : await addDomainToCompose(compose, domains); const path = getComposePath(compose); if (!composeConverted) { @@ -347,8 +528,156 @@ export const createDomainLabels = ( return labels; }; +export type DokployTraefikLabelClassifierContext = { + appName?: string; + domains?: Pick[]; + includeGenericLabels?: boolean; +}; + +const getLabelKey = (label: string) => label.split("=")[0] ?? label; + +const getLabelValue = (label: string) => { + const separatorIndex = label.indexOf("="); + return separatorIndex === -1 ? undefined : label.slice(separatorIndex + 1); +}; + +const getDomainEntrypoints = ( + domain: Pick, +) => { + if (domain.customEntrypoint) { + return [domain.customEntrypoint]; + } + return domain.https ? ["web", "websecure"] : ["web"]; +}; + +const isGenericDokployTraefikLabel = (label: string) => { + const key = getLabelKey(label); + const value = getLabelValue(label); + + if (key === "traefik.enable") { + return value === undefined || value === "true"; + } + + if (key === "traefik.docker.network" || key === "traefik.swarm.network") { + return value === undefined || value === "dokploy-network"; + } + + return false; +}; + +const isDomainSpecificDokployTraefikLabel = ( + label: string, + context: DokployTraefikLabelClassifierContext = {}, +) => { + const key = getLabelKey(label); + const { appName, domains } = context; + + if (appName && domains) { + for (const domain of domains) { + for (const entrypoint of getDomainEntrypoints(domain)) { + const routerName = `${appName}-${domain.uniqueConfigKey}-${entrypoint}`; + if ( + key.startsWith(`traefik.http.routers.${routerName}.`) || + key.startsWith(`traefik.http.services.${routerName}.`) + ) { + return true; + } + } + + if ( + key.startsWith( + `traefik.http.middlewares.stripprefix-${appName}-${domain.uniqueConfigKey}.`, + ) || + key.startsWith( + `traefik.http.middlewares.addprefix-${appName}-${domain.uniqueConfigKey}.`, + ) + ) { + return true; + } + } + + return false; + } + + if (appName) { + const appRouterPattern = new RegExp( + `^traefik\\.http\\.(routers|services)\\.${appName.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}.+-(web|websecure)\\.`, + ); + if (appRouterPattern.test(key)) { + return true; + } + } + + return ( + /^traefik\.http\.(routers|services)\.[^.]+-\d+-[^.]+\./.test(key) || + /^traefik\.http\.middlewares\.(stripprefix|addprefix)-.+-\d+\./.test(key) + ); +}; + +export const isDokployGeneratedTraefikLabel = ( + label: string, + context: DokployTraefikLabelClassifierContext = {}, +) => { + return ( + isDomainSpecificDokployTraefikLabel(label, context) || + (context.includeGenericLabels !== false && + isGenericDokployTraefikLabel(label)) + ); +}; + +const stringifyLabelObjectEntry = ( + key: string, + value: string | number | boolean | null, +) => (value === null ? key : `${key}=${value}`); + +export const removeDokployGeneratedTraefikLabels = ( + labels: ListOrDict | undefined, + context: DokployTraefikLabelClassifierContext = {}, +): ListOrDict | undefined => { + if (!labels) { + return labels; + } + + if (Array.isArray(labels)) { + const removedSpecificLabel = labels.some((label) => + isDomainSpecificDokployTraefikLabel(label, context), + ); + return labels.filter((label) => { + if (isDomainSpecificDokployTraefikLabel(label, context)) { + return false; + } + if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) { + return false; + } + return true; + }); + } + + const entries = Object.entries(labels); + const removedSpecificLabel = entries.some(([key, value]) => + isDomainSpecificDokployTraefikLabel( + stringifyLabelObjectEntry(key, value), + context, + ), + ); + + return Object.fromEntries( + entries.filter(([key, value]) => { + const label = stringifyLabelObjectEntry(key, value); + if (isDomainSpecificDokployTraefikLabel(label, context)) { + return false; + } + if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) { + return false; + } + return true; + }), + ); +}; + export const addDokployNetworkToService = ( networkService: DefinitionsService["networks"], + options: { aliases?: string[] } = {}, ) => { let networks = networkService; const network = "dokploy-network"; @@ -364,6 +693,17 @@ export const addDokployNetworkToService = ( if (!networks.includes(defaultNetwork)) { networks.push(defaultNetwork); } + if (options.aliases?.length) { + const nextNetworks: Record = {}; + for (const item of networks) { + nextNetworks[item] = {}; + } + nextNetworks[network] = { + ...nextNetworks[network], + aliases: [...new Set(options.aliases)], + }; + networks = nextNetworks; + } } else if (networks && typeof networks === "object") { if (!(network in networks)) { networks[network] = {}; @@ -371,6 +711,17 @@ export const addDokployNetworkToService = ( if (!(defaultNetwork in networks)) { networks[defaultNetwork] = {}; } + if (options.aliases?.length) { + const current = networks[network]; + const currentAliases = + current && typeof current === "object" && "aliases" in current + ? ((current.aliases as string[] | undefined) ?? []) + : []; + networks[network] = { + ...(current && typeof current === "object" ? current : {}), + aliases: [...new Set([...currentAliases, ...options.aliases])], + }; + } } return networks; diff --git a/packages/server/src/utils/web-server/domain.ts b/packages/server/src/utils/web-server/domain.ts new file mode 100644 index 0000000000..19066863d8 --- /dev/null +++ b/packages/server/src/utils/web-server/domain.ts @@ -0,0 +1,31 @@ +import type { Domain } from "@dokploy/server/services/domain"; +import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; +import type { ApplicationNested } from "../builders"; +import { manageCaddyDomain, removeCaddyDomain } from "../caddy/domain"; +import { manageDomain, removeDomain } from "../traefik/domain"; + +export const manageWebServerDomain = async ( + app: ApplicationNested, + domain: Domain, +) => { + const provider = await resolveWebServerProvider(app.serverId); + + if (provider === "caddy") { + return manageCaddyDomain(app, domain); + } + + return manageDomain(app, domain); +}; + +export const removeWebServerDomain = async ( + app: ApplicationNested, + uniqueConfigKey: number, +) => { + const provider = await resolveWebServerProvider(app.serverId); + + if (provider === "caddy") { + return removeCaddyDomain(app, uniqueConfigKey); + } + + return removeDomain(app, uniqueConfigKey); +}; diff --git a/packages/server/src/utils/web-server/paths.ts b/packages/server/src/utils/web-server/paths.ts new file mode 100644 index 0000000000..1d113bf030 --- /dev/null +++ b/packages/server/src/utils/web-server/paths.ts @@ -0,0 +1,29 @@ +import { paths } from "@dokploy/server/constants"; +import type { WebServerProvider } from "./providers"; + +export const getWebServerPaths = ( + provider: WebServerProvider, + isServer = false, +) => { + const resolvedPaths = paths(isServer); + + if (provider === "caddy") { + return { + provider, + basePath: resolvedPaths.MAIN_CADDY_PATH, + activeConfigPath: resolvedPaths.CADDY_CONFIG_PATH, + fragmentsPath: resolvedPaths.CADDY_FRAGMENTS_PATH, + dataPath: resolvedPaths.CADDY_DATA_PATH, + configDirPath: resolvedPaths.CADDY_CONFIG_DIR_PATH, + migrationsPath: resolvedPaths.CADDY_MIGRATIONS_PATH, + }; + } + + return { + provider, + basePath: resolvedPaths.MAIN_TRAEFIK_PATH, + activeConfigPath: `${resolvedPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + fragmentsPath: resolvedPaths.DYNAMIC_TRAEFIK_PATH, + certificatesPath: resolvedPaths.CERTIFICATES_PATH, + }; +}; diff --git a/packages/server/src/utils/web-server/providers.ts b/packages/server/src/utils/web-server/providers.ts new file mode 100644 index 0000000000..e5c29623a3 --- /dev/null +++ b/packages/server/src/utils/web-server/providers.ts @@ -0,0 +1,20 @@ +export const WEB_SERVER_PROVIDERS = ["traefik", "caddy"] as const; + +export type WebServerProvider = (typeof WEB_SERVER_PROVIDERS)[number]; + +export const DEFAULT_WEB_SERVER_PROVIDER: WebServerProvider = "traefik"; + +export const isWebServerProvider = ( + provider: unknown, +): provider is WebServerProvider => + typeof provider === "string" && + WEB_SERVER_PROVIDERS.includes(provider as WebServerProvider); + +export const normalizeWebServerProvider = ( + provider: unknown, +): WebServerProvider => + isWebServerProvider(provider) ? provider : DEFAULT_WEB_SERVER_PROVIDER; + +export const getWebServerResourceName = (provider: WebServerProvider) => { + return provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik"; +}; From cee43a4b7aee61a7ec8f3731e3ec53d7e632fd45 Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 10 Jun 2026 19:09:28 -0400 Subject: [PATCH 04/14] =?UTF-8?q?feat(caddy):=20guarded=20Traefik=E2=86=92?= =?UTF-8?q?Caddy=20migration=20=E2=80=94=20dry-run,=20preflight,=20apply,?= =?UTF-8?q?=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 +- apps/dokploy/esbuild.config.ts | 1 + apps/dokploy/package.json | 2 + .../scripts/caddy-migration-rollback.ts | 87 ++ .../server/src/utils/caddy/migration/apply.ts | 464 +++++++ .../migration/compose-label-translator.ts | 575 +++++++++ .../migration/dynamic-file-translator.ts | 736 +++++++++++ .../server/src/utils/caddy/migration/files.ts | 335 +++++ .../src/utils/caddy/migration/prepare.ts | 1096 +++++++++++++++++ .../src/utils/caddy/migration/rollback.ts | 238 ++++ .../caddy/migration/traefik-rule-parser.ts | 337 +++++ .../server/src/utils/caddy/migration/types.ts | 200 +++ .../caddy/migration/upstream-preflight.ts | 336 +++++ 13 files changed, 4410 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/scripts/caddy-migration-rollback.ts create mode 100644 packages/server/src/utils/caddy/migration/apply.ts create mode 100644 packages/server/src/utils/caddy/migration/compose-label-translator.ts create mode 100644 packages/server/src/utils/caddy/migration/dynamic-file-translator.ts create mode 100644 packages/server/src/utils/caddy/migration/files.ts create mode 100644 packages/server/src/utils/caddy/migration/prepare.ts create mode 100644 packages/server/src/utils/caddy/migration/rollback.ts create mode 100644 packages/server/src/utils/caddy/migration/traefik-rule-parser.ts create mode 100644 packages/server/src/utils/caddy/migration/types.ts create mode 100644 packages/server/src/utils/caddy/migration/upstream-preflight.ts diff --git a/Dockerfile b/Dockerfile index 4cab0e8f36..6886e32178 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build +RUN test -f /usr/src/app/apps/dokploy/dist/caddy-migration-rollback.mjs RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy @@ -43,7 +44,8 @@ COPY --from=build /prod/dokploy/drizzle ./drizzle COPY .env.production ./.env COPY --from=build /prod/dokploy/components.json ./components.json COPY --from=build /prod/dokploy/node_modules ./node_modules - +RUN test -f /app/dist/caddy-migration-rollback.mjs \ + && node -r dotenv/config /app/dist/caddy-migration-rollback.mjs --help | grep -q "Usage: caddy-migration-rollback" # Install docker RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash diff --git a/apps/dokploy/esbuild.config.ts b/apps/dokploy/esbuild.config.ts index fa053d726c..e590de8254 100644 --- a/apps/dokploy/esbuild.config.ts +++ b/apps/dokploy/esbuild.config.ts @@ -29,6 +29,7 @@ try { "reset-password": "reset-password.ts", "reset-2fa": "reset-2fa.ts", "migrate-auth-secret": "scripts/migrate-auth-secret.ts", + "caddy-migration-rollback": "scripts/caddy-migration-rollback.ts", }, bundle: true, platform: "node", diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 8b5d2d6b69..dbc688611c 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -20,6 +20,8 @@ "migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts", "migration:run": "tsx -r dotenv/config migration.ts", "manual-migration:run": "tsx -r dotenv/config migrate.ts", + "caddy:migration:rollback": "tsx -r dotenv/config scripts/caddy-migration-rollback.ts", + "caddy:migration:rollback:runtime": "node -r dotenv/config dist/caddy-migration-rollback.mjs", "migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts", "migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts", "db:push": "drizzle-kit push --config ./server/db/drizzle.config.ts", diff --git a/apps/dokploy/scripts/caddy-migration-rollback.ts b/apps/dokploy/scripts/caddy-migration-rollback.ts new file mode 100644 index 0000000000..40e9936a3f --- /dev/null +++ b/apps/dokploy/scripts/caddy-migration-rollback.ts @@ -0,0 +1,87 @@ +import { pathToFileURL } from "node:url"; +import type { CaddyMigrationReport } from "@dokploy/server"; + +interface RollbackArgs { + migrationId: string; + serverId?: string; +} + +interface CliIo { + stdout: Pick; + stderr: Pick; +} + +const usage = + "Usage: caddy-migration-rollback --migration-id [--server-id ]"; + +export const parseCaddyRollbackArgs = (argv: string[]): RollbackArgs => { + let migrationId = ""; + let serverId: string | undefined; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--migration-id") { + migrationId = argv[++index] ?? ""; + } else if (arg === "--server-id") { + serverId = argv[++index] || undefined; + } else if (arg === "--help" || arg === "-h") { + throw new Error(usage); + } else { + throw new Error(`Unknown argument "${arg}". ${usage}`); + } + } + if (!migrationId) { + throw new Error(`Missing --migration-id. ${usage}`); + } + return { migrationId, serverId }; +}; + +const isHelpRequest = (argv: string[]) => + argv.some((arg) => arg === "--help" || arg === "-h"); + +const buildOutput = (report: CaddyMigrationReport) => ({ + migrationId: report.migrationId, + status: report.status, + providerTarget: "traefik", + warnings: report.warnings, + summary: report.summary, + reportPath: report.artifactPaths.reportJson, +}); + +export const runCaddyMigrationRollbackCli = async ( + argv = process.argv.slice(2), + io: CliIo = process, +) => { + if (isHelpRequest(argv)) { + io.stdout.write(`${usage}\n`); + return 0; + } + + try { + const args = parseCaddyRollbackArgs(argv); + const { rollbackCaddyMigration } = await import("@dokploy/server"); + const report = await rollbackCaddyMigration(args); + const output = buildOutput(report); + io.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return report.status === "rolled_back" ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Rollback failed"; + io.stderr.write(`${message}\n`); + io.stdout.write( + `${JSON.stringify( + { + status: "failed", + providerTarget: "traefik", + error: message, + }, + null, + 2, + )}\n`, + ); + return 1; + } +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const code = await runCaddyMigrationRollbackCli(); + process.exit(code); +} diff --git a/packages/server/src/utils/caddy/migration/apply.ts b/packages/server/src/utils/caddy/migration/apply.ts new file mode 100644 index 0000000000..15493362aa --- /dev/null +++ b/packages/server/src/utils/caddy/migration/apply.ts @@ -0,0 +1,464 @@ +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + getDockerResourceSnapshot, + readEnvironmentVariables, + readPorts, + stopDockerResource, + waitForDockerResourceRunning, + writeCaddySetup, +} from "@dokploy/server/services/settings"; +import { + getCaddyCompileSettings, + resolveWebServerProvider, + updateLocalWebServerProvider, + updateRemoteWebServerProvider, +} from "@dokploy/server/services/web-server-settings"; +import { filterTraefikCarryOverPortsForCaddyMigration } from "@dokploy/server/setup/caddy-setup"; +import { + reloadCaddyAfterValidation, + validateCaddyConfigFileWithImage, + validateCaddyConfigWithContainer, +} from "@dokploy/server/utils/caddy/config"; +import { + acquireCaddyMigrationOperationLock, + appendCaddyMigrationEvent, + copyMigrationFileInPlace, + copyMigrationPath, + loadCaddyMigrationReport, + migrationPathExists, + readMigrationTextFileIfExists, + removeMigrationPath, + writeCaddyMigrationReport, + writeMigrationTextFile, +} from "./files"; +import { rollbackCaddyMigration } from "./rollback"; +import type { + CaddyMigrationBackupSummary, + CaddyMigrationFileBackup, + CaddyMigrationReport, + CaddyMigrationResourceSnapshot, +} from "./types"; +import { runCaddyMigrationUpstreamPreflight } from "./upstream-preflight"; + +const updateProviderToCaddy = async (serverId?: string | null) => { + if (serverId) { + await updateRemoteWebServerProvider(serverId, "caddy"); + return; + } + await updateLocalWebServerProvider("caddy"); +}; + +type CaddyMigrationCompileSettings = NonNullable< + CaddyMigrationReport["compileSettings"] +>; + +const normalizeCompileSettings = ( + settings: CaddyMigrationReport["compileSettings"], +): CaddyMigrationCompileSettings => ({ + letsEncryptEmail: settings?.letsEncryptEmail ?? null, + trustedProxies: settings?.trustedProxies ?? null, + accessLogs: settings?.accessLogs ?? null, +}); + +const compileSettingsChanged = ( + prepared: CaddyMigrationReport["compileSettings"], + current: CaddyMigrationReport["compileSettings"], +) => + JSON.stringify(normalizeCompileSettings(prepared)) !== + JSON.stringify(normalizeCompileSettings(current)); + +const createFileBackup = async ( + label: string, + source: string, + backupPath: string, + serverId?: string | null, +): Promise => { + const existed = await migrationPathExists(source, serverId); + if (existed) { + await copyMigrationPath(source, backupPath, serverId); + } + return { label, source, backupPath, existed }; +}; + +const redactResourceSnapshot = ( + snapshot: CaddyMigrationResourceSnapshot, +): CaddyMigrationResourceSnapshot => ({ + resourceName: snapshot.resourceName, + resourceType: snapshot.resourceType, + running: snapshot.running, + replicas: snapshot.replicas, + additionalPorts: snapshot.additionalPorts, +}); + +const createMigrationBackups = async ( + report: CaddyMigrationReport, + serverId?: string, +): Promise => { + const currentPaths = paths(!!serverId); + const backupRoot = report.artifactPaths.backupsDir; + const files: CaddyMigrationFileBackup[] = []; + files.push( + await createFileBackup( + "traefik-static", + path.posix.join(currentPaths.MAIN_TRAEFIK_PATH, "traefik.yml"), + path.posix.join(backupRoot, "traefik", "traefik.yml"), + serverId, + ), + ); + files.push( + await createFileBackup( + "traefik-dynamic", + currentPaths.DYNAMIC_TRAEFIK_PATH, + path.posix.join(backupRoot, "traefik", "dynamic"), + serverId, + ), + ); + files.push( + await createFileBackup( + "caddy-config", + currentPaths.CADDY_CONFIG_PATH, + path.posix.join(backupRoot, "caddy", "caddy.json"), + serverId, + ), + ); + files.push( + await createFileBackup( + "caddy-fragments", + currentPaths.CADDY_FRAGMENTS_PATH, + path.posix.join(backupRoot, "caddy", "fragments"), + serverId, + ), + ); + + const [traefikResource, caddyResource] = await Promise.all([ + getDockerResourceSnapshot("dokploy-traefik", serverId), + getDockerResourceSnapshot("dokploy-caddy", serverId), + ]); + const restoreSnapshotPath = path.posix.join( + backupRoot, + "resources.restore.json", + ); + await writeMigrationTextFile( + restoreSnapshotPath, + `${JSON.stringify({ traefikResource, caddyResource }, null, 2)}\n`, + serverId, + ); + + return { + createdAt: new Date().toISOString(), + traefikResource: redactResourceSnapshot(traefikResource), + caddyResource: redactResourceSnapshot(caddyResource), + restoreSnapshotPath, + files, + }; +}; + +const readResourceEnvAndPorts = async ( + resourceName: string, + serverId?: string, +) => { + try { + const [env, additionalPorts] = await Promise.all([ + readEnvironmentVariables(resourceName, serverId), + readPorts(resourceName, serverId), + ]); + return { env, additionalPorts }; + } catch { + return { env: "", additionalPorts: [] }; + } +}; + +const validateAppliedCaddy = async (serverId?: string) => { + await waitForDockerResourceRunning("dokploy-caddy", serverId, { + retries: 30, + intervalMs: 1000, + }); + await validateCaddyConfigWithContainer(serverId); +}; + +const getLetsEncryptEmailFromConfig = async ( + caddyJsonPath: string, + serverId?: string, +) => { + const content = await readMigrationTextFileIfExists(caddyJsonPath, serverId); + if (!content) return null; + try { + const config = JSON.parse(content) as { + apps?: { + tls?: { + automation?: { + policies?: Array<{ + issuers?: Array<{ email?: string }>; + }>; + }; + }; + }; + }; + return ( + config.apps?.tls?.automation?.policies?.[0]?.issuers?.[0]?.email ?? null + ); + } catch { + return null; + } +}; + +const markFailed = async ( + report: CaddyMigrationReport, + message: string, + serverId?: string, +) => { + const warning = { + code: "apply-failed" as const, + message, + blocking: true, + }; + return writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { + ...report, + status: "failed", + warnings: [...report.warnings, warning], + summary: { + ...report.summary, + warnings: report.summary.warnings + 1, + blockingWarnings: report.summary.blockingWarnings + 1, + }, + }, + "apply_failed", + message, + ), + serverId, + ); +}; + +export const applyCaddyMigration = async (input: { + migrationId: string; + serverId?: string; +}) => { + const releaseLock = await acquireCaddyMigrationOperationLock( + input.migrationId, + input.serverId, + ); + try { + return await applyCaddyMigrationUnlocked(input); + } finally { + await releaseLock(); + } +}; + +const applyCaddyMigrationUnlocked = async (input: { + migrationId: string; + serverId?: string; +}) => { + const serverId = input.serverId; + let report = await loadCaddyMigrationReport(input.migrationId, serverId); + if (report.summary.blockingWarnings > 0) { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: report has ${report.summary.blockingWarnings} blocking warning(s)`, + ); + } + if (report.validation.status !== "passed") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: draft validation did not pass`, + ); + } + if (report.status !== "prepared") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: report status is ${report.status}`, + ); + } + if ( + !(await migrationPathExists(report.artifactPaths.caddyJson, serverId)) || + !(await migrationPathExists(report.artifactPaths.fragmentsDir, serverId)) + ) { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: approved Caddy artifacts are missing`, + ); + } + const provider = await resolveWebServerProvider(serverId); + if (provider !== "traefik") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: active provider is ${provider}`, + ); + } + const currentCompileSettings = await getCaddyCompileSettings(serverId); + if ( + report.compileSettings && + compileSettingsChanged(report.compileSettings, currentCompileSettings) + ) { + const message = `Cannot apply Caddy migration ${input.migrationId}: Caddy compile settings changed after prepare. Prepare a fresh migration dry run before applying.`; + await markFailed(report, message, serverId); + throw new Error(message); + } + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "applying" }, + "applying", + "Applying Caddy migration cutover", + ), + serverId, + ); + + const runtimePreflight = await runCaddyMigrationUpstreamPreflight(report, { + serverId, + }); + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, runtimePreflight }, + "runtime_preflight", + `Runtime upstream preflight ${runtimePreflight.status}`, + ), + serverId, + ); + if (runtimePreflight.status !== "passed") { + const failedChecks = runtimePreflight.checks.filter( + (check) => check.status === "failed", + ); + const message = + runtimePreflight.status === "failed" + ? `Runtime upstream preflight failed for ${failedChecks.length} upstream check(s)` + : `Runtime upstream preflight did not pass: ${runtimePreflight.status}`; + await markFailed(report, message, serverId); + throw new Error(message); + } + + try { + await validateCaddyConfigFileWithImage( + report.artifactPaths.caddyJson, + serverId, + ); + } catch (error) { + const message = `Pre-stop Caddy runtime validation failed: ${ + error instanceof Error ? error.message : "unknown error" + }`; + await markFailed(report, message, serverId); + throw error; + } + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report }, + "pre_stop_caddy_validate", + "Prepared Caddy config validated with the runtime Caddy image before stopping Traefik", + ), + serverId, + ); + + try { + const backup = await createMigrationBackups(report, serverId); + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, backup }, + "backup_created", + "Backed up Traefik and Caddy configuration before cutover", + ), + serverId, + ); + + const currentPaths = paths(!!serverId); + await removeMigrationPath(currentPaths.CADDY_FRAGMENTS_PATH, serverId); + await copyMigrationPath( + report.artifactPaths.fragmentsDir, + currentPaths.CADDY_FRAGMENTS_PATH, + serverId, + ); + await copyMigrationPath( + report.artifactPaths.caddyJson, + currentPaths.CADDY_CONFIG_PATH, + serverId, + ); + + const caddyRuntime = await readResourceEnvAndPorts( + "dokploy-caddy", + serverId, + ); + const traefikRuntime = await readResourceEnvAndPorts( + "dokploy-traefik", + serverId, + ); + const runtime = (await migrationPathExists( + currentPaths.CADDY_CONFIG_PATH, + serverId, + )) + ? { + env: caddyRuntime.env || undefined, + additionalPorts: caddyRuntime.additionalPorts.length + ? caddyRuntime.additionalPorts + : traefikRuntime.additionalPorts, + } + : { + env: undefined, + additionalPorts: traefikRuntime.additionalPorts, + }; + const caddyAdditionalPorts = filterTraefikCarryOverPortsForCaddyMigration( + runtime.additionalPorts, + ); + const droppedPorts = runtime.additionalPorts.filter( + (port) => !caddyAdditionalPorts.includes(port), + ); + if (droppedPorts.length) { + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report }, + "caddy_ports_filtered", + `Dropped Traefik-only additional port(s) for Caddy: ${droppedPorts + .map( + (port) => + `${port.publishedPort}->${port.targetPort}/${port.protocol ?? "tcp"}`, + ) + .join(", ")}`, + ), + serverId, + ); + } + + await stopDockerResource("dokploy-traefik", serverId); + await writeCaddySetup({ + env: runtime.env ? runtime.env.split("\n").filter(Boolean) : undefined, + additionalPorts: caddyAdditionalPorts, + serverId, + letsEncryptEmail: await getLetsEncryptEmailFromConfig( + report.artifactPaths.caddyJson, + serverId, + ), + trustedProxies: currentCompileSettings.trustedProxies, + }); + await copyMigrationFileInPlace( + report.artifactPaths.caddyJson, + currentPaths.CADDY_CONFIG_PATH, + serverId, + ); + await reloadCaddyAfterValidation(serverId); + await validateAppliedCaddy(serverId); + await updateProviderToCaddy(serverId); + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "applied" }, + "applied", + "Caddy migration applied successfully", + ), + serverId, + ); + return report; + } catch (error) { + const message = + error instanceof Error ? error.message : "Caddy migration apply failed"; + report = await markFailed(report, message, serverId); + try { + await rollbackCaddyMigration({ + migrationId: input.migrationId, + serverId, + skipOperationLock: true, + }); + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error + ? rollbackError.message + : "rollback failed"; + throw new Error(`${message}; rollback also failed: ${rollbackMessage}`); + } + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/migration/compose-label-translator.ts b/packages/server/src/utils/caddy/migration/compose-label-translator.ts new file mode 100644 index 0000000000..4a1d33583f --- /dev/null +++ b/packages/server/src/utils/caddy/migration/compose-label-translator.ts @@ -0,0 +1,575 @@ +import type { Domain } from "@dokploy/server/services/domain"; +import { isDokployGeneratedTraefikLabel } from "../../docker/domain"; +import type { ListOrDict } from "../../docker/types"; +import type { HttpMiddleware } from "../../traefik/file-types"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "../types"; +import { + defaultKnownTraefikFileMiddlewares, + translateTraefikMiddleware, +} from "./dynamic-file-translator"; +import { parseTraefikRule } from "./traefik-rule-parser"; +import type { CaddyMigrationWarning, KnownTraefikMiddlewareMap } from "./types"; + +interface ComposeLabelTranslatorOptions { + sourceFile?: string; + fragmentId?: string; + appName?: string; + serviceName?: string; + upstreamServiceName?: string; + upstreamNetwork?: string | null; + composeType?: "docker-compose" | "stack"; + domains?: Domain[]; + knownMiddlewares?: KnownTraefikMiddlewareMap; + fileMiddlewares?: Record; +} + +interface ParsedRouterLabels { + rule?: string; + entryPoints?: string[]; + middlewares?: string[]; + service?: string; + priority?: number; + tls?: boolean; + tlsCertResolver?: string; +} + +interface ParsedServiceLabels { + port?: number; + scheme?: string; +} + +interface LabelClassification { + label: string; + dokployGenerated: boolean; +} + +const CADDY_FRAGMENT_VERSION = 1; + +const toSafeId = (value: string) => + value + .replace(/\.[^.]+$/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "compose-labels"; + +const warning = ( + message: string, + options: ComposeLabelTranslatorOptions & { + routerName?: string; + serviceName?: string; + middlewareName?: string; + label?: string; + code?: CaddyMigrationWarning["code"]; + }, +): CaddyMigrationWarning => ({ + code: options.code ?? "invalid-label", + message, + blocking: true, + source: options.sourceFile, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, + label: options.label, +}); + +const labelsToStrings = (labels: ListOrDict | undefined): string[] => { + if (!labels) { + return []; + } + if (Array.isArray(labels)) { + return labels.map(String); + } + return Object.entries(labels).map(([key, value]) => + value === null ? key : `${key}=${value}`, + ); +}; + +const splitLabel = (label: string) => { + const separatorIndex = label.indexOf("="); + if (separatorIndex === -1) { + return { key: label, value: undefined }; + } + return { + key: label.slice(0, separatorIndex), + value: label.slice(separatorIndex + 1), + }; +}; + +const splitList = (value: string | undefined) => + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + +const mergeTransforms = ( + target: NonNullable, + next: NonNullable, +) => { + target.requestHeaders = { + ...(target.requestHeaders ?? {}), + ...(next.requestHeaders ?? {}), + }; + target.responseHeaders = { + ...(target.responseHeaders ?? {}), + ...(next.responseHeaders ?? {}), + }; + if (next.stripPrefix) target.stripPrefix = next.stripPrefix; + if (next.addPrefix) target.addPrefix = next.addPrefix; +}; + +const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => { + if (!next?.length) { + return; + } + for (const range of next) { + if (!target.includes(range)) { + target.push(range); + } + } +}; + +const ensureMiddleware = ( + middlewares: Record>, + name: string, +) => { + middlewares[name] ??= {}; + return middlewares[name]; +}; + +const parseBoolean = (value: string | undefined) => + value === undefined ? undefined : value === "true"; + +const setMiddlewareLabel = ( + middlewares: Record>, + name: string, + suffix: string, + value: string | undefined, +) => { + const middleware = ensureMiddleware(middlewares, name); + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "stripprefix.prefixes") { + middleware.stripPrefix = { prefixes: splitList(value) }; + return true; + } + if (lowerSuffix === "addprefix.prefix") { + middleware.addPrefix = { prefix: value }; + return true; + } + if (lowerSuffix === "basicauth.users") { + middleware.basicAuth = { + ...(middleware.basicAuth as Record | undefined), + users: splitList(value), + }; + return true; + } + if (lowerSuffix === "basicauth.usersfile") { + middleware.basicAuth = { + ...(middleware.basicAuth as Record | undefined), + usersFile: value, + }; + return true; + } + if (lowerSuffix === "redirectscheme.scheme") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + scheme: value, + }; + return true; + } + if (lowerSuffix === "redirectscheme.permanent") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + permanent: parseBoolean(value), + }; + return true; + } + if (lowerSuffix === "redirectscheme.port") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + port: value, + }; + return true; + } + if (lowerSuffix === "chain.middlewares") { + middleware.chain = { middlewares: splitList(value) }; + return true; + } + if (lowerSuffix.startsWith("ipallowlist.")) { + middleware.ipAllowList = { + ...(middleware.ipAllowList as Record | undefined), + [suffix.slice("ipallowlist.".length)]: lowerSuffix.endsWith("sourcerange") + ? splitList(value) + : value, + }; + return true; + } + if (lowerSuffix.startsWith("ipwhitelist.")) { + middleware.ipWhiteList = { + ...(middleware.ipWhiteList as Record | undefined), + [suffix.slice("ipwhitelist.".length)]: lowerSuffix.endsWith("sourcerange") + ? splitList(value) + : value, + }; + return true; + } + if (lowerSuffix.startsWith("headers.customresponseheaders.")) { + const header = suffix.slice("headers.customresponseheaders.".length); + middleware.headers = { + ...(middleware.headers as Record | undefined), + customResponseHeaders: { + ...(( + middleware.headers as + | Record> + | undefined + )?.customResponseHeaders ?? {}), + [header]: value ?? "", + }, + }; + return true; + } + if (lowerSuffix.startsWith("headers.customrequestheaders.")) { + const header = suffix.slice("headers.customrequestheaders.".length); + middleware.headers = { + ...(middleware.headers as Record | undefined), + customRequestHeaders: { + ...(( + middleware.headers as + | Record> + | undefined + )?.customRequestHeaders ?? {}), + [header]: value ?? "", + }, + }; + return true; + } + return false; +}; + +const parseComposeLabels = ( + labels: string[], + options: ComposeLabelTranslatorOptions, +) => { + const routers: Record = {}; + const services: Record = {}; + const middlewares: Record> = {}; + const warnings: CaddyMigrationWarning[] = []; + const classifications: LabelClassification[] = []; + + for (const label of labels) { + const { key, value } = splitLabel(label); + const dokployGenerated = isDokployGeneratedTraefikLabel(label, { + appName: options.appName, + domains: options.domains, + includeGenericLabels: true, + }); + classifications.push({ label, dokployGenerated }); + + const routerMatch = key.match(/^traefik\.http\.routers\.([^.]+)\.(.+)$/); + if (routerMatch?.[1] && routerMatch[2]) { + const routerName = routerMatch[1]; + const suffix = routerMatch[2]; + routers[routerName] ??= {}; + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "rule") routers[routerName].rule = value; + else if (lowerSuffix === "entrypoints") { + routers[routerName].entryPoints = splitList(value); + } else if (lowerSuffix === "middlewares") { + routers[routerName].middlewares = splitList(value); + } else if (lowerSuffix === "service") routers[routerName].service = value; + else if (lowerSuffix === "priority") { + const priority = Number(value); + if (Number.isFinite(priority)) routers[routerName].priority = priority; + } else if (lowerSuffix === "tls") { + routers[routerName].tls = parseBoolean(value) ?? value === ""; + } else if (lowerSuffix === "tls.certresolver") { + routers[routerName].tlsCertResolver = value; + } else { + warnings.push( + warning(`Unsupported router label suffix "${suffix}"`, { + ...options, + routerName, + label, + code: "unsupported-router", + }), + ); + } + continue; + } + + const serviceMatch = key.match(/^traefik\.http\.services\.([^.]+)\.(.+)$/); + if (serviceMatch?.[1] && serviceMatch[2]) { + const serviceName = serviceMatch[1]; + const suffix = serviceMatch[2]; + services[serviceName] ??= {}; + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "loadbalancer.server.port") { + const port = Number(value); + if (Number.isFinite(port)) services[serviceName].port = port; + } else if (lowerSuffix === "loadbalancer.server.scheme") { + services[serviceName].scheme = value; + } else { + warnings.push( + warning(`Unsupported service label suffix "${suffix}"`, { + ...options, + serviceName, + label, + code: "unsupported-service", + }), + ); + } + continue; + } + + const middlewareMatch = key.match( + /^traefik\.http\.middlewares\.([^.]+)\.(.+)$/, + ); + if (middlewareMatch?.[1] && middlewareMatch[2]) { + const middlewareName = middlewareMatch[1]; + const suffix = middlewareMatch[2]; + if (!setMiddlewareLabel(middlewares, middlewareName, suffix, value)) { + warnings.push( + warning(`Unsupported middleware label suffix "${suffix}"`, { + ...options, + middlewareName, + label, + code: "unsupported-middleware", + }), + ); + } + continue; + } + + if (key.startsWith("traefik.http.")) { + warnings.push( + warning(`Unsupported Traefik HTTP label "${key}"`, { + ...options, + label, + code: "invalid-label", + }), + ); + } + } + + return { routers, services, middlewares, warnings, classifications }; +}; + +const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) => + item.code === "unsupported-security-middleware"; + +const dedupeWarnings = (warnings: CaddyMigrationWarning[]) => { + const seen = new Set(); + return warnings.filter((item) => { + const key = [ + item.code, + item.source ?? "", + item.routerName ?? "", + item.serviceName ?? "", + item.middlewareName ?? "", + item.label ?? "", + item.message, + ].join("\0"); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const routerUsesHttps = (router: ParsedRouterLabels) => + Boolean(router.tls) || + Boolean(router.tlsCertResolver) || + Boolean( + router.entryPoints?.includes("websecure") && + !router.entryPoints?.includes("web"), + ); + +const getRouterUpstreams = ( + routerName: string, + router: ParsedRouterLabels, + services: Record, + options: ComposeLabelTranslatorOptions, +) => { + const serviceName = router.service ?? routerName; + const service = services[serviceName] ?? services[routerName]; + if (!service?.port) { + return { + upstreams: [], + warnings: [ + warning( + `Router "${routerName}" references service "${serviceName}" without a migrated port label`, + { + ...options, + routerName, + serviceName, + code: "unresolved-service", + }, + ), + ], + }; + } + const scheme = service.scheme ?? "http"; + const upstreamName = + options.upstreamServiceName ?? options.serviceName ?? serviceName; + return { + upstreams: [`${scheme}://${upstreamName}:${service.port}`], + warnings: [], + }; +}; + +export const translateTraefikComposeLabelsToCaddyFragment = ( + labelsInput: ListOrDict | undefined, + options: ComposeLabelTranslatorOptions = {}, +): { + fragment: CaddyRouteFragment; + routes: CaddyRouteIntent[]; + warnings: CaddyMigrationWarning[]; + classifications: LabelClassification[]; +} => { + const labels = labelsToStrings(labelsInput); + const initialParsed = parseComposeLabels(labels, options); + const generatedSecurityWarnings = initialParsed.classifications.some( + (item) => item.dokployGenerated, + ) + ? translateParsedComposeRoutes(initialParsed, options).warnings.filter( + isSecurityMiddlewareWarning, + ) + : []; + const hasManualLabels = initialParsed.classifications.some( + (item) => !item.dokployGenerated, + ); + const hasGeneratedLabels = initialParsed.classifications.some( + (item) => item.dokployGenerated, + ); + const labelsForTranslation = + hasManualLabels && hasGeneratedLabels + ? labels.filter( + (_, index) => !initialParsed.classifications[index]?.dokployGenerated, + ) + : labels; + const parsed = + labelsForTranslation === labels + ? initialParsed + : parseComposeLabels(labelsForTranslation, options); + const warnings = dedupeWarnings([ + ...parsed.warnings, + ...generatedSecurityWarnings, + ]); + const translatedRoutes = translateParsedComposeRoutes(parsed, options); + warnings.push(...translatedRoutes.warnings); + return { + ...translatedRoutes, + warnings: dedupeWarnings(warnings), + classifications: initialParsed.classifications, + }; +}; + +const translateParsedComposeRoutes = ( + parsed: ReturnType, + options: ComposeLabelTranslatorOptions, +) => { + const warnings: CaddyMigrationWarning[] = []; + const routes: CaddyRouteIntent[] = []; + const middlewares = { + ...(options.fileMiddlewares ?? {}), + ...(parsed.middlewares as Record), + }; + + for (const [routerName, router] of Object.entries(parsed.routers)) { + if (!router.rule) { + warnings.push( + warning(`Router "${routerName}" is missing a rule label`, { + ...options, + routerName, + code: "unsupported-router", + }), + ); + continue; + } + + const rule = parseTraefikRule(router.rule, { + source: options.sourceFile, + routerName, + }); + warnings.push(...rule.warnings); + + const transforms = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + let redirectScheme: CaddyRouteIntent["redirectScheme"] = null; + for (const middlewareName of router.middlewares ?? []) { + const translated = translateTraefikMiddleware( + middlewareName, + middlewares, + { + sourceFile: options.sourceFile, + routerName, + knownMiddlewares: { + ...defaultKnownTraefikFileMiddlewares, + ...(options.knownMiddlewares ?? {}), + }, + }, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + + if (router.tlsCertResolver && router.tlsCertResolver !== "letsencrypt") { + warnings.push( + warning( + `Custom certResolver "${router.tlsCertResolver}" has no direct Caddy mapping`, + { + ...options, + routerName, + code: "unsupported-router", + }, + ), + ); + } + + const upstreamResult = redirectScheme + ? { upstreams: [], warnings: [] } + : getRouterUpstreams(routerName, router, parsed.services, options); + warnings.push(...upstreamResult.warnings); + + rule.matches.forEach((match, index) => { + routes.push({ + id: `${toSafeId(options.sourceFile ?? options.appName ?? "compose")}-${routerName}${rule.matches.length > 1 ? `-${index + 1}` : ""}`, + source: "traefik-compose-label", + hosts: match.hosts, + pathPrefix: match.pathPrefix, + pathExact: match.pathExact, + https: redirectScheme ? false : routerUsesHttps(router), + priority: router.priority ?? null, + upstreams: upstreamResult.upstreams, + upstreamNetwork: options.upstreamNetwork, + transforms, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + basicAuth: basicAuth.length ? basicAuth : null, + redirectScheme, + }); + }); + } + + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: + options.fragmentId ?? + `migration.traefik-compose-label.${toSafeId( + options.sourceFile ?? options.appName ?? "compose", + )}`, + source: "traefik-compose-label", + description: options.sourceFile + ? `Migrated Traefik compose labels from ${options.sourceFile}` + : "Migrated Traefik compose labels", + routes, + }; + + return { + fragment, + routes, + warnings, + }; +}; diff --git a/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts new file mode 100644 index 0000000000..cd75b2aa92 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts @@ -0,0 +1,736 @@ +import { parse } from "yaml"; +import type { FileConfig, HttpMiddleware } from "../../traefik/file-types"; +import type { + CaddyHeaderMap, + CaddyRouteFragment, + CaddyRouteIntent, + CaddyRouteTransform, +} from "../types"; +import { parseTraefikRule } from "./traefik-rule-parser"; +import type { + CaddyMiddlewareTranslation, + CaddyMigrationWarning, + KnownTraefikMiddlewareMap, + ResolvedCaddyMiddleware, +} from "./types"; + +interface DynamicTranslatorOptions { + sourceFile?: string; + fragmentId?: string; + knownMiddlewares?: KnownTraefikMiddlewareMap; + fileMiddlewares?: Record; +} + +const CADDY_FRAGMENT_VERSION = 1; +const httpUrlPrefixRegex = /^https?:\/\//i; + +export const defaultKnownTraefikFileMiddlewares: KnownTraefikMiddlewareMap = { + "redirect-to-https": { + redirectScheme: { scheme: "https", permanent: true }, + }, + "security-headers": { + transforms: { + responseHeaders: { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }, + }, +}; + +const toSafeId = (value: string) => + value + .replace(/\.[^.]+$/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "traefik-dynamic"; + +const warning = ( + message: string, + options: { + source?: string; + routerName?: string; + serviceName?: string; + middlewareName?: string; + code?: CaddyMigrationWarning["code"]; + }, +): CaddyMigrationWarning => ({ + code: options.code ?? "unsupported-middleware", + message, + blocking: true, + source: options.source, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, +}); + +const asRecord = (value: unknown) => + value && typeof value === "object" ? (value as Record) : {}; + +const mergeHeaders = ( + a?: CaddyHeaderMap | null, + b?: CaddyHeaderMap | null, +) => ({ + ...(a ?? {}), + ...(b ?? {}), +}); + +const mergeTransforms = ( + target: CaddyRouteTransform, + next: CaddyRouteTransform = {}, +) => { + target.requestHeaders = mergeHeaders( + target.requestHeaders, + next.requestHeaders, + ); + target.responseHeaders = mergeHeaders( + target.responseHeaders, + next.responseHeaders, + ); + if (next.stripPrefix) { + target.stripPrefix = next.stripPrefix; + } + if (next.addPrefix) { + target.addPrefix = next.addPrefix; + } +}; + +const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => { + if (!next?.length) { + return; + } + for (const range of next) { + if (!target.includes(range)) { + target.push(range); + } + } +}; + +const normalizeMiddlewareName = (name: string) => name.replace(/@file$/, ""); + +const knownMiddlewareToTranslation = ( + middleware: Partial & { + responseHeaders?: CaddyHeaderMap; + requestHeaders?: CaddyHeaderMap; + }, +): ResolvedCaddyMiddleware => ({ + transforms: { + ...(middleware.transforms ?? {}), + ...(middleware.requestHeaders || middleware.responseHeaders + ? { + requestHeaders: middleware.requestHeaders, + responseHeaders: middleware.responseHeaders, + } + : {}), + }, + basicAuth: middleware.basicAuth ?? [], + allowedRemoteIps: middleware.allowedRemoteIps ?? null, + redirectScheme: middleware.redirectScheme ?? null, +}); + +const setResponseHeader = ( + transforms: CaddyRouteTransform, + name: string, + value: string, +) => { + transforms.responseHeaders = { + ...(transforms.responseHeaders ?? {}), + [name]: value, + }; +}; + +const translateHeadersMiddleware = (headers: Record) => { + const transforms: CaddyRouteTransform = {}; + const customRequestHeaders = asRecord(headers.customRequestHeaders); + const customResponseHeaders = asRecord(headers.customResponseHeaders); + if (Object.keys(customRequestHeaders).length) { + transforms.requestHeaders = customRequestHeaders as CaddyHeaderMap; + } + if (Object.keys(customResponseHeaders).length) { + transforms.responseHeaders = customResponseHeaders as CaddyHeaderMap; + } + if (headers.frameDeny === true) { + setResponseHeader(transforms, "X-Frame-Options", "DENY"); + } + if (typeof headers.customFrameOptionsValue === "string") { + setResponseHeader( + transforms, + "X-Frame-Options", + headers.customFrameOptionsValue, + ); + } + if (headers.contentTypeNosniff === true) { + setResponseHeader(transforms, "X-Content-Type-Options", "nosniff"); + } + if (headers.browserXssFilter === true) { + setResponseHeader(transforms, "X-XSS-Protection", "1; mode=block"); + } + if (typeof headers.customBrowserXSSValue === "string") { + setResponseHeader( + transforms, + "X-XSS-Protection", + headers.customBrowserXSSValue, + ); + } + if (typeof headers.contentSecurityPolicy === "string") { + setResponseHeader( + transforms, + "Content-Security-Policy", + headers.contentSecurityPolicy, + ); + } + if (typeof headers.referrerPolicy === "string") { + setResponseHeader(transforms, "Referrer-Policy", headers.referrerPolicy); + } + if (typeof headers.permissionsPolicy === "string") { + setResponseHeader( + transforms, + "Permissions-Policy", + headers.permissionsPolicy, + ); + } + if (typeof headers.featurePolicy === "string") { + setResponseHeader(transforms, "Feature-Policy", headers.featurePolicy); + } + if (typeof headers.stsSeconds === "number" && headers.stsSeconds > 0) { + setResponseHeader( + transforms, + "Strict-Transport-Security", + [ + `max-age=${headers.stsSeconds}`, + headers.stsIncludeSubdomains === true ? "includeSubDomains" : null, + headers.stsPreload === true ? "preload" : null, + ] + .filter(Boolean) + .join("; "), + ); + } + return transforms; +}; + +const parseBasicAuthUsers = (users: unknown) => { + if (!Array.isArray(users)) { + return []; + } + return users.flatMap((user) => { + if (typeof user !== "string") { + return []; + } + const separatorIndex = user.indexOf(":"); + if (separatorIndex === -1) { + return []; + } + return [ + { + username: user.slice(0, separatorIndex), + hash: user.slice(separatorIndex + 1), + }, + ]; + }); +}; + +const isLikelyCaddySupportedBasicAuthHash = (hash: string) => + /^\$2[aby]\$/.test(hash); + +const findCaseInsensitiveValue = ( + record: Record, + key: string, +) => { + const lowerKey = key.toLowerCase(); + const entry = Object.entries(record).find( + ([entryKey]) => entryKey.toLowerCase() === lowerKey, + ); + return entry?.[1]; +}; + +const getIpAllowRanges = (middleware: Record) => { + const ipConfig = asRecord(middleware.ipAllowList ?? middleware.ipWhiteList); + const sourceRange = findCaseInsensitiveValue(ipConfig, "sourceRange"); + if (!Array.isArray(sourceRange)) { + return []; + } + return sourceRange + .map((item) => `${item}`.trim()) + .filter((item) => item.length > 0); +}; + +const normalizeTraefikServerUrl = (value: string) => { + if (!httpUrlPrefixRegex.test(value)) { + return value; + } + try { + const url = new URL(value); + if (url.port) { + return value; + } + const scheme = url.protocol.replace(":", ""); + const defaultPort = scheme === "https" ? "443" : "80"; + return `${scheme}://${url.hostname}:${defaultPort}`; + } catch { + return value; + } +}; + +export const translateTraefikMiddleware = ( + middlewareName: string, + middlewares: Record = {}, + options: DynamicTranslatorOptions & { + routerName?: string; + seen?: Set; + } = {}, +): CaddyMiddlewareTranslation => { + const normalizedName = normalizeMiddlewareName(middlewareName); + const source = options.sourceFile; + const seen = options.seen ?? new Set(); + if (seen.has(normalizedName)) { + return { + transforms: {}, + basicAuth: [], + warnings: [ + warning(`Middleware chain cycle at "${normalizedName}"`, { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ], + }; + } + + const knownMiddleware = { + ...defaultKnownTraefikFileMiddlewares, + ...(options.knownMiddlewares ?? {}), + }[normalizedName]; + if (knownMiddleware && !middlewares[normalizedName]) { + return { + ...knownMiddlewareToTranslation(knownMiddleware), + warnings: [], + }; + } + + const middleware = middlewares[normalizedName]; + if (!middleware) { + return { + transforms: {}, + basicAuth: [], + warnings: [ + warning(`Referenced middleware "${middlewareName}" was not found`, { + source, + routerName: options.routerName, + middlewareName, + code: "unresolved-middleware", + }), + ], + }; + } + + const middlewareRecord = middleware as Record; + const transforms: CaddyRouteTransform = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + const warnings: CaddyMigrationWarning[] = []; + let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null; + + const securityMiddleware = middlewareRecord.ipAllowList + ? "ipAllowList" + : middlewareRecord.ipWhiteList + ? "ipWhiteList" + : null; + if (securityMiddleware) { + const ranges = getIpAllowRanges(middlewareRecord); + if (ranges.length) { + mergeAllowedRemoteIps(allowedRemoteIps, ranges); + } else { + warnings.push( + warning( + `Traefik middleware "${normalizedName}" uses ${securityMiddleware} without a migratable sourceRange`, + { + source, + routerName: options.routerName, + middlewareName: normalizedName, + code: "unsupported-security-middleware", + }, + ), + ); + } + } else if (middlewareRecord.headers) { + mergeTransforms( + transforms, + translateHeadersMiddleware(asRecord(middlewareRecord.headers)), + ); + } else if (middlewareRecord.stripPrefix) { + const prefixes = asRecord(middlewareRecord.stripPrefix).prefixes; + if (Array.isArray(prefixes) && prefixes.length === 1) { + transforms.stripPrefix = `${prefixes[0]}`; + } else { + warnings.push( + warning("stripPrefix migration supports exactly one prefix", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.addPrefix) { + const prefix = asRecord(middlewareRecord.addPrefix).prefix; + if (typeof prefix === "string") { + transforms.addPrefix = prefix; + } else { + warnings.push( + warning("addPrefix middleware is missing a string prefix", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.basicAuth) { + const basicAuthConfig = asRecord(middlewareRecord.basicAuth); + for (const account of parseBasicAuthUsers(basicAuthConfig.users)) { + if (isLikelyCaddySupportedBasicAuthHash(account.hash)) { + basicAuth.push(account); + continue; + } + warnings.push( + warning( + `basicAuth user "${account.username}" uses a hash format that is not supported by the Caddy migration`, + { + source, + routerName: options.routerName, + middlewareName: normalizedName, + code: "unsupported-security-middleware", + }, + ), + ); + } + if (basicAuthConfig.usersFile) { + warnings.push( + warning("basicAuth usersFile cannot be inlined into Caddy migration", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.redirectScheme) { + const redirect = asRecord(middlewareRecord.redirectScheme); + redirectScheme = { + scheme: typeof redirect.scheme === "string" ? redirect.scheme : "https", + permanent: redirect.permanent !== false, + port: typeof redirect.port === "string" ? redirect.port : null, + }; + } else if (middlewareRecord.chain) { + const chain = asRecord(middlewareRecord.chain).middlewares; + if (!Array.isArray(chain)) { + warnings.push( + warning("chain middleware is missing middlewares", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } else { + const nextSeen = new Set([...seen, normalizedName]); + for (const chainedName of chain) { + const translated = translateTraefikMiddleware( + `${chainedName}`, + middlewares, + { + ...options, + seen: nextSeen, + }, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + } + } else { + warnings.push( + warning(`Unsupported Traefik middleware "${normalizedName}"`, { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + + return { + transforms, + basicAuth, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + redirectScheme, + warnings, + }; +}; + +const collectRouterMiddlewares = ( + middlewareNames: string[] | undefined, + middlewares: Record, + options: DynamicTranslatorOptions & { routerName: string }, +) => { + const transforms: CaddyRouteTransform = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + const warnings: CaddyMigrationWarning[] = []; + let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null; + + for (const middlewareName of middlewareNames ?? []) { + const translated = translateTraefikMiddleware( + middlewareName, + middlewares, + options, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + + return { + transforms, + basicAuth, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + redirectScheme, + warnings, + }; +}; + +const getServiceUpstreams = ( + serviceName: string, + config: FileConfig, + options: DynamicTranslatorOptions & { routerName: string }, +) => { + const service = config.http?.services?.[normalizeMiddlewareName(serviceName)]; + if (!service) { + return { + upstreams: [], + warnings: [ + warning(`Referenced service "${serviceName}" was not found`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unresolved-service", + }), + ], + }; + } + const loadBalancer = (service as Record).loadBalancer; + if (!loadBalancer) { + return { + upstreams: [], + warnings: [ + warning(`Service "${serviceName}" is not a loadBalancer service`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ], + }; + } + const loadBalancerRecord = asRecord(loadBalancer); + const servers = loadBalancerRecord.servers; + const upstreams = Array.isArray(servers) + ? servers.flatMap((server) => { + const url = asRecord(server).url; + return typeof url === "string" ? [normalizeTraefikServerUrl(url)] : []; + }) + : []; + const warnings: CaddyMigrationWarning[] = []; + if (!upstreams.length) { + warnings.push( + warning(`Service "${serviceName}" has no loadBalancer servers`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + if (loadBalancerRecord.passHostHeader === false) { + warnings.push( + warning("passHostHeader=false has no direct migration mapping yet", { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + for (const key of [ + "sticky", + "healthCheck", + "responseForwarding", + "serversTransport", + ]) { + if (loadBalancerRecord[key]) { + warnings.push( + warning(`Service option "${key}" is not migrated yet`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + } + return { upstreams, warnings }; +}; + +const routerUsesHttps = (router: Record) => { + if (router.tls) { + return true; + } + const entryPoints = router.entryPoints; + return Array.isArray(entryPoints) + ? entryPoints.includes("websecure") && !entryPoints.includes("web") + : false; +}; + +const isTraefikInternalRouter = ( + routerName: string, + router: Record, +) => { + const service = typeof router.service === "string" ? router.service : ""; + const entryPoints = Array.isArray(router.entryPoints) + ? router.entryPoints.map(String) + : []; + return ( + service === "api@internal" || + (routerName.includes("traefik-dashboard") && + entryPoints.includes("traefik")) + ); +}; + +export const translateTraefikDynamicConfigToCaddyFragment = ( + input: string | FileConfig, + options: DynamicTranslatorOptions = {}, +): { + fragment: CaddyRouteFragment; + routes: CaddyRouteIntent[]; + warnings: CaddyMigrationWarning[]; +} => { + let config: FileConfig; + try { + config = typeof input === "string" ? (parse(input) as FileConfig) : input; + } catch (error) { + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: options.fragmentId ?? "migration.traefik-dynamic.invalid", + source: "traefik-dynamic-file", + routes: [], + }; + return { + fragment, + routes: [], + warnings: [ + warning( + error instanceof Error + ? error.message + : "Invalid Traefik dynamic config", + { source: options.sourceFile, code: "invalid-config" }, + ), + ], + }; + } + + const routes: CaddyRouteIntent[] = []; + const warnings: CaddyMigrationWarning[] = []; + const mergedMiddlewares = { + ...(options.fileMiddlewares ?? {}), + ...(config.http?.middlewares ?? {}), + }; + for (const [routerName, router] of Object.entries( + config.http?.routers ?? {}, + )) { + const routerRecord = router as unknown as Record; + if (isTraefikInternalRouter(routerName, routerRecord)) { + warnings.push({ + code: "unsupported-router", + message: `Skipped Traefik internal router "${routerName}"; Caddy migration does not expose api@internal/dashboard routes`, + blocking: false, + source: options.sourceFile, + routerName, + serviceName: + typeof routerRecord.service === "string" + ? routerRecord.service + : undefined, + }); + continue; + } + const rule = typeof router.rule === "string" ? router.rule : ""; + const parsedRule = parseTraefikRule(rule, { + source: options.sourceFile, + routerName, + }); + warnings.push(...parsedRule.warnings); + + const middlewareResult = collectRouterMiddlewares( + router.middlewares, + mergedMiddlewares, + { ...options, routerName }, + ); + warnings.push(...middlewareResult.warnings); + + const serviceName = router.service; + const serviceResult = middlewareResult.redirectScheme + ? { upstreams: [], warnings: [] } + : getServiceUpstreams(serviceName, config, { ...options, routerName }); + warnings.push(...serviceResult.warnings); + + const tls = asRecord(routerRecord.tls); + if (tls.certResolver && tls.certResolver !== "letsencrypt") { + warnings.push( + warning( + `Custom certResolver "${tls.certResolver}" has no direct Caddy mapping`, + { + source: options.sourceFile, + routerName, + code: "unsupported-router", + }, + ), + ); + } + + parsedRule.matches.forEach((match, index) => { + routes.push({ + id: `${toSafeId(options.sourceFile ?? "dynamic")}-${routerName}${parsedRule.matches.length > 1 ? `-${index + 1}` : ""}`, + source: "traefik-dynamic-file", + hosts: match.hosts, + pathPrefix: match.pathPrefix, + pathExact: match.pathExact, + https: middlewareResult.redirectScheme + ? false + : routerUsesHttps(routerRecord), + priority: router.priority ?? null, + upstreams: serviceResult.upstreams, + transforms: middlewareResult.transforms, + allowedRemoteIps: middlewareResult.allowedRemoteIps, + basicAuth: middlewareResult.basicAuth.length + ? middlewareResult.basicAuth + : null, + redirectScheme: middlewareResult.redirectScheme, + }); + }); + } + + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: + options.fragmentId ?? + `migration.traefik-dynamic.${toSafeId(options.sourceFile ?? "manual")}`, + source: "traefik-dynamic-file", + description: options.sourceFile + ? `Migrated Traefik dynamic file ${options.sourceFile}` + : "Migrated Traefik dynamic config", + routes, + }; + + return { fragment, routes, warnings }; +}; diff --git a/packages/server/src/utils/caddy/migration/files.ts b/packages/server/src/utils/caddy/migration/files.ts new file mode 100644 index 0000000000..b5bcbc7cc6 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/files.ts @@ -0,0 +1,335 @@ +import { randomUUID } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import type { + CaddyMigrationArtifactPaths, + CaddyMigrationReport, +} from "./types"; + +export const assertSafeMigrationId = (migrationId: string) => { + if ( + !/^[a-zA-Z0-9_.-]+$/.test(migrationId) || + migrationId === "." || + migrationId === ".." || + migrationId.split(".").some((segment) => segment === "") + ) { + throw new Error( + `Invalid Caddy migration id "${migrationId}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`, + ); + } +}; + +const assertPosixPathWithinBase = (basePath: string, targetPath: string) => { + const resolvedBase = path.posix.resolve(basePath); + const resolvedTarget = path.posix.resolve(targetPath); + if ( + resolvedTarget !== resolvedBase && + !resolvedTarget.startsWith(`${resolvedBase}/`) + ) { + throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`); + } +}; + +export const createCaddyMigrationId = () => + `caddy-${new Date() + .toISOString() + .replace(/[^0-9]/g, "") + .slice(0, 14)}-${randomUUID().slice(0, 8)}`; + +export const getCaddyMigrationArtifactPaths = ( + migrationId: string, + serverId?: string | null, +): CaddyMigrationArtifactPaths => { + assertSafeMigrationId(migrationId); + const migrationsPath = paths(!!serverId).CADDY_MIGRATIONS_PATH; + const root = path.posix.join(migrationsPath, migrationId); + assertPosixPathWithinBase(migrationsPath, root); + return { + root, + reportJson: path.posix.join(root, "report.json"), + reportMd: path.posix.join(root, "report.md"), + caddyJson: path.posix.join(root, "caddy.json"), + fragmentsDir: path.posix.join(root, "fragments"), + backupsDir: path.posix.join(root, "backups"), + }; +}; + +const encodeBase64 = (content: string) => + Buffer.from(content, "utf8").toString("base64"); + +export const ensureMigrationDirectory = async ( + dirPath: string, + serverId?: string | null, +) => { + if (serverId) { + await execAsyncRemote(serverId, `mkdir -p ${quote([dirPath])}`); + return; + } + mkdirSync(dirPath, { recursive: true }); +}; + +export const writeMigrationTextFile = async ( + filePath: string, + content: string, + serverId?: string | null, +) => { + if (serverId) { + const tempPath = `${filePath}.tmp-${Date.now()}`; + await execAsyncRemote( + serverId, + [ + `mkdir -p ${quote([path.posix.dirname(filePath)])}`, + `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([ + tempPath, + ])}`, + `mv ${quote([tempPath])} ${quote([filePath])}`, + ].join(" && "), + ); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, "utf8"); +}; + +export const readMigrationTextFileIfExists = async ( + filePath: string, + serverId?: string | null, +) => { + if (serverId) { + const { stdout } = await execAsyncRemote( + serverId, + `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`, + ); + return stdout || null; + } + if (!existsSync(filePath)) return null; + return readFileSync(filePath, "utf8"); +}; + +export const readRequiredMigrationTextFile = async ( + filePath: string, + serverId?: string | null, +) => { + const content = await readMigrationTextFileIfExists(filePath, serverId); + if (content === null) { + throw new Error(`Migration file not found: ${filePath}`); + } + return content; +}; + +export const listMigrationFiles = async ( + dirPath: string, + serverId?: string | null, + extensions: string[] = [], +) => { + if (serverId) { + const extensionExpression = extensions.length + ? extensions.map((ext) => `-name '*${ext}'`).join(" -o ") + : "-type f"; + const { stdout } = await execAsyncRemote( + serverId, + `if [ -d ${quote([dirPath])} ]; then find ${quote([ + dirPath, + ])} -maxdepth 1 -type f \\( ${extensionExpression} \\) | sort; fi`, + ); + return stdout + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + } + if (!existsSync(dirPath)) return []; + return readdirSync(dirPath) + .filter((fileName) => + extensions.length + ? extensions.some((ext) => fileName.endsWith(ext)) + : true, + ) + .sort() + .map((fileName) => path.join(dirPath, fileName)); +}; + +export const migrationPathExists = async ( + target: string, + serverId?: string | null, +) => { + if (serverId) { + const { stdout } = await execAsyncRemote( + serverId, + `if [ -e ${quote([target])} ]; then echo yes; fi`, + ); + return stdout.trim() === "yes"; + } + return existsSync(target); +}; + +export const acquireCaddyMigrationOperationLock = async ( + migrationId: string, + serverId?: string | null, +) => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + const lockPath = path.posix.join(artifactPaths.root, ".operation.lock"); + const errorMessage = `Caddy migration ${migrationId} already has an apply or rollback operation in progress`; + + if (serverId) { + try { + await execAsyncRemote( + serverId, + `mkdir ${quote([lockPath])} 2>/dev/null || { echo ${quote([ + errorMessage, + ])} >&2; exit 70; }`, + ); + } catch { + throw new Error(errorMessage); + } + } else { + try { + mkdirSync(lockPath); + } catch { + throw new Error(errorMessage); + } + } + + let released = false; + return async () => { + if (released) return; + released = true; + await removeMigrationPath(lockPath, serverId); + }; +}; + +const copyLocalPath = (source: string, destination: string) => { + const stats = statSync(source); + if (stats.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source)) { + copyLocalPath(path.join(source, entry), path.join(destination, entry)); + } + return; + } + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, readFileSync(source)); +}; + +export const copyMigrationPath = async ( + source: string, + destination: string, + serverId?: string | null, +) => { + const tempDestination = `${destination}.tmp-${Date.now()}`; + if (serverId) { + await execAsyncRemote( + serverId, + [ + `test -e ${quote([source])}`, + `rm -rf ${quote([tempDestination])}`, + `mkdir -p ${quote([path.posix.dirname(destination)])}`, + `cp -a ${quote([source])} ${quote([tempDestination])}`, + `rm -rf ${quote([destination])}`, + `mv ${quote([tempDestination])} ${quote([destination])}`, + ].join(" && "), + ); + return; + } + if (!existsSync(source)) { + throw new Error(`Migration source path not found: ${source}`); + } + rmSync(tempDestination, { force: true, recursive: true }); + mkdirSync(path.dirname(destination), { recursive: true }); + copyLocalPath(source, tempDestination); + rmSync(destination, { force: true, recursive: true }); + renameSync(tempDestination, destination); +}; + +export const copyMigrationFileInPlace = async ( + source: string, + destination: string, + serverId?: string | null, +) => { + if (serverId) { + const copyInPlaceCommand = [ + `test -f ${quote([source])}`, + `test -f ${quote([destination])}`, + `cp ${quote([source])} ${quote([destination])}`, + ].join(" && "); + try { + await execAsyncRemote(serverId, copyInPlaceCommand); + return; + } catch { + await copyMigrationPath(source, destination, serverId); + return; + } + } + if ( + existsSync(source) && + statSync(source).isFile() && + existsSync(destination) + ) { + const destinationStats = statSync(destination); + if (destinationStats.isFile()) { + copyFileSync(source, destination); + return; + } + } + await copyMigrationPath(source, destination, serverId); +}; + +export const removeMigrationPath = async ( + target: string, + serverId?: string | null, +) => { + if (serverId) { + await execAsyncRemote(serverId, `rm -rf ${quote([target])}`); + return; + } + rmSync(target, { force: true, recursive: true }); +}; + +export const loadCaddyMigrationReport = async ( + migrationId: string, + serverId?: string | null, +): Promise => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + const content = await readRequiredMigrationTextFile( + artifactPaths.reportJson, + serverId, + ); + return JSON.parse(content) as CaddyMigrationReport; +}; + +export const writeCaddyMigrationReport = async ( + report: CaddyMigrationReport, + serverId?: string | null, +) => { + const nextReport = { + ...report, + updatedAt: new Date().toISOString(), + }; + await writeMigrationTextFile( + nextReport.artifactPaths.reportJson, + `${JSON.stringify(nextReport, null, 2)}\n`, + serverId, + ); + return nextReport; +}; + +export const appendCaddyMigrationEvent = ( + report: CaddyMigrationReport, + type: string, + message: string, +): CaddyMigrationReport => ({ + ...report, + events: [...report.events, { at: new Date().toISOString(), type, message }], +}); diff --git a/packages/server/src/utils/caddy/migration/prepare.ts b/packages/server/src/utils/caddy/migration/prepare.ts new file mode 100644 index 0000000000..18e74dcde8 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/prepare.ts @@ -0,0 +1,1096 @@ +import { isIP } from "node:net"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { applications, compose } from "@dokploy/server/db/schema"; +import { assertCertificatePathAvailableForServer } from "@dokploy/server/services/certificate"; +import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings"; +import { createCaddyComposeRouteFragment } from "@dokploy/server/utils/caddy/compose"; +import { + compileCaddyConfig, + parseCaddyUpstream, + readCaddyRouteFragments, +} from "@dokploy/server/utils/caddy/config"; +import { + createCaddyApplicationRouteFragment, + getUnsupportedCaddyDomainFieldMessages, +} from "@dokploy/server/utils/caddy/domain"; +import type { CaddyRouteFragment } from "@dokploy/server/utils/caddy/types"; +import { + DOKPLOY_CADDY_NETWORK, + getCaddyComposeNetworkAlias, + getCaddyComposeRuntimeTarget, +} from "@dokploy/server/utils/caddy/upstream-targets"; +import { + loadDockerCompose, + loadDockerComposeRemote, +} from "@dokploy/server/utils/docker/domain"; +import type { + ComposeSpecification, + ListOrDict, +} from "@dokploy/server/utils/docker/types"; +import { getComposeContainer } from "@dokploy/server/utils/docker/utils"; +import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker"; +import type { + FileConfig, + HttpMiddleware, +} from "@dokploy/server/utils/traefik/file-types"; +import { eq, isNull } from "drizzle-orm"; +import { parse } from "yaml"; +import { translateTraefikComposeLabelsToCaddyFragment } from "./compose-label-translator"; +import { translateTraefikDynamicConfigToCaddyFragment } from "./dynamic-file-translator"; +import { + createCaddyMigrationId, + ensureMigrationDirectory, + getCaddyMigrationArtifactPaths, + listMigrationFiles, + readMigrationTextFileIfExists, + writeCaddyMigrationReport, + writeMigrationTextFile, +} from "./files"; +import type { CaddyMigrationReport, CaddyMigrationWarning } from "./types"; + +const CADDY_FRAGMENT_VERSION = 1; + +const warning = ( + message: string, + options: Partial = {}, +): CaddyMigrationWarning => ({ + code: options.code ?? "missing-input", + message, + blocking: options.blocking ?? false, + source: options.source, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, + label: options.label, +}); + +const safeFragmentIdPart = (value: string) => + value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source"; + +const addFragment = ( + fragments: CaddyRouteFragment[], + fragment: CaddyRouteFragment, +) => { + if (!fragment.routes.length) return; + const existing = new Set(fragments.map((item) => item.id)); + let nextFragment = fragment; + let index = 2; + while (existing.has(nextFragment.id)) { + nextFragment = { ...fragment, id: `${fragment.id}.${index}` }; + index++; + } + fragments.push(nextFragment); +}; + +const labelsToStrings = (labels: ListOrDict | undefined) => { + if (!labels) return []; + if (Array.isArray(labels)) return labels.map(String); + return Object.entries(labels).map(([key, value]) => + value === null ? key : `${key}=${value}`, + ); +}; + +const labelsHaveTraefikHttp = (labels: ListOrDict | undefined) => + labelsToStrings(labels).some((label) => label.startsWith("traefik.http.")); + +const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) => + item.code === "unsupported-security-middleware"; + +const hasTraefikHttpLabelMap = (labels: Record | undefined) => + labels + ? Object.keys(labels).some((label) => label.startsWith("traefik.http.")) + : false; + +const getTraefikNetworkLabel = (labels: Record | undefined) => + labels?.["traefik.swarm.network"] ?? + labels?.["traefik.docker.network"] ?? + DOKPLOY_CADDY_NETWORK; + +const splitDialHost = (dial: string) => { + const index = dial.lastIndexOf(":"); + return index === -1 ? dial : dial.slice(0, index); +}; + +const isDockerLocalHost = (host: string) => !isIP(host) && !host.includes("."); + +const getRunningTaskCounts = (tasks: any[]) => { + const counts = new Map(); + for (const task of tasks) { + const serviceId = task.ServiceID; + if ( + typeof serviceId !== "string" || + task.DesiredState !== "running" || + task.Status?.State !== "running" + ) { + continue; + } + counts.set(serviceId, (counts.get(serviceId) ?? 0) + 1); + } + return counts; +}; + +const getRunningServiceTasks = ( + service: any, + runningTaskCounts?: Map, +) => { + if (runningTaskCounts && typeof service.ID === "string") { + return runningTaskCounts.get(service.ID) ?? 0; + } + const runningTasks = service.ServiceStatus?.RunningTasks; + if (typeof runningTasks === "number") return runningTasks; + const replicas = service.Spec?.Mode?.Replicated?.Replicas; + return typeof replicas === "number" ? replicas : 1; +}; + +const getPrimaryContainerName = (container: any) => { + const names = Array.isArray(container.Names) ? container.Names : []; + return names[0]?.replace(/^\//, "") ?? container.Names?.[0] ?? null; +}; + +const addContainerNetworkAliases = (hosts: Set, container: any) => { + const networks = container.NetworkSettings?.Networks ?? {}; + for (const network of Object.values(networks) as any[]) { + for (const alias of network?.Aliases ?? []) { + if ( + typeof alias === "string" && + alias && + !/^[a-f0-9]{12,64}$/.test(alias) + ) { + hosts.add(alias); + } + } + } +}; + +const inspectContainer = async (docker: any, container: any) => { + const containerId = container.Id ?? container.ID; + if (typeof containerId !== "string" || !containerId) return null; + if (typeof docker.getContainer !== "function") return null; + try { + return await docker.getContainer(containerId).inspect(); + } catch { + return null; + } +}; + +const getLiveDockerTraefikFragments = async ( + serverId: string | undefined, + fileMiddlewares: Record, +) => { + const docker = await getRemoteDocker(serverId); + const hosts = new Set(); + const fragments: CaddyRouteFragment[] = []; + const warnings: CaddyMigrationWarning[] = []; + + const [services, containers, tasks] = await Promise.all([ + docker.listServices().catch(() => []), + docker.listContainers().catch(() => []), + typeof docker.listTasks === "function" + ? docker.listTasks().catch(() => null) + : Promise.resolve(null), + ]); + const runningTaskCounts = Array.isArray(tasks) + ? getRunningTaskCounts(tasks) + : undefined; + + for (const service of services as any[]) { + const serviceName = service.Spec?.Name; + if (typeof serviceName !== "string" || !serviceName) continue; + if (getRunningServiceTasks(service, runningTaskCounts) <= 0) continue; + hosts.add(serviceName); + hosts.add(`tasks.${serviceName}`); + const labels = service.Spec?.Labels as Record | undefined; + if (!hasTraefikHttpLabelMap(labels)) continue; + const translated = translateTraefikComposeLabelsToCaddyFragment(labels, { + sourceFile: `docker-service/${serviceName}`, + appName: serviceName, + serviceName, + upstreamServiceName: serviceName, + upstreamNetwork: getTraefikNetworkLabel(labels), + composeType: "stack", + fileMiddlewares, + }); + warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning)); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-docker-label.service.${safeFragmentIdPart( + serviceName, + )}`, + }); + } + + for (const container of containers as any[]) { + const containerName = getPrimaryContainerName(container); + if (!containerName) continue; + hosts.add(containerName); + for (const name of container.Names ?? []) { + if (typeof name === "string") hosts.add(name.replace(/^\//, "")); + } + addContainerNetworkAliases(hosts, container); + const inspectedContainer = await inspectContainer(docker, container); + if (inspectedContainer) { + addContainerNetworkAliases(hosts, inspectedContainer); + } + const labels = container.Labels as Record | undefined; + if ( + !hasTraefikHttpLabelMap(labels) || + labels?.["com.docker.swarm.service.name"] + ) { + continue; + } + const translated = translateTraefikComposeLabelsToCaddyFragment(labels, { + sourceFile: `docker-container/${containerName}`, + appName: containerName, + serviceName: containerName, + upstreamServiceName: containerName, + upstreamNetwork: getTraefikNetworkLabel(labels), + fileMiddlewares, + }); + warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning)); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-docker-label.container.${safeFragmentIdPart( + containerName, + )}`, + }); + } + + return { + fragments, + warnings, + hosts, + canPrune: + hosts.has("dokploy") || + hosts.has("dokploy-traefik") || + fragments.length > 0, + }; +}; + +const getLocalOrRemoteComposeSpec = async ( + composeEntity: typeof compose.$inferSelect, +) => { + const loaded = composeEntity.serverId + ? await loadDockerComposeRemote(composeEntity) + : await loadDockerCompose(composeEntity); + if (loaded) return loaded; + if (composeEntity.composeFile?.trim()) { + return parse(composeEntity.composeFile, { + maxAliasCount: 10000, + }) as ComposeSpecification; + } + return null; +}; + +const resolveFinalComposeServiceName = ( + composeEntity: typeof compose.$inferSelect, + composeSpec: ComposeSpecification | null, + serviceName: string, +) => { + if (!composeSpec?.services) { + return composeEntity.randomize || composeEntity.isolatedDeployment + ? null + : serviceName; + } + if (composeSpec.services[serviceName]) { + return serviceName; + } + const suffix = composeEntity.randomize + ? composeEntity.suffix + : composeEntity.isolatedDeployment + ? composeEntity.suffix || composeEntity.appName + : null; + if (suffix && composeSpec.services[`${serviceName}-${suffix}`]) { + return `${serviceName}-${suffix}`; + } + return null; +}; + +const getServiceNetworkAliases = ( + composeSpec: ComposeSpecification | null, + serviceName: string, + network: string, +) => { + const networks = composeSpec?.services?.[serviceName]?.networks; + if (!networks || Array.isArray(networks)) return []; + const attachment = networks[network]; + return attachment?.aliases ?? []; +}; + +const getComposeServiceContainerName = async ( + composeEntity: typeof compose.$inferSelect, + finalServiceName: string, +) => { + try { + const container = await getComposeContainer( + composeEntity as never, + finalServiceName, + ); + return container?.Names?.[0]?.replace(/^\//, "") ?? null; + } catch { + return null; + } +}; + +const resolveMigrationComposeUpstreamTarget = async ( + composeEntity: typeof compose.$inferSelect, + composeSpec: ComposeSpecification | null, + finalServiceName: string, +) => { + const runtimeTarget = getCaddyComposeRuntimeTarget( + composeEntity, + finalServiceName, + ); + + if ( + composeEntity.composeType === "stack" || + composeEntity.isolatedDeployment + ) { + return runtimeTarget; + } + + const expectedAlias = getCaddyComposeNetworkAlias( + composeEntity.appName, + finalServiceName, + ); + if ( + getServiceNetworkAliases( + composeSpec, + finalServiceName, + DOKPLOY_CADDY_NETWORK, + ).includes(expectedAlias) + ) { + return runtimeTarget; + } + + const containerName = + composeSpec?.services?.[finalServiceName]?.container_name; + if (typeof containerName === "string" && containerName.trim()) { + return { + host: containerName.trim(), + network: DOKPLOY_CADDY_NETWORK, + }; + } + + const runningContainerName = await getComposeServiceContainerName( + composeEntity, + finalServiceName, + ); + if (runningContainerName) { + return { + host: runningContainerName, + network: DOKPLOY_CADDY_NETWORK, + }; + } + + return runtimeTarget; +}; + +const routeCoverageKey = (route: CaddyRouteFragment["routes"][number]) => + route.hosts + .map((host) => + [ + host, + route.pathPrefix ?? "", + route.pathExact ?? "", + route.https ? "https" : "http", + ].join("\0"), + ) + .sort(); + +type MigrationRoute = CaddyRouteFragment["routes"][number]; + +const pathMatchesOverlap = ( + left: Pick, + right: Pick, +) => { + const leftExact = left.pathExact ?? null; + const rightExact = right.pathExact ?? null; + const leftPrefix = left.pathPrefix ?? null; + const rightPrefix = right.pathPrefix ?? null; + + if (!leftExact && !leftPrefix) return true; + if (!rightExact && !rightPrefix) return true; + if (leftExact && rightExact) return leftExact === rightExact; + if (leftExact && rightPrefix) return leftExact.startsWith(rightPrefix); + if (leftPrefix && rightExact) return rightExact.startsWith(leftPrefix); + if (leftPrefix && rightPrefix) { + return ( + leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix) + ); + } + return false; +}; + +const routesOverlap = (left: MigrationRoute, right: MigrationRoute) => { + if (left.https !== right.https) return false; + if (!left.hosts.some((host) => right.hosts.includes(host))) return false; + return pathMatchesOverlap(left, right); +}; + +const warnOnManualFragmentConflicts = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], +) => { + const manualFragments = fragments.filter( + (fragment) => fragment.source === "manual", + ); + const generatedFragments = fragments.filter( + (fragment) => fragment.source !== "manual", + ); + const seen = new Set(); + + for (const manualFragment of manualFragments) { + for (const manualRoute of manualFragment.routes) { + for (const generatedFragment of generatedFragments) { + for (const generatedRoute of generatedFragment.routes) { + if (!routesOverlap(manualRoute, generatedRoute)) continue; + const key = [ + manualFragment.id, + manualRoute.id, + generatedFragment.id, + generatedRoute.id, + ].join("\0"); + if (seen.has(key)) continue; + seen.add(key); + warnings.push( + warning( + `Manual Caddy route "${manualRoute.id}" overlaps generated migration route "${generatedRoute.id}" for at least one host/path`, + { + code: "conflicting-manual-fragment", + source: manualFragment.id, + blocking: true, + }, + ), + ); + } + } + } + } +}; + +const hasAvailableUploadedCertificate = async ( + certificatePath: string, + serverId: string | null | undefined, + organizationId?: string | null, +) => { + try { + await assertCertificatePathAvailableForServer( + certificatePath, + serverId, + organizationId, + ); + return true; + } catch { + return false; + } +}; + +const warnIfCustomCertificateMissing = async ( + domain: { + host: string; + https: boolean; + certificateType: string; + customCertResolver?: string | null; + }, + source: string, + serverId: string | null | undefined, + warnings: CaddyMigrationWarning[], + serviceName?: string | null, + organizationId?: string | null, +) => { + if ( + !domain.https || + domain.certificateType !== "custom" || + !domain.customCertResolver + ) { + return false; + } + if ( + await hasAvailableUploadedCertificate( + domain.customCertResolver, + serverId, + organizationId, + ) + ) { + return false; + } + + warnings.push( + warning( + `Domain "${domain.host}" references custom certificate "${domain.customCertResolver}" that is not an uploaded certificate with readable files for this server and organization`, + { + blocking: true, + code: "missing-certificate", + source, + serviceName: serviceName ?? undefined, + }, + ), + ); + return true; +}; + +const reconcileDbFallbackRoutes = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], +) => { + const migratedCoverage = new Set(); + for (const fragment of fragments) { + if ( + fragment.source !== "traefik-compose-label" && + fragment.source !== "traefik-dynamic-file" + ) { + continue; + } + for (const route of fragment.routes) { + if (route.redirectScheme || !route.upstreams.length) continue; + for (const key of routeCoverageKey(route)) { + migratedCoverage.add(key); + } + } + } + + const reconciled: CaddyRouteFragment[] = []; + for (const fragment of fragments) { + if ( + fragment.source !== "dokploy-compose" && + fragment.source !== "dokploy-application" + ) { + reconciled.push(fragment); + continue; + } + + const nextRoutes = fragment.routes.filter((route) => { + const shadowed = routeCoverageKey(route).some((key) => + migratedCoverage.has(key), + ); + if (shadowed) { + warnings.push( + warning( + `Dropped DB fallback route "${route.id}" because a migrated Traefik route covers the same host/path`, + { + code: "shadowed-route", + source: fragment.id, + blocking: false, + }, + ), + ); + } + return !shadowed; + }); + + if (nextRoutes.length) { + reconciled.push({ ...fragment, routes: nextRoutes }); + } + } + + return reconciled; +}; + +const pruneUnreachableDockerUpstreams = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], + reachableDockerHosts: Set, +) => { + const reconciled: CaddyRouteFragment[] = []; + for (const fragment of fragments) { + const nextRoutes: CaddyRouteFragment["routes"] = []; + for (const route of fragment.routes) { + if (route.redirectScheme || !route.upstreams.length) { + nextRoutes.push(route); + continue; + } + const upstreams = route.upstreams.filter((upstream) => { + let host = ""; + try { + host = splitDialHost(parseCaddyUpstream(upstream).dial); + } catch { + return true; + } + if (!isDockerLocalHost(host) || reachableDockerHosts.has(host)) { + return true; + } + warnings.push( + warning( + `Route "${route.id}" upstream "${upstream}" did not match a running Docker service, container, or network alias for "${host}"`, + { + code: "unreachable-upstream", + source: fragment.id, + blocking: true, + }, + ), + ); + return true; + }); + if (upstreams.length) { + nextRoutes.push({ ...route, upstreams }); + } + } + if (nextRoutes.length) { + reconciled.push({ ...fragment, routes: nextRoutes }); + } + } + return reconciled; +}; + +const readTraefikStaticConfig = async (serverId?: string) => { + const configPath = path.posix.join( + paths(!!serverId).MAIN_TRAEFIK_PATH, + "traefik.yml", + ); + const content = await readMigrationTextFileIfExists(configPath, serverId); + return { path: configPath, content }; +}; + +const renderReportMarkdown = (report: CaddyMigrationReport) => { + const blocking = report.warnings.filter((item) => item.blocking); + return [ + `# Caddy Migration ${report.migrationId}`, + "", + `Status: **${report.status}**`, + `Server: ${report.serverId ?? "local"}`, + `Created: ${report.createdAt}`, + "", + "## Summary", + `- Fragments: ${report.summary.fragments}`, + `- Routes: ${report.summary.routes}`, + `- Warnings: ${report.summary.warnings}`, + `- Blocking warnings: ${report.summary.blockingWarnings}`, + `- Validation: ${report.validation.status}${report.validation.message ? ` (${report.validation.message})` : ""}`, + "", + "## Inputs", + `- Traefik static config: ${report.inputs.traefikStaticConfigFound ? report.inputs.traefikStaticConfigPath : "not found"}`, + `- Dynamic files: ${report.inputs.dynamicFiles.length}`, + `- DB application domains: ${report.inputs.dbApplicationDomains}`, + `- DB compose domains: ${report.inputs.dbComposeDomains}`, + `- Compose files scanned: ${report.inputs.composeFilesScanned.length}`, + `- Compose files skipped: ${report.inputs.composeFilesSkipped.length}`, + "", + "## Blocking warnings", + blocking.length + ? blocking + .map( + (item) => + `- [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`, + ) + .join("\n") + : "None", + "", + "## All warnings", + report.warnings.length + ? report.warnings + .map( + (item) => + `- ${item.blocking ? "BLOCKING" : "info"} [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`, + ) + .join("\n") + : "None", + "", + ].join("\n"); +}; + +export const prepareCaddyMigration = async ( + input: { serverId?: string } = {}, +) => { + const serverId = input.serverId; + const migrationId = createCaddyMigrationId(); + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + await ensureMigrationDirectory(artifactPaths.fragmentsDir, serverId); + await ensureMigrationDirectory(artifactPaths.backupsDir, serverId); + + const createdAt = new Date().toISOString(); + let fragments: CaddyRouteFragment[] = []; + const warnings: CaddyMigrationWarning[] = []; + const composeFilesScanned: string[] = []; + const composeFilesSkipped: Array<{ path: string; reason: string }> = []; + const fileMiddlewares: Record = {}; + let reachableDockerHosts = new Set(); + let canPruneUnreachableDockerUpstreams = false; + + try { + const existingManualFragments = ( + await readCaddyRouteFragments({ serverId }) + ).filter((fragment) => fragment.source === "manual"); + for (const fragment of existingManualFragments) { + addFragment(fragments, fragment); + } + } catch (error) { + warnings.push( + warning( + `Unable to read existing manual Caddy fragments: ${ + error instanceof Error ? error.message : "unknown error" + }`, + { + code: "missing-input", + blocking: false, + }, + ), + ); + } + + const staticConfig = await readTraefikStaticConfig(serverId); + const dynamicFileContents: Array<{ + file: string; + content: string; + config?: FileConfig; + }> = []; + if (!staticConfig.content) { + warnings.push( + warning("Traefik static config was not found", { + source: staticConfig.path, + }), + ); + } + + const dynamicFiles = await listMigrationFiles( + paths(!!serverId).DYNAMIC_TRAEFIK_PATH, + serverId, + [".yml", ".yaml"], + ); + for (const dynamicFile of dynamicFiles) { + const content = await readMigrationTextFileIfExists(dynamicFile, serverId); + if (!content) continue; + try { + const config = parse(content) as FileConfig; + Object.assign(fileMiddlewares, config.http?.middlewares ?? {}); + dynamicFileContents.push({ file: dynamicFile, content, config }); + } catch { + dynamicFileContents.push({ file: dynamicFile, content }); + } + } + for (const dynamicFile of dynamicFileContents) { + const translated = translateTraefikDynamicConfigToCaddyFragment( + dynamicFile.config ?? dynamicFile.content, + { + sourceFile: path.posix.basename(dynamicFile.file), + fileMiddlewares, + }, + ); + warnings.push(...translated.warnings); + addFragment(fragments, translated.fragment); + } + try { + const liveDockerLabels = await getLiveDockerTraefikFragments( + serverId, + fileMiddlewares, + ); + reachableDockerHosts = liveDockerLabels.hosts; + canPruneUnreachableDockerUpstreams = liveDockerLabels.canPrune; + warnings.push(...liveDockerLabels.warnings); + for (const fragment of liveDockerLabels.fragments) { + addFragment(fragments, fragment); + } + } catch (error) { + warnings.push( + warning( + `Unable to inspect live Docker Traefik labels: ${ + error instanceof Error ? error.message : "unknown error" + }`, + { + code: "missing-input", + blocking: false, + }, + ), + ); + } + + const appWhere = serverId + ? eq(applications.serverId, serverId) + : isNull(applications.serverId); + const applicationRows = await db.query.applications.findMany({ + where: appWhere, + with: { domains: true, environment: { with: { project: true } } }, + }); + let applicationDomainCount = 0; + for (const app of applicationRows) { + for (const domain of app.domains ?? []) { + applicationDomainCount++; + const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain); + if (unsupportedFields.length) { + warnings.push( + warning( + `Application domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`, + { + blocking: true, + code: "unsupported-domain-field", + source: app.appName, + }, + ), + ); + continue; + } + if ( + await warnIfCustomCertificateMissing( + domain, + app.appName, + app.serverId, + warnings, + undefined, + app.environment?.project?.organizationId, + ) + ) { + continue; + } + addFragment( + fragments, + createCaddyApplicationRouteFragment(app as never, domain), + ); + } + } + + const composeWhere = serverId + ? eq(compose.serverId, serverId) + : isNull(compose.serverId); + const composeRows = await db.query.compose.findMany({ + where: composeWhere, + with: { domains: true, environment: { with: { project: true } } }, + }); + let composeDomainCount = 0; + for (const composeEntity of composeRows) { + let composeSpec: ComposeSpecification | null = null; + try { + composeSpec = await getLocalOrRemoteComposeSpec(composeEntity); + } catch (error) { + composeFilesSkipped.push({ + path: composeEntity.appName, + reason: + error instanceof Error + ? error.message + : "Unable to read compose file", + }); + } + + for (const domain of composeEntity.domains ?? []) { + composeDomainCount++; + const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain); + if (unsupportedFields.length) { + warnings.push( + warning( + `Compose domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`, + { + blocking: true, + code: "unsupported-domain-field", + source: composeEntity.appName, + serviceName: domain.serviceName ?? undefined, + }, + ), + ); + continue; + } + if ( + await warnIfCustomCertificateMissing( + domain, + composeEntity.appName, + composeEntity.serverId, + warnings, + domain.serviceName, + composeEntity.environment?.project?.organizationId, + ) + ) { + continue; + } + if (!domain.serviceName) { + warnings.push( + warning(`Compose domain "${domain.host}" is missing a service name`, { + blocking: true, + source: composeEntity.appName, + serviceName: domain.serviceName ?? undefined, + }), + ); + continue; + } + const finalServiceName = resolveFinalComposeServiceName( + composeEntity, + composeSpec, + domain.serviceName, + ); + if (!finalServiceName) { + warnings.push( + warning( + `Compose domain "${domain.host}" references service "${domain.serviceName}" that could not be verified in the compose file`, + { + blocking: true, + source: composeEntity.appName, + serviceName: domain.serviceName, + }, + ), + ); + continue; + } + const upstreamTarget = await resolveMigrationComposeUpstreamTarget( + composeEntity, + composeSpec, + finalServiceName, + ); + addFragment( + fragments, + createCaddyComposeRouteFragment( + composeEntity, + domain, + finalServiceName, + { + upstreamServiceName: upstreamTarget.host, + upstreamNetwork: upstreamTarget.network, + }, + ), + ); + } + + if (!composeSpec?.services) { + composeFilesSkipped.push({ + path: composeEntity.appName, + reason: "Compose file not found or has no services", + }); + continue; + } + + composeFilesScanned.push(composeEntity.appName); + for (const [serviceName, service] of Object.entries(composeSpec.services)) { + const labelSources: Array<{ + labels: ListOrDict | undefined; + suffix: string; + }> = [ + { labels: service.labels, suffix: "labels" }, + { labels: service.deploy?.labels, suffix: "deploy.labels" }, + ].filter((labelSource) => labelsHaveTraefikHttp(labelSource.labels)); + if (!labelSources.length) continue; + + const labelFinalServiceName = + resolveFinalComposeServiceName( + composeEntity, + composeSpec, + serviceName, + ) ?? serviceName; + const upstreamTarget = await resolveMigrationComposeUpstreamTarget( + composeEntity, + composeSpec, + labelFinalServiceName, + ); + for (const labelSource of labelSources) { + const translated = translateTraefikComposeLabelsToCaddyFragment( + labelSource.labels, + { + sourceFile: `${composeEntity.appName}/${serviceName}/${labelSource.suffix}`, + appName: composeEntity.appName, + domains: composeEntity.domains ?? [], + serviceName, + upstreamServiceName: upstreamTarget.host, + upstreamNetwork: upstreamTarget.network, + composeType: composeEntity.composeType, + fileMiddlewares, + }, + ); + warnings.push( + ...translated.warnings.filter( + (item) => + !translated.classifications.every( + (classification) => classification.dokployGenerated, + ) || isSecurityMiddlewareWarning(item), + ), + ); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-compose-label.${safeFragmentIdPart( + `${composeEntity.appName}.${serviceName}.${labelSource.suffix}`, + )}`, + }); + } + } + } + + fragments = reconcileDbFallbackRoutes(fragments, warnings); + if (canPruneUnreachableDockerUpstreams && reachableDockerHosts.size) { + fragments = pruneUnreachableDockerUpstreams( + fragments, + warnings, + reachableDockerHosts, + ); + } + warnOnManualFragmentConflicts(fragments, warnings); + + const compileSettings = await getCaddyCompileSettings(serverId); + let config: ReturnType; + let validation: CaddyMigrationReport["validation"]; + try { + config = compileCaddyConfig({ + fragments, + ...compileSettings, + }); + validation = { + status: "passed", + message: + "Draft config compiled successfully; container validation runs during apply", + }; + } catch (error) { + config = compileCaddyConfig(); + const message = + error instanceof Error ? error.message : "Draft Caddy config is invalid"; + warnings.push( + warning(message, { + code: "validation-failed", + blocking: true, + }), + ); + validation = { status: "failed", message }; + } + + for (const fragment of fragments) { + await writeMigrationTextFile( + path.posix.join(artifactPaths.fragmentsDir, `${fragment.id}.json`), + `${JSON.stringify(fragment, null, 2)}\n`, + serverId, + ); + } + await writeMigrationTextFile( + artifactPaths.caddyJson, + `${JSON.stringify(config, null, 2)}\n`, + serverId, + ); + + const report: CaddyMigrationReport = { + migrationId, + serverId: serverId ?? null, + createdAt, + updatedAt: createdAt, + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: staticConfig.path, + traefikStaticConfigFound: Boolean(staticConfig.content), + dynamicFiles, + dbApplicationDomains: applicationDomainCount, + dbComposeDomains: composeDomainCount, + composeFilesScanned, + composeFilesSkipped, + }, + summary: { + fragments: fragments.length, + routes: fragments.reduce( + (total, fragment) => total + fragment.routes.length, + 0, + ), + warnings: warnings.length, + blockingWarnings: warnings.filter((item) => item.blocking).length, + }, + validation, + compileSettings, + warnings, + events: [ + { + at: createdAt, + type: "prepared", + message: "Dry-run Caddy migration artifacts generated", + }, + ], + }; + await writeCaddyMigrationReport(report, serverId); + await writeMigrationTextFile( + artifactPaths.reportMd, + renderReportMarkdown(report), + serverId, + ); + return report; +}; diff --git a/packages/server/src/utils/caddy/migration/rollback.ts b/packages/server/src/utils/caddy/migration/rollback.ts new file mode 100644 index 0000000000..f43519b8a1 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/rollback.ts @@ -0,0 +1,238 @@ +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + ensureTraefikRunningFromSnapshot, + stopDockerResource, +} from "@dokploy/server/services/settings"; +import { + updateLocalWebServerProvider, + updateRemoteWebServerProvider, +} from "@dokploy/server/services/web-server-settings"; +import { + acquireCaddyMigrationOperationLock, + appendCaddyMigrationEvent, + copyMigrationPath, + getCaddyMigrationArtifactPaths, + loadCaddyMigrationReport, + migrationPathExists, + readMigrationTextFileIfExists, + removeMigrationPath, + writeCaddyMigrationReport, +} from "./files"; +import type { + CaddyMigrationReport, + CaddyMigrationResourceSnapshot, +} from "./types"; + +const updateProviderToTraefik = async (serverId?: string | null) => { + if (serverId) { + await updateRemoteWebServerProvider(serverId, "traefik"); + return; + } + await updateLocalWebServerProvider("traefik"); +}; + +const restorePathFromBackup = async ( + backupSource: string, + liveDestination: string, + existed?: boolean, + serverId?: string | null, +) => { + if (existed === true) { + if (!(await migrationPathExists(backupSource, serverId))) { + throw new Error( + `Cannot restore ${liveDestination}: backup path is missing at ${backupSource}`, + ); + } + await copyMigrationPath(backupSource, liveDestination, serverId); + return; + } + if (existed === false) { + await removeMigrationPath(liveDestination, serverId); + } +}; + +const loadRestoreSnapshots = async ( + report: CaddyMigrationReport, + serverId?: string | null, +): Promise<{ + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; +}> => { + const restoreSnapshotPath = report.backup?.restoreSnapshotPath; + if (!restoreSnapshotPath) { + return { + traefikResource: report.backup?.traefikResource, + caddyResource: report.backup?.caddyResource, + }; + } + const content = await readMigrationTextFileIfExists( + restoreSnapshotPath, + serverId, + ); + if (!content) { + return { + traefikResource: report.backup?.traefikResource, + caddyResource: report.backup?.caddyResource, + }; + } + return JSON.parse(content) as { + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; + }; +}; + +export const rollbackCaddyMigration = async (input: { + migrationId: string; + serverId?: string; + skipOperationLock?: boolean; +}) => { + const releaseLock = input.skipOperationLock + ? null + : await acquireCaddyMigrationOperationLock( + input.migrationId, + input.serverId, + ); + try { + return await rollbackCaddyMigrationUnlocked(input); + } finally { + await releaseLock?.(); + } +}; + +const rollbackCaddyMigrationUnlocked = async (input: { + migrationId: string; + serverId?: string; +}) => { + const serverId = input.serverId; + let report = await loadCaddyMigrationReport(input.migrationId, serverId); + if (!report.backup) { + throw new Error( + `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`, + ); + } + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "rolling_back" }, + "rolling_back", + "Rolling back Caddy migration to Traefik", + ), + serverId, + ); + + try { + await stopDockerResource("dokploy-caddy", serverId); + + const caddyPaths = paths(!!serverId); + const backup = report.backup; + if (!backup) { + throw new Error( + `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`, + ); + } + const backupRoot = path.posix.join( + report.artifactPaths.backupsDir, + "caddy", + ); + const backupFiles = backup.files ?? []; + const backedUpCaddyConfig = backupFiles.find( + (item) => item.label === "caddy-config", + ); + const backedUpCaddyFragments = backupFiles.find( + (item) => item.label === "caddy-fragments", + ); + const backedUpTraefikStatic = backupFiles.find( + (item) => item.label === "traefik-static", + ); + const backedUpTraefikDynamic = backupFiles.find( + (item) => item.label === "traefik-dynamic", + ); + + await restorePathFromBackup( + backedUpCaddyConfig?.backupPath ?? + path.posix.join(backupRoot, "caddy.json"), + caddyPaths.CADDY_CONFIG_PATH, + backedUpCaddyConfig?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpCaddyFragments?.backupPath ?? + path.posix.join(backupRoot, "fragments"), + caddyPaths.CADDY_FRAGMENTS_PATH, + backedUpCaddyFragments?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpTraefikStatic?.backupPath ?? + path.posix.join( + report.artifactPaths.backupsDir, + "traefik", + "traefik.yml", + ), + path.posix.join(caddyPaths.MAIN_TRAEFIK_PATH, "traefik.yml"), + backedUpTraefikStatic?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpTraefikDynamic?.backupPath ?? + path.posix.join(report.artifactPaths.backupsDir, "traefik", "dynamic"), + caddyPaths.DYNAMIC_TRAEFIK_PATH, + backedUpTraefikDynamic?.existed, + serverId, + ); + + const restoreSnapshots = await loadRestoreSnapshots(report, serverId); + await ensureTraefikRunningFromSnapshot( + restoreSnapshots.traefikResource ?? backup.traefikResource, + serverId, + ); + await updateProviderToTraefik(serverId); + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "rolled_back" }, + "rolled_back", + "Rollback completed; Traefik is the active provider", + ), + serverId, + ); + return report; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Caddy migration rollback failed"; + const failedReport: CaddyMigrationReport = { + ...report, + status: "failed", + warnings: [ + ...report.warnings, + { + code: "rollback-failed", + message, + blocking: true, + }, + ], + summary: { + ...report.summary, + warnings: report.summary.warnings + 1, + blockingWarnings: report.summary.blockingWarnings + 1, + }, + }; + await writeCaddyMigrationReport( + appendCaddyMigrationEvent(failedReport, "rollback_failed", message), + serverId, + ); + throw error; + } +}; + +export const getCaddyMigrationReport = async (input: { + migrationId: string; + serverId?: string; +}) => loadCaddyMigrationReport(input.migrationId, input.serverId); + +export const getCaddyMigrationPaths = (input: { + migrationId: string; + serverId?: string; +}) => getCaddyMigrationArtifactPaths(input.migrationId, input.serverId); diff --git a/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts new file mode 100644 index 0000000000..a4b923d55e --- /dev/null +++ b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts @@ -0,0 +1,337 @@ +import type { CaddyMigrationWarning, TraefikRuleMatch } from "./types"; + +interface ParseOptions { + source?: string; + routerName?: string; +} + +type TokenType = + | "identifier" + | "string" + | "and" + | "or" + | "lparen" + | "rparen" + | "comma"; + +interface Token { + type: TokenType; + value: string; +} + +type RuleNode = + | { type: "matcher"; name: string; args: string[] } + | { type: "and" | "or"; left: RuleNode; right: RuleNode }; + +const warning = ( + message: string, + options: ParseOptions, + code: CaddyMigrationWarning["code"] = "unsupported-rule", +): CaddyMigrationWarning => ({ + code, + message, + blocking: true, + source: options.source, + routerName: options.routerName, +}); + +const tokenizeRule = (rule: string, options: ParseOptions) => { + const tokens: Token[] = []; + let index = 0; + + while (index < rule.length) { + const char = rule[index] ?? ""; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (rule.startsWith("&&", index)) { + tokens.push({ type: "and", value: "&&" }); + index += 2; + continue; + } + if (rule.startsWith("||", index)) { + tokens.push({ type: "or", value: "||" }); + index += 2; + continue; + } + if (char === "(") { + tokens.push({ type: "lparen", value: char }); + index += 1; + continue; + } + if (char === ")") { + tokens.push({ type: "rparen", value: char }); + index += 1; + continue; + } + if (char === ",") { + tokens.push({ type: "comma", value: char }); + index += 1; + continue; + } + if (char === "`" || char === "'" || char === '"') { + const quote = char; + let value = ""; + index += 1; + while (index < rule.length && rule[index] !== quote) { + value += rule[index] ?? ""; + index += 1; + } + if (rule[index] !== quote) { + throw new Error( + warning("Unterminated string in Traefik rule", options).message, + ); + } + index += 1; + tokens.push({ type: "string", value }); + continue; + } + if (/[A-Za-z]/.test(char)) { + let value = ""; + while (index < rule.length && /[A-Za-z0-9_]/.test(rule[index] ?? "")) { + value += rule[index] ?? ""; + index += 1; + } + tokens.push({ type: "identifier", value }); + continue; + } + throw new Error( + warning(`Unsupported token "${char}" in Traefik rule`, options).message, + ); + } + + return tokens; +}; + +class RuleParser { + private index = 0; + + constructor( + private readonly tokens: Token[], + private readonly options: ParseOptions, + ) {} + + parse() { + const expression = this.parseOr(); + if (this.peek()) { + throw new Error( + warning( + `Unexpected token "${this.peek()?.value}" in Traefik rule`, + this.options, + ).message, + ); + } + return expression; + } + + private peek() { + return this.tokens[this.index]; + } + + private consume(type?: TokenType) { + const token = this.tokens[this.index]; + if (!token || (type && token.type !== type)) { + throw new Error( + warning(`Expected ${type ?? "token"} in Traefik rule`, this.options) + .message, + ); + } + this.index += 1; + return token; + } + + private parseOr(): RuleNode { + let node = this.parseAnd(); + while (this.peek()?.type === "or") { + this.consume("or"); + node = { type: "or", left: node, right: this.parseAnd() }; + } + return node; + } + + private parseAnd(): RuleNode { + let node = this.parsePrimary(); + while (this.peek()?.type === "and") { + this.consume("and"); + node = { type: "and", left: node, right: this.parsePrimary() }; + } + return node; + } + + private parsePrimary(): RuleNode { + const token = this.peek(); + if (token?.type === "lparen") { + this.consume("lparen"); + const node = this.parseOr(); + this.consume("rparen"); + return node; + } + if (token?.type !== "identifier") { + throw new Error( + warning("Expected Traefik matcher in rule", this.options).message, + ); + } + + const name = this.consume("identifier").value; + this.consume("lparen"); + const args: string[] = []; + while (this.peek()?.type !== "rparen") { + args.push(this.consume("string").value); + if (this.peek()?.type === "comma") { + this.consume("comma"); + } else if (this.peek()?.type !== "rparen") { + throw new Error( + warning(`Expected comma in ${name} matcher`, this.options).message, + ); + } + } + this.consume("rparen"); + return { type: "matcher", name, args }; + } +} + +const mergeMatches = ( + left: TraefikRuleMatch, + right: TraefikRuleMatch, + warnings: CaddyMigrationWarning[], + options: ParseOptions, +): TraefikRuleMatch | null => { + const hosts = [...new Set([...left.hosts, ...right.hosts])]; + const pathPrefix = left.pathPrefix ?? right.pathPrefix ?? null; + const pathExact = left.pathExact ?? right.pathExact ?? null; + + if ( + left.pathPrefix && + right.pathPrefix && + left.pathPrefix !== right.pathPrefix + ) { + warnings.push( + warning( + `Multiple PathPrefix matchers cannot be represented as one Caddy route: ${left.pathPrefix}, ${right.pathPrefix}`, + options, + ), + ); + return null; + } + if (left.pathExact && right.pathExact && left.pathExact !== right.pathExact) { + warnings.push( + warning( + `Multiple Path matchers cannot be represented as one Caddy route: ${left.pathExact}, ${right.pathExact}`, + options, + ), + ); + return null; + } + if (pathPrefix && pathExact) { + warnings.push( + warning( + `Combined Path and PathPrefix matchers cannot be represented as one Caddy route: ${pathExact}, ${pathPrefix}`, + options, + ), + ); + return null; + } + + return { hosts, pathPrefix, pathExact }; +}; + +const expandRuleNode = ( + node: RuleNode, + warnings: CaddyMigrationWarning[], + options: ParseOptions, +): TraefikRuleMatch[] => { + if (node.type === "matcher") { + if (node.name === "Host") { + return [{ hosts: [...new Set(node.args)] }]; + } + if (node.name === "PathPrefix") { + return node.args.map((pathPrefix) => ({ hosts: [], pathPrefix })); + } + if (node.name === "Path") { + return node.args.map((pathExact) => ({ hosts: [], pathExact })); + } + warnings.push( + warning( + `Unsupported Traefik matcher "${node.name}" in rule`, + options, + "unsupported-matcher", + ), + ); + return []; + } + + const left = expandRuleNode(node.left, warnings, options); + const right = expandRuleNode(node.right, warnings, options); + if (node.type === "or") { + return [...left, ...right]; + } + + const merged: TraefikRuleMatch[] = []; + for (const leftMatch of left) { + for (const rightMatch of right) { + const match = mergeMatches(leftMatch, rightMatch, warnings, options); + if (match) { + merged.push(match); + } + } + } + return merged; +}; + +const normalizeMatches = ( + matches: TraefikRuleMatch[], + warnings: CaddyMigrationWarning[], + options: ParseOptions, +) => { + const grouped = new Map(); + for (const match of matches) { + if (!match.hosts.length) { + warnings.push( + warning("Traefik rule did not include a Host matcher", options), + ); + continue; + } + + const key = `${match.pathPrefix ?? ""}\u0000${match.pathExact ?? ""}`; + const existing = grouped.get(key); + if (existing) { + existing.hosts = [...new Set([...existing.hosts, ...match.hosts])]; + } else { + grouped.set(key, { + ...match, + hosts: [...new Set(match.hosts)], + }); + } + } + return [...grouped.values()]; +}; + +export const parseTraefikRule = ( + rule: string, + options: ParseOptions = {}, +): { matches: TraefikRuleMatch[]; warnings: CaddyMigrationWarning[] } => { + const warnings: CaddyMigrationWarning[] = []; + try { + const tokens = tokenizeRule(rule, options); + const ast = new RuleParser(tokens, options).parse(); + const matches = normalizeMatches( + expandRuleNode(ast, warnings, options), + warnings, + options, + ); + return { matches, warnings }; + } catch (error) { + return { + matches: [], + warnings: [ + warning( + error instanceof Error + ? error.message + : "Failed to parse Traefik rule", + options, + ), + ], + }; + } +}; diff --git a/packages/server/src/utils/caddy/migration/types.ts b/packages/server/src/utils/caddy/migration/types.ts new file mode 100644 index 0000000000..d2fe158122 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/types.ts @@ -0,0 +1,200 @@ +import type { + CaddyCompileOptions, + CaddyHeaderMap, + CaddyRouteRedirectScheme, + CaddyRouteTransform, +} from "../types"; + +export type CaddyMigrationWarningCode = + | "unsupported-rule" + | "unsupported-matcher" + | "unsupported-router" + | "unsupported-service" + | "unsupported-middleware" + | "unsupported-security-middleware" + | "unsupported-domain-field" + | "unresolved-middleware" + | "unresolved-service" + | "shadowed-route" + | "conflicting-manual-fragment" + | "unreachable-upstream" + | "invalid-label" + | "invalid-config" + | "missing-input" + | "missing-certificate" + | "validation-failed" + | "health-check-failed" + | "backup-failed" + | "apply-failed" + | "rollback-failed"; + +export interface CaddyMigrationWarning { + code: CaddyMigrationWarningCode; + message: string; + blocking: boolean; + source?: string; + routerName?: string; + serviceName?: string; + middlewareName?: string; + label?: string; +} + +export interface CaddyMiddlewareTranslation { + transforms: CaddyRouteTransform; + basicAuth: { username: string; hash: string }[]; + allowedRemoteIps?: string[] | null; + redirectScheme?: CaddyRouteRedirectScheme | null; + warnings: CaddyMigrationWarning[]; +} + +export type ResolvedCaddyMiddleware = Omit< + CaddyMiddlewareTranslation, + "warnings" +>; + +export type KnownTraefikMiddlewareMap = Record< + string, + Partial & { + transforms?: CaddyRouteTransform; + basicAuth?: { username: string; hash: string }[]; + responseHeaders?: CaddyHeaderMap; + requestHeaders?: CaddyHeaderMap; + } +>; + +export interface TraefikRuleMatch { + hosts: string[]; + pathPrefix?: string | null; + pathExact?: string | null; +} + +export type CaddyMigrationStatus = + | "prepared" + | "applying" + | "applied" + | "failed" + | "rolling_back" + | "rolled_back"; + +export interface CaddyMigrationArtifactPaths { + root: string; + reportJson: string; + reportMd: string; + caddyJson: string; + fragmentsDir: string; + backupsDir: string; +} + +export interface CaddyMigrationFileBackup { + label: string; + source: string; + backupPath: string; + existed: boolean; +} + +export interface CaddyMigrationResourceSnapshot { + resourceName: string; + resourceType: "service" | "standalone" | "unknown"; + running: boolean; + replicas?: number; + env?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + protocol?: string; + }[]; + image?: string; + binds?: string[]; + mounts?: Array>; + networks?: Array>; + labels?: Record; + containerLabels?: Record; + placement?: Record; + endpointPorts?: Array>; + restartPolicy?: Record; +} + +export interface CaddyMigrationBackupSummary { + createdAt: string; + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; + restoreSnapshotPath?: string; + files?: CaddyMigrationFileBackup[]; +} + +export interface CaddyMigrationRuntimePreflightRoute { + routeId: string; + routeHosts: string[]; + source: string; + sourceFragment?: string; + upstream: string; + normalizedHost: string; + normalizedPort: number; + network: string; +} + +export interface CaddyMigrationRuntimePreflightCheck { + dial: string; + host: string; + port: number; + network: string; + status: "passed" | "failed"; + reason?: string; + routes: CaddyMigrationRuntimePreflightRoute[]; +} + +export interface CaddyMigrationRuntimePreflight { + status: "passed" | "failed" | "skipped"; + checkedAt?: string; + network: string; + networks?: string[]; + probeMode?: "standalone" | "service"; + probeImage: string; + checks: CaddyMigrationRuntimePreflightCheck[]; +} + +export interface CaddyMigrationReport { + migrationId: string; + serverId: string | null; + createdAt: string; + updatedAt: string; + status: CaddyMigrationStatus; + sourceProvider: "traefik"; + targetProvider: "caddy"; + artifactPaths: CaddyMigrationArtifactPaths; + inputs: { + traefikStaticConfigPath: string; + traefikStaticConfigFound: boolean; + dynamicFiles: string[]; + dbApplicationDomains: number; + dbComposeDomains: number; + composeFilesScanned: string[]; + composeFilesSkipped: Array<{ path: string; reason: string }>; + }; + summary: { + fragments: number; + routes: number; + warnings: number; + blockingWarnings: number; + }; + validation: { + status: "passed" | "failed" | "skipped"; + message?: string; + }; + compileSettings?: Pick< + CaddyCompileOptions, + "letsEncryptEmail" | "trustedProxies" | "accessLogs" + >; + runtimePreflight?: CaddyMigrationRuntimePreflight; + warnings: CaddyMigrationWarning[]; + backup?: CaddyMigrationBackupSummary; + events: Array<{ + at: string; + type: string; + message: string; + }>; +} + +export const createMigrationWarning = ( + warning: CaddyMigrationWarning, +): CaddyMigrationWarning => warning; diff --git a/packages/server/src/utils/caddy/migration/upstream-preflight.ts b/packages/server/src/utils/caddy/migration/upstream-preflight.ts new file mode 100644 index 0000000000..1c2693e830 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/upstream-preflight.ts @@ -0,0 +1,336 @@ +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import { parseCaddyUpstream } from "../config"; +import type { CaddyRouteFragment } from "../types"; +import { DOKPLOY_CADDY_NETWORK } from "../upstream-targets"; +import { listMigrationFiles, readRequiredMigrationTextFile } from "./files"; +import type { + CaddyMigrationReport, + CaddyMigrationRuntimePreflight, + CaddyMigrationRuntimePreflightCheck, + CaddyMigrationRuntimePreflightRoute, +} from "./types"; + +const PROBE_IMAGE = "busybox:1.36"; +const DEFAULT_PROBE_NETWORK = DOKPLOY_CADDY_NETWORK; +type ProbeMode = "standalone" | "service"; + +const runCommand = async (command: string, serverId?: string) => { + if (serverId) { + return execAsyncRemote(serverId, command); + } + return execAsync(command); +}; + +const splitDial = (dial: string) => { + const separatorIndex = dial.lastIndexOf(":"); + if (separatorIndex === -1) { + throw new Error("normalized upstream dial is missing a port"); + } + const host = dial.slice(0, separatorIndex); + const port = Number(dial.slice(separatorIndex + 1)); + if (!host || !Number.isInteger(port)) { + throw new Error("normalized upstream dial is invalid"); + } + return { host, port }; +}; + +const routeRefsFromFragment = ( + fragment: CaddyRouteFragment, +): { + refs: CaddyMigrationRuntimePreflightRoute[]; + invalidChecks: CaddyMigrationRuntimePreflightCheck[]; +} => { + const refs: CaddyMigrationRuntimePreflightRoute[] = []; + const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + for (const route of fragment.routes) { + if (route.redirectScheme) continue; + for (const upstream of route.upstreams) { + let host = ""; + let port = 0; + try { + const parsed = parseCaddyUpstream(upstream); + const normalized = splitDial(parsed.dial); + host = normalized.host; + port = normalized.port; + } catch (error) { + invalidChecks.push({ + dial: upstream, + host: "", + port: 0, + status: "failed", + reason: + error instanceof Error ? error.message : "Invalid upstream format", + routes: [ + { + routeId: route.id, + routeHosts: route.hosts, + source: route.source, + sourceFragment: fragment.id, + upstream, + normalizedHost: "", + normalizedPort: 0, + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }, + ], + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }); + continue; + } + refs.push({ + routeId: route.id, + routeHosts: route.hosts, + source: route.source, + sourceFragment: fragment.id, + upstream, + normalizedHost: host, + normalizedPort: port, + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }); + } + } + return { refs, invalidChecks }; +}; + +const readPreflightInputs = async ( + report: CaddyMigrationReport, + serverId?: string, +) => { + const refs: CaddyMigrationRuntimePreflightRoute[] = []; + const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + const fragmentFiles = await listMigrationFiles( + report.artifactPaths.fragmentsDir, + serverId, + [".json"], + ); + for (const fragmentFile of fragmentFiles) { + const content = await readRequiredMigrationTextFile(fragmentFile, serverId); + const fragment = JSON.parse(content) as CaddyRouteFragment; + const parsed = routeRefsFromFragment(fragment); + refs.push(...parsed.refs); + invalidChecks.push(...parsed.invalidChecks); + } + return { refs, invalidChecks }; +}; + +const probeScript = [ + 'if nc -z -w 3 "$1" "$2" >/dev/null 2>&1; then echo passed; exit 0; fi', + 'if ! nslookup "$1" >/dev/null 2>&1; then echo dns_failed; exit 10; fi', + "echo tcp_failed", + "exit 11", +].join("; "); + +const parseProbeFailure = (error: unknown, output: string) => { + if (output.includes("dns_failed")) { + return "DNS resolution failed"; + } + if (output.includes("tcp_failed")) { + return "TCP connection failed"; + } + if (output) { + return output.split("\n").slice(-3).join("; "); + } + return error instanceof Error ? error.message : "upstream probe failed"; +}; + +const shouldFallbackToServiceProbe = (reason: string) => + [ + /not manually attachable/i, + /not attachable/i, + /network .* not found/i, + /could not find network/i, + /only supported for user defined networks/i, + /error response from daemon/i, + ].some((pattern) => pattern.test(reason)); + +const probeStandaloneUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +) => { + const command = [ + "docker run --rm", + `--network ${quote([network])}`, + quote([PROBE_IMAGE]), + "sh -c", + quote([probeScript]), + "sh", + quote([host]), + quote([String(port)]), + ].join(" "); + + try { + const { stdout } = await runCommand(command, serverId); + return stdout.trim() === "passed" + ? null + : stdout.trim() || "upstream probe did not report success"; + } catch (error) { + const output = + `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim(); + return parseProbeFailure(error, output); + } +}; + +const probeServiceUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +) => { + const serviceName = `dokploy-caddy-preflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const command = ` +set -e +SERVICE_NAME=${quote([serviceName])} +docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true +trap 'docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true' EXIT +docker service create --detach=true --name "$SERVICE_NAME" --restart-condition none --network ${quote([network])} ${quote([PROBE_IMAGE])} sh -c ${quote([probeScript])} sh ${quote([host])} ${quote([String(port)])} >/dev/null +for i in $(seq 1 30); do + LOGS=$(docker service logs --raw "$SERVICE_NAME" 2>&1 || true) + if echo "$LOGS" | grep -Eq '(^|[[:space:]])(passed|dns_failed|tcp_failed)([[:space:]]|$)'; then + echo "$LOGS" + exit 0 + fi + STATE=$(docker service ps "$SERVICE_NAME" --no-trunc --format '{{.CurrentState}} {{.Error}}' 2>&1 | head -n 1 || true) + if echo "$STATE" | grep -Eq 'Failed|Rejected|Complete|Shutdown'; then + echo "$LOGS" + echo "$STATE" + exit 0 + fi + sleep 1 +done +docker service logs --raw "$SERVICE_NAME" 2>&1 || true +exit 12 +`; + + try { + const { stdout } = await runCommand(command, serverId); + return stdout.trim().includes("passed") + ? null + : parseProbeFailure(new Error("upstream service probe failed"), stdout); + } catch (error) { + const output = + `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim(); + return parseProbeFailure(error, output); + } +}; + +const probeUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +): Promise<{ failureReason: string | null; probeMode: ProbeMode }> => { + const standaloneFailure = await probeStandaloneUpstream( + host, + port, + network, + serverId, + ); + if (!standaloneFailure) { + return { failureReason: null, probeMode: "standalone" }; + } + if (!shouldFallbackToServiceProbe(standaloneFailure)) { + return { failureReason: standaloneFailure, probeMode: "standalone" }; + } + + const serviceFailure = await probeServiceUpstream( + host, + port, + network, + serverId, + ); + if (!serviceFailure) { + return { failureReason: null, probeMode: "service" }; + } + return { + failureReason: `standalone probe failed (${standaloneFailure}); service probe failed (${serviceFailure})`, + probeMode: "service", + }; +}; + +export const runCaddyMigrationUpstreamPreflight = async ( + report: CaddyMigrationReport, + input: { serverId?: string } = {}, +): Promise => { + const grouped = new Map(); + let refs: CaddyMigrationRuntimePreflightRoute[] = []; + let invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + try { + const inputs = await readPreflightInputs(report, input.serverId); + refs = inputs.refs; + invalidChecks = inputs.invalidChecks; + } catch (error) { + return { + status: "failed", + checkedAt: new Date().toISOString(), + network: DEFAULT_PROBE_NETWORK, + networks: [DEFAULT_PROBE_NETWORK], + probeImage: PROBE_IMAGE, + checks: [ + { + dial: "fragment-read", + host: "", + port: 0, + network: DEFAULT_PROBE_NETWORK, + status: "failed", + reason: + error instanceof Error + ? error.message + : "Unable to read Caddy fragments", + routes: [], + }, + ], + }; + } + + for (const ref of refs) { + const dial = `${ref.normalizedHost}:${ref.normalizedPort}`; + const groupKey = `${ref.network}\0${dial}`; + grouped.set(groupKey, { + dial, + host: ref.normalizedHost, + port: ref.normalizedPort, + network: ref.network, + status: "passed", + routes: [...(grouped.get(groupKey)?.routes ?? []), ref], + }); + } + + const checks = [...invalidChecks, ...grouped.values()]; + const probeableChecks = checks.filter((item) => item.port > 0); + let probeMode: ProbeMode = "standalone"; + for (const check of probeableChecks) { + const probeResult = await probeUpstream( + check.host, + check.port, + check.network, + input.serverId, + ); + if (probeResult.probeMode === "service") { + probeMode = "service"; + } + const { failureReason } = probeResult; + if (failureReason) { + check.status = "failed"; + check.reason = failureReason; + } + } + const networks = [...new Set(checks.map((check) => check.network))].sort(); + + return { + status: checks.some((check) => check.status === "failed") + ? "failed" + : "passed", + checkedAt: new Date().toISOString(), + network: + networks.length === 1 ? (networks[0] ?? DEFAULT_PROBE_NETWORK) : "mixed", + networks, + probeMode, + probeImage: PROBE_IMAGE, + checks, + }; +}; From d43f859fcc00cbc2e3190ecfdab6efb3f38040bb Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 10 Jun 2026 19:09:37 -0400 Subject: [PATCH 05/14] =?UTF-8?q?feat(caddy):=20settings=20UI=20=E2=80=94?= =?UTF-8?q?=20provider=20selector,=20migration=20panel,=20dashboard=20guar?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/domains/handle-domain.tsx | 327 ++++++++++++------ .../certificates/show-certificates.tsx | 9 +- .../dashboard/settings/web-server.tsx | 7 + .../web-server/caddy-migration-panel.tsx | 319 +++++++++++++++++ .../caddy-trusted-proxy-settings.tsx | 215 ++++++++++++ .../settings/web-server/edit-traefik-env.tsx | 184 +--------- .../web-server/edit-web-server-env.tsx | 197 +++++++++++ .../web-server/manage-traefik-ports.tsx | 36 +- .../web-server-provider-selector.tsx | 101 ++++++ .../dokploy/components/shared/code-editor.tsx | 2 +- 10 files changed, 1081 insertions(+), 316 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx create mode 100644 apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx create mode 100644 apps/dokploy/components/dashboard/settings/web-server/edit-web-server-env.tsx create mode 100644 apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index b232591e4b..c51ee531a4 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import z from "zod"; +import { invalidateApplicationWebServerConfig } from "@/components/dashboard/application/web-server-config-cache"; import { AlertBlock } from "@/components/shared/alert-block"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -80,7 +81,11 @@ export const domain = z }); } - if (input.certificateType === "custom" && !input.customCertResolver) { + if ( + input.https && + input.certificateType === "custom" && + !input.customCertResolver + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["customCertResolver"], @@ -178,6 +183,22 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); + const { data: activeProvider, isLoading: isLoadingProvider } = + api.settings.getActiveWebServerProvider.useQuery( + { serverId: application?.serverId || undefined }, + { enabled: !!application }, + ); + const isCaddyProvider = activeProvider === "caddy"; + + const { data: certificates } = api.certificates.all.useQuery(undefined, { + enabled: isOpen && isCaddyProvider, + }); + const caddyCertificates = (certificates ?? []).filter((certificate) => + application?.serverId + ? certificate.serverId === application.serverId + : !certificate.serverId, + ); + const { data: canGenerateTraefikMeDomains } = api.domain.canGenerateTraefikMeDomains.useQuery({ serverId: application?.serverId || "", @@ -264,12 +285,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { } }, [form, data, isPending, domainId]); - // Separate effect for handling custom cert resolver validation + // Separate effect for handling provider-specific certificate fields useEffect(() => { if (certificateType === "custom") { form.trigger("customCertResolver"); } - }, [certificateType, form]); + if (isCaddyProvider && certificateType !== "custom") { + form.setValue("customCertResolver", undefined); + } + }, [certificateType, form, isCaddyProvider]); const dictionary = { success: domainId ? "Domain Updated" : "Domain Created", @@ -299,9 +323,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { await utils.domain.byApplicationId.invalidate({ applicationId: id, }); - await utils.application.readTraefikConfig.invalidate({ - applicationId: id, - }); + await invalidateApplicationWebServerConfig(utils, id); } else if (data.domainType === "compose") { await utils.domain.byComposeId.invalidate({ composeId: id, @@ -337,6 +359,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { )} + {isCaddyProvider && ( + + This server uses Caddy. Dokploy will generate Caddy route fragments, + Caddy can manage HTTPS certificates for public DNS names or load an + uploaded certificate, and Traefik-only custom entrypoints and + middleware references are hidden. + + )} +
{ { + field.onChange(checked); + if (!checked) { + form.setValue("certificateType", "none"); + form.setValue("customCertResolver", undefined); + } + }} /> @@ -658,36 +695,38 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> - ( - -
- Custom Entrypoint - - Use custom entrypoint for domain -
- "web" and/or "websecure" is used by default. -
- -
- - { - field.onChange(checked); - if (!checked) { - form.setValue("customEntrypoint", undefined); - } - }} - /> - -
- )} - /> + {!isCaddyProvider && ( + ( + +
+ Custom Entrypoint + + Use custom entrypoint for domain +
+ "web" and/or "websecure" is used by default. +
+ +
+ + { + field.onChange(checked); + if (!checked) { + form.setValue("customEntrypoint", undefined); + } + }} + /> + +
+ )} + /> + )} - {useCustomEntrypoint && ( + {!isCaddyProvider && useCustomEntrypoint && ( {
HTTPS - Automatically provision SSL Certificate. + {isCaddyProvider + ? "Let Caddy manage HTTPS automatically for this host." + : "Automatically provision SSL Certificate."}
@@ -758,9 +799,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { None - Let's Encrypt + {isCaddyProvider + ? "Caddy-managed HTTPS (ACME)" + : "Let's Encrypt"} + + + {isCaddyProvider + ? "Uploaded certificate" + : "Custom"} - Custom @@ -769,7 +816,52 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> - {certificateType === "custom" && ( + {isCaddyProvider && certificateType === "custom" && ( + { + return ( + + Uploaded Certificate + {caddyCertificates.length > 0 ? ( + + ) : ( + + Add an uploaded certificate for this server + before selecting custom HTTPS. + + )} + + + ); + }} + /> + )} + + {!isCaddyProvider && certificateType === "custom" && ( { )} )} - ( - -
- Middlewares - - - -
- ? -
-
- -

- Add Traefik middleware references. Middlewares - must be defined in your Traefik configuration. -

-
-
-
-
-
- {field.value?.map((name, index) => ( - - {name} - { - const newMiddlewares = [...(field.value || [])]; - newMiddlewares.splice(index, 1); - form.setValue("middlewares", newMiddlewares); + {!isCaddyProvider && ( + ( + +
+ Middlewares + + + +
+ ? +
+
+ +

+ Add Traefik middleware references. Middlewares + must be defined in your Traefik configuration. +

+
+
+
+
+
+ {field.value?.map((name, index) => ( + + {name} + { + const newMiddlewares = [ + ...(field.value || []), + ]; + newMiddlewares.splice(index, 1); + form.setValue("middlewares", newMiddlewares); + }} + /> + + ))} +
+ +
+ { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.currentTarget; + const value = input.value.trim(); + if (value && !field.value?.includes(value)) { + form.setValue("middlewares", [ + ...(field.value || []), + value, + ]); + input.value = ""; + } + } }} /> - - ))} -
- -
- { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.currentTarget; + -
-
- -
- )} - /> + }} + > + Add + +
+ + +
+ )} + /> + )} - diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index c69451c895..d158b381bc 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -46,14 +46,13 @@ export const ShowCertificates = () => { Certificates - Create certificates in the Traefik directory + Create uploaded certificates for web server domains - Certificates are created in the Traefik directory. Traefik uses - these certificates to secure your applications. Using invalid - certificates can break your Traefik instance, preventing access to - your applications. + Uploaded certificates can be used by supported web server + providers to secure your applications. Invalid certificates can + break domain access for the services that use them. diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index a383fbf7d6..e2f9a85e42 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -13,7 +13,10 @@ import { ShowDokployActions } from "./servers/actions/show-dokploy-actions"; import { ShowStorageActions } from "./servers/actions/show-storage-actions"; import { ShowTraefikActions } from "./servers/actions/show-traefik-actions"; import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup"; +import { CaddyMigrationPanel } from "./web-server/caddy-migration-panel"; +import { CaddyTrustedProxySettings } from "./web-server/caddy-trusted-proxy-settings"; import { UpdateServer } from "./web-server/update-server"; +import { WebServerProviderSelector } from "./web-server/web-server-provider-selector"; export const WebServer = () => { const { data: webServerSettings } = @@ -42,6 +45,10 @@ export const WebServer = () => { */} + + + +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx new file mode 100644 index 0000000000..a788b01d9e --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx @@ -0,0 +1,319 @@ +import type { CaddyMigrationReport } from "@dokploy/server"; +import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +const statusLabel = (report: CaddyMigrationReport) => { + if (report.summary.blockingWarnings > 0) { + return "Blocked"; + } + if (report.validation.status !== "passed") { + return "Validation required"; + } + return "Ready"; +}; + +export const CaddyMigrationPanel = ({ serverId }: Props) => { + const [migrationId, setMigrationId] = useState(null); + const [maintenanceConfirmed, setMaintenanceConfirmed] = useState(false); + + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const utils = api.useUtils(); + const { mutateAsync: prepareMigration, isPending: isPreparing } = + api.settings.prepareCaddyMigration.useMutation(); + const { mutateAsync: applyMigration, isPending: isApplying } = + api.settings.applyCaddyMigration.useMutation(); + const { mutateAsync: rollbackMigration, isPending: isRollingBack } = + api.settings.rollbackCaddyMigration.useMutation(); + const { data: fetchedReport, refetch: refetchReport } = + api.settings.getCaddyMigrationReport.useQuery( + { migrationId: migrationId ?? "", serverId }, + { enabled: !!migrationId }, + ); + const [preparedReport, setPreparedReport] = + useState(null); + const report = fetchedReport ?? preparedReport; + const blockingWarnings = report?.warnings.filter( + (warning) => warning.blocking, + ); + const nonBlockingWarnings = report?.warnings.filter( + (warning) => !warning.blocking, + ); + const isMutating = isPreparing || isApplying || isRollingBack; + const canApply = + !!report && + report.summary.blockingWarnings === 0 && + report.validation.status === "passed" && + maintenanceConfirmed && + !isMutating; + const canRollback = !!report && report.status !== "prepared" && !isMutating; + + const handleDryRun = async () => { + try { + const nextReport = await prepareMigration({ serverId }); + setPreparedReport(nextReport); + setMigrationId(nextReport.migrationId); + setMaintenanceConfirmed(false); + toast.success("Caddy migration dry run prepared"); + } catch (error) { + toast.error( + (error as Error).message || "Error preparing Caddy migration", + ); + } + }; + + const handleApply = async () => { + if (!report) return; + try { + await applyMigration({ + migrationId: report.migrationId, + serverId, + confirmMaintenanceWindow: true, + }); + toast.success("Caddy migration apply started"); + await utils.settings.getActiveWebServerProvider.invalidate({ serverId }); + await refetchReport(); + } catch (error) { + toast.error((error as Error).message || "Error applying Caddy migration"); + } + }; + + const handleRollback = async () => { + if (!report) return; + try { + await rollbackMigration({ migrationId: report.migrationId, serverId }); + toast.success("Caddy rollback started"); + await utils.settings.getActiveWebServerProvider.invalidate({ serverId }); + await refetchReport(); + } catch (error) { + toast.error( + (error as Error).message || "Error rolling back Caddy migration", + ); + } + }; + + return ( + + +
+
+ Caddy migration + + Prepare a reviewable Traefik → Caddy migration before any + maintenance-window cutover. + +
+ +
+
+ + {activeProvider === "caddy" && ( + + Caddy is already the active provider. Dry runs are still useful for + reviewing translated Traefik artifacts before rollback or follow-up + changes. + + )} + + {isPreparing && ( +
+ Preparing migration + artifacts... +
+ )} + + {report && ( +
+
+
+
+ + Dry run {report.migrationId} + + 0 + ? "destructive" + : "secondary" + } + > + {statusLabel(report)} + +
+

+ Created {new Date(report.createdAt).toLocaleString()} · + Status: {report.status} · Validation:{" "} + {report.validation.status} +

+
+
+
+
{report.summary.fragments}
+
Fragments
+
+
+
{report.summary.routes}
+
Routes
+
+
+
+ {report.summary.blockingWarnings}/{report.summary.warnings} +
+
Blocking
+
+
+
+ + {report.validation.message && ( + + {report.validation.message} + + )} + +
+
+
Inputs
+
    +
  • + Traefik static config:{" "} + {report.inputs.traefikStaticConfigFound + ? "found" + : "not found"} +
  • +
  • Dynamic files: {report.inputs.dynamicFiles.length}
  • +
  • + Application domains: {report.inputs.dbApplicationDomains} +
  • +
  • Compose domains: {report.inputs.dbComposeDomains}
  • +
+
+
+
Artifacts
+
    +
  • Report: {report.artifactPaths.reportMd}
  • +
  • Draft Caddy JSON: {report.artifactPaths.caddyJson}
  • +
  • Fragments: {report.artifactPaths.fragmentsDir}
  • +
+
+
+ + {blockingWarnings && blockingWarnings.length > 0 ? ( + +
+
Blocking items
+
    + {blockingWarnings.map((warning, index) => ( +
  • + {warning.source ? `${warning.source}: ` : ""} + {warning.message} +
  • + ))} +
+
+
+ ) : ( + } + > + No blocking migration items were reported. + + )} + + {nonBlockingWarnings && nonBlockingWarnings.length > 0 && ( +
+ + Review non-blocking warnings ({nonBlockingWarnings.length}) + +
    + {nonBlockingWarnings.map((warning, index) => ( +
  • + {warning.source ? `${warning.source}: ` : ""} + {warning.message} +
  • + ))} +
+
+ )} + +
+
+ +
+

+ Apply stops Traefik, starts Caddy on ports 80/443/443 UDP, + and changes the active provider only after cutover checks + pass. Run this during a maintenance window. Changing Caddy + settings after a dry run requires preparing a fresh dry run. +

+
+ + setMaintenanceConfirmed(checked === true) + } + /> + +
+
+
+
+ +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx new file mode 100644 index 0000000000..e3acb8962a --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx @@ -0,0 +1,215 @@ +import { Cloud, ShieldCheck } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +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 { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +type TrustedProxyMode = "disabled" | "cloudflare" | "static"; + +interface Props { + serverId?: string; + children?: React.ReactNode; +} + +const splitList = (value: string) => + Array.from( + new Set( + value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter((item) => item.length > 0), + ), + ); + +const joinList = (values?: string[] | null) => (values ?? []).join("\n"); + +const modeLabel = (mode?: TrustedProxyMode) => { + switch (mode) { + case "cloudflare": + return "Cloudflare"; + case "static": + return "Static CIDRs"; + default: + return "Disabled"; + } +}; + +export const CaddyTrustedProxySettings = ({ serverId, children }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [mode, setMode] = useState("disabled"); + const [ranges, setRanges] = useState(""); + const [headers, setHeaders] = useState(""); + const [strict, setStrict] = useState(true); + + const { data: settings, refetch } = + api.settings.getCaddyTrustedProxySettings.useQuery({ serverId }); + const { mutateAsync: updateSettings, isPending } = + api.settings.updateCaddyTrustedProxySettings.useMutation(); + + useEffect(() => { + if (!settings) return; + setMode(settings.mode); + setRanges(joinList(settings.ranges)); + setHeaders(joinList(settings.clientIpHeaders)); + setStrict(settings.strict !== false); + }, [settings]); + + const save = async () => { + try { + await updateSettings({ + serverId, + mode, + ranges: mode === "static" ? splitList(ranges) : [], + clientIpHeaders: splitList(headers), + strict, + }); + await refetch(); + toast.success("Caddy trusted proxy settings updated"); + setIsOpen(false); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Error updating Caddy trusted proxy settings", + ); + } + }; + + const trigger = children ?? ( + + ); + + return ( + + {trigger} + + + Caddy trusted proxies + + Configure which proxy IPs Caddy trusts for client IP headers. + + + +
+
+ + +
+ + {mode === "cloudflare" && ( + +
+ + + Caddy will trust Cloudflare IP ranges and use CF-Connecting-IP + before X-Forwarded-For. Use DNS-only or Full (strict) SSL mode + for origin traffic; Flexible SSL is not recommended. + +
+
+ )} + + {mode === "static" && ( +
+ +