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/__test__/caddy/application-web-server-config-cache.test.ts b/apps/dokploy/__test__/caddy/application-web-server-config-cache.test.ts new file mode 100644 index 0000000000..36f1b926c0 --- /dev/null +++ b/apps/dokploy/__test__/caddy/application-web-server-config-cache.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import { expect, test, vi } from "vitest"; +import { invalidateApplicationWebServerConfig } from "@/components/dashboard/application/web-server-config-cache"; + +const createUtils = () => ({ + application: { + readTraefikConfig: { + invalidate: vi.fn(), + }, + readWebServerConfig: { + invalidate: vi.fn(), + }, + }, +}); + +test("invalidates legacy Traefik and provider-aware application web-server config caches", async () => { + const utils = createUtils(); + + await invalidateApplicationWebServerConfig(utils, "app-1"); + + expect(utils.application.readTraefikConfig.invalidate).toHaveBeenCalledWith({ + applicationId: "app-1", + }); + expect(utils.application.readWebServerConfig.invalidate).toHaveBeenCalledWith( + { applicationId: "app-1" }, + ); +}); + +test("awaits both application web-server config cache invalidations", async () => { + const calls: string[] = []; + const utils = createUtils(); + utils.application.readTraefikConfig.invalidate.mockImplementation( + async () => { + await Promise.resolve(); + calls.push("traefik"); + }, + ); + utils.application.readWebServerConfig.invalidate.mockImplementation( + async () => { + await Promise.resolve(); + calls.push("web-server"); + }, + ); + + await invalidateApplicationWebServerConfig(utils, "app-1"); + + expect(calls).toEqual(["traefik", "web-server"]); +}); + +test("propagates application web-server config cache invalidation failures", async () => { + const utils = createUtils(); + utils.application.readWebServerConfig.invalidate.mockRejectedValueOnce( + new Error("provider-aware cache failed"), + ); + + await expect( + invalidateApplicationWebServerConfig(utils, "app-1"), + ).rejects.toThrow("provider-aware cache failed"); + expect(utils.application.readTraefikConfig.invalidate).toHaveBeenCalledWith({ + applicationId: "app-1", + }); +}); + +const applicationMutationHandlers = [ + [ + "domain handler", + "../../components/dashboard/application/domains/handle-domain.tsx", + ], + [ + "redirect form handler", + "../../components/dashboard/application/advanced/redirects/handle-redirect.tsx", + ], + [ + "redirect delete handler", + "../../components/dashboard/application/advanced/redirects/show-redirects.tsx", + ], + [ + "security form handler", + "../../components/dashboard/application/advanced/security/handle-security.tsx", + ], + [ + "security delete handler", + "../../components/dashboard/application/advanced/security/show-security.tsx", + ], +] as const; + +test.each(applicationMutationHandlers)( + "%s uses the shared application web-server cache invalidation helper", + (_name, filePath) => { + const source = readFileSync(new URL(filePath, import.meta.url), "utf8"); + + expect(source).toContain("invalidateApplicationWebServerConfig"); + expect(source).not.toMatch(/readTraefikConfig\.invalidate/); + expect(source).not.toMatch(/readWebServerConfig\.invalidate/); + }, +); diff --git a/apps/dokploy/__test__/caddy/application/domain-service.test.ts b/apps/dokploy/__test__/caddy/application/domain-service.test.ts new file mode 100644 index 0000000000..9e09a71ed8 --- /dev/null +++ b/apps/dokploy/__test__/caddy/application/domain-service.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const txInsertMock = vi.hoisted(() => vi.fn()); +const transactionMock = vi.hoisted(() => vi.fn()); +const dbDeleteMock = vi.hoisted(() => vi.fn()); +const domainsFindFirstMock = vi.hoisted(() => vi.fn()); +const domainsFindManyMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/db", () => ({ + db: { + transaction: transactionMock, + query: { + domains: { + findFirst: domainsFindFirstMock, + findMany: domainsFindManyMock, + }, + }, + delete: dbDeleteMock, + }, +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/web-server/domain", () => ({ + manageWebServerDomain: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/domain", () => ({ + getCaddyComposeRouteTargetsForWebServer: vi.fn(), + writeCaddyComposeRoutesForTargets: vi.fn(), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { + createComposeDomain, + createDomain, + removeComposeDomainsForWebServer, +} from "@dokploy/server/services/domain"; +import { + getCaddyComposeRouteTargetsForWebServer, + writeCaddyComposeRoutesForTargets, +} from "@dokploy/server/utils/docker/domain"; +import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain"; + +const domain = { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + host: "example.com", + uniqueConfigKey: 7, +}; + +const composeDomain = { + ...domain, + applicationId: null, + composeId: "compose-1", + domainType: "compose", +}; + +const retainedComposeDomain = { + ...composeDomain, + domainId: "domain-2", + host: "retained.example.com", + uniqueConfigKey: 8, +}; + +const application = { + applicationId: "app-1", + appName: "my-app", + serverId: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + txInsertMock.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([domain]), + }), + }); + transactionMock.mockImplementation(async (callback) => { + const tx = { delete: dbDeleteMock, insert: txInsertMock }; + return callback(tx); + }); + domainsFindFirstMock.mockResolvedValue(domain); + domainsFindManyMock.mockResolvedValue([composeDomain]); + dbDeleteMock.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([composeDomain]), + }), + }); + vi.mocked(getCaddyComposeRouteTargetsForWebServer).mockResolvedValue([ + {}, + ] as never); + vi.mocked(writeCaddyComposeRoutesForTargets).mockResolvedValue( + undefined as never, + ); + vi.mocked(findApplicationById).mockResolvedValue(application as never); +}); + +test("creates copied new-service application domains through the active web server provider", async () => { + vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never); + + const created = await createDomain({ + host: " example.com ", + applicationId: "app-1", + domainType: "application", + } as never); + + expect(created).toBe(domain); + expect(txInsertMock).toHaveBeenCalled(); + expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain); +}); + +test("removes application domain rows when provider route creation fails", async () => { + vi.mocked(manageWebServerDomain).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + createDomain({ + host: "example.com", + applicationId: "app-1", + domainType: "application", + } as never), + ).rejects.toThrow("caddy reload failed"); + + expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain); + expect(dbDeleteMock).toHaveBeenCalled(); +}); + +test("removes compose domain rows when Caddy compose route refresh fails after creation", async () => { + txInsertMock.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([composeDomain]), + }), + }); + vi.mocked(writeCaddyComposeRoutesForTargets) + .mockRejectedValueOnce(new Error("caddy refresh failed") as never) + .mockResolvedValueOnce(undefined as never); + + await expect( + createComposeDomain( + { + composeId: "compose-1", + appName: "my-compose", + serverId: null, + } as never, + { + host: "example.com", + composeId: "compose-1", + domainType: "compose", + } as never, + "caddy", + ), + ).rejects.toThrow("caddy refresh failed"); + + expect(dbDeleteMock).toHaveBeenCalled(); + expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(2); +}); + +test("refreshes Caddy compose routes with zero remaining domains before deleting imported template domains", async () => { + domainsFindManyMock.mockResolvedValueOnce([composeDomain]); + + const removed = await removeComposeDomainsForWebServer( + { + composeId: "compose-1", + appName: "my-compose", + serverId: null, + } as never, + [composeDomain] as never, + "caddy", + ); + + expect(removed).toEqual([composeDomain]); + expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenCalledWith( + expect.objectContaining({ composeId: "compose-1" }), + [], + "caddy", + ); + expect(dbDeleteMock).toHaveBeenCalled(); + expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(1); +}); + +test("restores Caddy compose routes if imported template domain deletion fails", async () => { + domainsFindManyMock.mockResolvedValueOnce([ + composeDomain, + retainedComposeDomain, + ]); + dbDeleteMock.mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockRejectedValue(new Error("db delete failed")), + }), + }); + + await expect( + removeComposeDomainsForWebServer( + { + composeId: "compose-1", + appName: "my-compose", + serverId: null, + } as never, + [composeDomain] as never, + "caddy", + ), + ).rejects.toThrow("db delete failed"); + + expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ composeId: "compose-1" }), + [retainedComposeDomain], + "caddy", + ); + expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ composeId: "compose-1" }), + [composeDomain, retainedComposeDomain], + "caddy", + ); + expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(2); +}); + +test("deletes imported template compose domains without Caddy refresh under Traefik", async () => { + domainsFindManyMock.mockResolvedValueOnce([composeDomain]); + + await removeComposeDomainsForWebServer( + { + composeId: "compose-1", + appName: "my-compose", + serverId: null, + } as never, + [composeDomain] as never, + "traefik", + ); + + expect(getCaddyComposeRouteTargetsForWebServer).not.toHaveBeenCalled(); + expect(writeCaddyComposeRoutesForTargets).not.toHaveBeenCalled(); + expect(dbDeleteMock).toHaveBeenCalled(); +}); diff --git a/apps/dokploy/__test__/caddy/application/domain.test.ts b/apps/dokploy/__test__/caddy/application/domain.test.ts new file mode 100644 index 0000000000..9b9569e743 --- /dev/null +++ b/apps/dokploy/__test__/caddy/application/domain.test.ts @@ -0,0 +1,165 @@ +import type { ApplicationNested, Domain } from "@dokploy/server"; +import { + compileCaddyConfig, + createCaddyApplicationRouteFragment, + getCaddyApplicationFragmentId, + paths, +} from "@dokploy/server"; +import { expect, test } from "vitest"; + +const app = (overrides: Partial = {}) => + ({ + appName: "my-app", + serverId: null, + ...overrides, + }) as ApplicationNested; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: null, + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: null, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + ...overrides, + }) as Domain; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +test("creates deterministic Caddy application fragments", () => { + const fragment = createCaddyApplicationRouteFragment(app(), domain()); + + expect(fragment.id).toBe(getCaddyApplicationFragmentId("my-app", 7)); + expect(fragment.source).toBe("dokploy-application"); + expect(fragment.routes[0]).toMatchObject({ + id: "my-app-route-7", + hosts: ["example.com"], + https: false, + upstreams: ["http://my-app:80"], + }); +}); + +test("renders path rewrites and custom upstream port", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ + path: "/public", + stripPath: true, + internalPath: "/internal", + port: 3000, + }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const handlers = getServers(config).http.routes[0].handle; + + expect(getServers(config).http.routes[0].match[0]).toMatchObject({ + host: ["example.com"], + path: ["/public*"], + }); + expect(handlers[0]).toMatchObject({ + handler: "rewrite", + strip_path_prefix: "/public", + }); + expect(handlers[1]).toMatchObject({ + handler: "rewrite", + uri: "/internal{http.request.uri.path}", + }); + expect(handlers[2]).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:3000" }], + }); +}); + +test("HTTPS application routes redirect HTTP and proxy on HTTPS", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ https: true, certificateType: "letsencrypt" }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const servers = getServers(config); + + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:80" }], + }); +}); + +test("loads uploaded certificates for custom Caddy HTTPS routes", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ + https: true, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const tls = (config.apps as any).tls; + const certificatePath = `${paths(false).CERTIFICATES_PATH}/certificate-uploaded`; + + expect(tls.certificates.load_files).toEqual([ + { + certificate: `${certificatePath}/chain.crt`, + key: `${certificatePath}/privkey.key`, + }, + ]); + expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:80" }], + }); +}); + +test("punycodes internationalized hosts for Caddy routes", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ host: "тест.рф" }), + ); + + expect(fragment.routes[0]?.hosts).toEqual(["xn--e1aybc.xn--p1ai"]); +}); + +test("rejects Traefik-only domain features for Caddy routes", () => { + expect(() => + createCaddyApplicationRouteFragment( + app(), + domain({ + customEntrypoint: "admin", + customCertResolver: "internal", + middlewares: ["auth@file"], + }), + ), + ).toThrow("unsupported Caddy fields"); +}); + +test("rejects Caddy custom certificate routes without an uploaded certificate", () => { + expect(() => + createCaddyApplicationRouteFragment( + app(), + domain({ + https: true, + certificateType: "custom", + customCertResolver: null, + }), + ), + ).toThrow("custom certificates require an uploaded certificate"); +}); diff --git a/apps/dokploy/__test__/caddy/certificate-guard.test.ts b/apps/dokploy/__test__/caddy/certificate-guard.test.ts new file mode 100644 index 0000000000..454a3cd97e --- /dev/null +++ b/apps/dokploy/__test__/caddy/certificate-guard.test.ts @@ -0,0 +1,146 @@ +import { fs, vol } from "memfs"; +import { beforeEach, expect, test, vi } from "vitest"; + +const certificatesFindFirstMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + certificates: { + findFirst: certificatesFindFirstMock, + }, + }, + }, +})); + +import type { Domain } from "@dokploy/server"; +import { paths } from "@dokploy/server/constants"; +import { assertCaddyDomainCertificateAvailable } from "@dokploy/server/utils/caddy/domain"; + +const writeCertificateFiles = (certificatePath: string) => { + const certDir = `${paths().CERTIFICATES_PATH}/${certificatePath}`; + vol.mkdirSync(certDir, { recursive: true }); + vol.writeFileSync(`${certDir}/chain.crt`, "cert"); + vol.writeFileSync(`${certDir}/privkey.key`, "key"); +}; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + ...overrides, + }) as Domain; + +beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); +}); + +test("allows uploaded Caddy certificates assigned to the same server and organization", async () => { + writeCertificateFiles("certificate-uploaded"); + certificatesFindFirstMock.mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-1", + }); + + await expect( + assertCaddyDomainCertificateAvailable(null, domain(), "org-1"), + ).resolves.toBeUndefined(); +}); + +test("requires organization context for uploaded Caddy certificate validation", async () => { + writeCertificateFiles("certificate-uploaded"); + certificatesFindFirstMock.mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-1", + }); + + await expect( + assertCaddyDomainCertificateAvailable(null, domain(), null), + ).rejects.toThrow( + "Caddy custom certificate validation requires organization context", + ); + expect(certificatesFindFirstMock).not.toHaveBeenCalled(); +}); + +test("rejects missing, cross-server, or cross-organization Caddy certificate paths", async () => { + certificatesFindFirstMock.mockResolvedValueOnce(null); + await expect( + assertCaddyDomainCertificateAvailable(null, domain(), "org-1"), + ).rejects.toThrow("is not available for this server and organization"); + + certificatesFindFirstMock.mockResolvedValueOnce({ + certificatePath: "certificate-uploaded", + serverId: "server-2", + organizationId: "org-1", + }); + await expect( + assertCaddyDomainCertificateAvailable("server-1", domain(), "org-1"), + ).rejects.toThrow("is not available for this server and organization"); + + certificatesFindFirstMock.mockResolvedValueOnce({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-2", + }); + await expect( + assertCaddyDomainCertificateAvailable(null, domain(), "org-1"), + ).rejects.toThrow("is not available for this server and organization"); +}); + +test("rejects uploaded Caddy certificate rows when files are missing", async () => { + certificatesFindFirstMock.mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-1", + }); + + await expect( + assertCaddyDomainCertificateAvailable(null, domain(), "org-1"), + ).rejects.toThrow("missing readable chain.crt or privkey.key"); +}); + +test("ignores stale custom certificate fields when HTTPS is disabled", async () => { + await expect( + assertCaddyDomainCertificateAvailable( + null, + domain({ https: false }), + "org-1", + ), + ).resolves.toBeUndefined(); + expect(certificatesFindFirstMock).not.toHaveBeenCalled(); +}); + +test("does not require organization context for non-custom Caddy certificates", async () => { + await expect( + assertCaddyDomainCertificateAvailable( + null, + domain({ certificateType: "letsencrypt", customCertResolver: null }), + null, + ), + ).resolves.toBeUndefined(); + expect(certificatesFindFirstMock).not.toHaveBeenCalled(); +}); diff --git a/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts b/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts new file mode 100644 index 0000000000..b49ddf616c --- /dev/null +++ b/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const dbMock = vi.hoisted(() => ({ + query: { + certificates: { findFirst: vi.fn() }, + domains: { findFirst: vi.fn() }, + }, + delete: vi.fn(), + update: vi.fn(), +})); + +const resolveWebServerProviderMock = vi.hoisted(() => vi.fn()); +const removeDirectoryIfExistsContentMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/db", () => ({ + db: dbMock, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getCaddyCompileSettings: vi.fn().mockResolvedValue({}), + resolveWebServerProvider: resolveWebServerProviderMock, +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryIfExistsContent: removeDirectoryIfExistsContentMock, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: vi.fn(), +})); + +import { + removeCertificateById, + updateCertificate, +} from "@dokploy/server/services/certificate"; + +const certificate = { + certificateId: "cert-1", + name: "Shared cert", + certificatePath: "certificate-uploaded", + certificateData: "cert", + privateKey: "key", + autoRenew: null, + organizationId: "org-1", + serverId: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + dbMock.query.certificates.findFirst.mockResolvedValue(certificate); + dbMock.query.domains.findFirst.mockResolvedValue(null); + dbMock.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([certificate]), + }), + }); + dbMock.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([certificate]), + }), + }), + }); + resolveWebServerProviderMock.mockResolvedValue("caddy"); + removeDirectoryIfExistsContentMock.mockResolvedValue(undefined); +}); + +test("blocks deleting uploaded certificates used by active Caddy domains", async () => { + dbMock.query.domains.findFirst.mockResolvedValueOnce({ + host: "example.com", + }); + + await expect(removeCertificateById("cert-1")).rejects.toThrow( + 'Cannot delete certificate "Shared cert" because active Caddy domain "example.com" uses it', + ); + + expect(removeDirectoryIfExistsContentMock).not.toHaveBeenCalled(); + expect(dbMock.delete).not.toHaveBeenCalled(); +}); + +test("blocks replacing uploaded certificate files used by active Caddy domains", async () => { + dbMock.query.domains.findFirst.mockResolvedValueOnce({ + host: "example.com", + }); + + await expect( + updateCertificate("cert-1", { certificateData: "new cert" }), + ).rejects.toThrow( + 'Cannot update certificate "Shared cert" because active Caddy domain "example.com" uses it', + ); + + expect(dbMock.update).not.toHaveBeenCalled(); +}); diff --git a/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts b/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts new file mode 100644 index 0000000000..b6f4f98647 --- /dev/null +++ b/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +const expectNoRawComposeCreateDomain = (source: string) => { + expect(source).not.toMatch( + /createDomain\(\s*\{[\s\S]{0,500}domainType:\s*"compose"/, + ); +}; + +describe("compose domain creation call-site contract", () => { + test("routes template compose domains through shared provider-aware helpers", () => { + const source = readSource("../../server/api/routers/compose.ts"); + + expect(source).toContain("createComposeDomain("); + expect(source).toContain("removeComposeDomainsForWebServer("); + expect(source).toContain("ctx.session.activeOrganizationId"); + expectNoRawComposeCreateDomain(source); + }); + + test("routes AI-generated compose domains through shared provider-aware helpers", () => { + const source = readSource("../../server/api/routers/ai.ts"); + + expect(source).toContain("createComposeDomain("); + expect(source).toContain("ctx.session.activeOrganizationId"); + expectNoRawComposeCreateDomain(source); + }); + + test("routes project duplication domains through application and compose helpers", () => { + const source = readSource("../../server/api/routers/project.ts"); + + expect(source).toContain("await createDomain({"); + expect(source).toContain("applicationId: newApplication.applicationId"); + expect(source).toContain("createComposeDomain("); + expect(source).toContain("ctx.session.activeOrganizationId"); + expectNoRawComposeCreateDomain(source); + }); + + test("routes domain-router compose domains through shared provider-aware helpers", () => { + const source = readSource("../../server/api/routers/domain.ts"); + + expect(source).toContain("createComposeDomain("); + expect(source).toContain("refreshCaddyComposeRoutes("); + expect(source).toContain("ctx.session.activeOrganizationId"); + expectNoRawComposeCreateDomain(source); + }); + + test("keeps compose deploy route refreshes tied to eagerly loaded project org context", () => { + const source = readSource( + "../../../../packages/server/src/services/compose.ts", + ); + + expect(source).toMatch( + /export const findComposeById[\s\S]*environment:\s*{\s*with:\s*{\s*project:\s*true/, + ); + + const routeRefreshCalls = + source.match(/writeCaddyComposeRoutesForTargets\([\s\S]*?\);/g) ?? []; + expect(routeRefreshCalls).toHaveLength(2); + for (const call of routeRefreshCalls) { + expect(call).toContain( + "organizationId: compose.environment.project.organizationId", + ); + } + }); +}); diff --git a/apps/dokploy/__test__/caddy/compose/domain.test.ts b/apps/dokploy/__test__/caddy/compose/domain.test.ts new file mode 100644 index 0000000000..35208b5651 --- /dev/null +++ b/apps/dokploy/__test__/caddy/compose/domain.test.ts @@ -0,0 +1,348 @@ +import path from "node:path"; +import type { Compose, Domain } from "@dokploy/server"; +import { fs, vol } from "memfs"; +import { parse, stringify } from "yaml"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +import { + addDomainToCompose, + addDomainToComposeForWebServer, + compileCaddyConfig, + createCaddyComposeRouteFragment, + createDomainLabels, + isDokployGeneratedTraefikLabel, + paths, + readCaddyRouteFragments, + refreshCaddyComposeRoutes, +} from "@dokploy/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const compose = (overrides: Partial = {}) => + ({ + appName: "my-compose", + serverId: null, + sourceType: "raw", + composePath: "docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + suffix: null, + ...overrides, + }) as Compose; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: null, + composeId: "compose-1", + previewDeploymentId: null, + domainType: "compose", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 8080, + serviceName: "web", + uniqueConfigKey: 3, + createdAt: "", + ...overrides, + }) as Domain; + +const writeComposeFile = ( + composeInput: Compose, + content: Record, +) => { + const filePath = path.join( + paths(false).COMPOSE_PATH, + composeInput.appName, + "code", + "docker-compose.yml", + ); + vol.mkdirSync(path.dirname(filePath), { recursive: true }); + vol.writeFileSync(filePath, stringify(content)); +}; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +beforeEach(() => { + vol.reset(); + execAsyncMock.mockReset(); + execAsyncRemoteMock.mockReset(); + execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ + stdout: "dokploy-caddy\n", + stderr: "", + }); +}); + +describe("Caddy compose route generation", () => { + test("creates route fragments with compose upstream names", () => { + const fragment = createCaddyComposeRouteFragment( + compose(), + domain(), + "web", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + + expect(fragment).toMatchObject({ + id: "compose.my-compose.3", + source: "dokploy-compose", + }); + expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose-web:8080" }], + }); + }); + + test("uses swarm stack service names for Caddy upstreams", () => { + const fragment = createCaddyComposeRouteFragment( + compose({ composeType: "stack" }), + domain(), + "web-blue", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + + expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose_web-blue:8080" }], + }); + }); + + test("refreshes Caddy compose route fragments for domains created outside the domain router", async () => { + const composeInput = compose(); + const routeDomain = domain({ https: true }); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + await refreshCaddyComposeRoutes(composeInput, [routeDomain], "caddy"); + + const fragments = await readCaddyRouteFragments(); + expect(fragments).toHaveLength(1); + expect(fragments[0]).toMatchObject({ + id: "compose.my-compose.3", + source: "dokploy-compose", + }); + const config = compileCaddyConfig({ fragments }); + expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose-web:8080" }], + }); + }); + + test("loads uploaded certificates for custom Caddy compose HTTPS routes", () => { + const fragment = createCaddyComposeRouteFragment( + compose(), + domain({ + https: true, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + }), + "web", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const tls = (config.apps as any).tls; + const certificatePath = `${paths(false).CERTIFICATES_PATH}/certificate-uploaded`; + + expect(tls.certificates.load_files).toEqual([ + { + certificate: `${certificatePath}/chain.crt`, + key: `${certificatePath}/privkey.key`, + }, + ]); + expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose-web:8080" }], + }); + }); + + test("skips compose route refresh for Traefik provider", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + await refreshCaddyComposeRoutes(composeInput, [domain()], "traefik"); + + expect(await readCaddyRouteFragments()).toEqual([]); + expect(execAsyncMock).not.toHaveBeenCalled(); + }); + + test("Traefik provider conversion delegates to the existing Traefik label path", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + const existingTraefikOutput = await addDomainToCompose(composeInput, [ + domain(), + ]); + const providerOutput = await addDomainToComposeForWebServer( + composeInput, + [domain()], + "traefik", + ); + + expect(providerOutput).toEqual(existingTraefikOutput); + }); + + test("Caddy compose conversion strips only Dokploy Traefik labels and attaches shared network", async () => { + const composeInput = compose(); + const routeDomain = domain({ https: true, path: "/app", stripPath: true }); + const dokployLabels = [ + "traefik.docker.network=dokploy-network", + "traefik.enable=true", + ...createDomainLabels(composeInput.appName, routeDomain, "web"), + ...createDomainLabels(composeInput.appName, routeDomain, "websecure"), + ]; + writeComposeFile(composeInput, { + services: { + web: { + image: "nginx", + labels: [ + "com.example.keep=true", + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + ...dokployLabels, + ], + }, + }, + }); + + const converted = await addDomainToComposeForWebServer( + composeInput, + [routeDomain], + "caddy", + ); + + const webService = converted?.services?.web; + expect(webService).toBeDefined(); + const labels = webService?.labels as string[]; + expect(labels).toEqual([ + "com.example.keep=true", + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + ]); + expect(webService?.networks).toEqual({ + "dokploy-network": { aliases: ["my-compose-web"] }, + default: {}, + }); + expect(converted?.networks?.["dokploy-network"]).toEqual({ + external: true, + }); + }); + + test("Caddy compose conversion rejects unsupported domain fields before mutating compose labels", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { + image: "nginx", + labels: [ + "traefik.enable=true", + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + ], + }, + }, + }); + + await expect( + addDomainToComposeForWebServer( + composeInput, + [domain({ middlewares: ["auth@file"] })], + "caddy", + ), + ).rejects.toThrow("unsupported Caddy fields"); + + const stored = parse( + vol + .readFileSync( + path.join( + paths(false).COMPOSE_PATH, + composeInput.appName, + "code", + "docker-compose.yml", + ), + "utf8", + ) + .toString(), + ) as any; + expect(stored.services.web.labels).toEqual([ + "traefik.enable=true", + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + ]); + }); + + test("Caddy compose conversion uses finalized randomized service names", async () => { + const composeInput = compose({ randomize: true, suffix: "blue" }); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + worker: { image: "busybox", depends_on: ["web"] }, + }, + }); + + const converted = await addDomainToComposeForWebServer( + composeInput, + [domain()], + "caddy", + ); + + expect(converted?.services?.web).toBeUndefined(); + expect(converted?.services?.["web-blue"]?.networks).toEqual({ + "dokploy-network": { aliases: ["my-compose-web-blue"] }, + default: {}, + }); + expect(converted?.services?.["worker-blue"]?.depends_on).toEqual([ + "web-blue", + ]); + }); + + test("classifier identifies current Dokploy-generated Traefik labels without matching unrelated labels", () => { + const routeDomain = domain({ https: true }); + expect( + isDokployGeneratedTraefikLabel( + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + { appName: "my-compose", domains: [routeDomain] }, + ), + ).toBe(true); + expect( + isDokployGeneratedTraefikLabel("traefik.enable=true", { + includeGenericLabels: true, + }), + ).toBe(true); + expect( + isDokployGeneratedTraefikLabel( + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + { appName: "my-compose", domains: [routeDomain] }, + ), + ).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/caddy/config.test.ts b/apps/dokploy/__test__/caddy/config.test.ts new file mode 100644 index 0000000000..96c0659ed5 --- /dev/null +++ b/apps/dokploy/__test__/caddy/config.test.ts @@ -0,0 +1,818 @@ +import { fs, vol } from "memfs"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +import { + type ApplicationNested, + type CaddyRouteFragment, + type CaddyRouteIntent, + CLOUDFLARE_TRUSTED_PROXY_RANGES, + caddyTrustedProxySettingsToConfig, + compileAndWriteCaddyConfig, + compileCaddyConfig, + compileWriteAndReloadCaddyConfigSafely, + compileWriteAndValidateCaddyConfigSafely, + createCaddyDashboardRouteFragment, + type Domain, + getCaddyMigrationArtifactPaths, + manageCaddyDomain, + normalizeCaddyTrustedProxySettings, + paths, + readCaddyRouteFragments, + removeCaddyDomain, + updateServerCaddy, + validateCaddyConfigFileWithImage, + writeCaddyRouteFragment, +} from "@dokploy/server"; +import type { webServerSettings } from "@dokploy/server/db/schema"; +import { beforeEach, expect, test, vi } from "vitest"; + +const route = ( + overrides: Partial = {}, +): CaddyRouteIntent => ({ + id: "app-route", + source: "dokploy-application", + hosts: ["example.com"], + https: false, + upstreams: ["http://app:3000"], + ...overrides, +}); + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +type WebServerSettings = typeof webServerSettings.$inferSelect; + +const settings = (overrides: Partial = {}) => + ({ + id: "settings-1", + webServerProvider: "caddy", + caddyTrustedProxyConfig: null, + requestLogsEnabled: false, + https: false, + certificateType: "none", + host: null, + serverIp: null, + letsEncryptEmail: null, + sshPrivateKey: null, + enableDockerCleanup: false, + logCleanupCron: null, + metricsConfig: {} as WebServerSettings["metricsConfig"], + whitelabelingConfig: null, + remoteServersOnly: false, + enforceSSO: false, + cleanupCacheApplications: false, + cleanupCacheOnCompose: false, + cleanupCacheOnPreviews: false, + createdAt: null, + updatedAt: new Date(), + ...overrides, + }) as WebServerSettings; + +beforeEach(() => { + vol.reset(); + execAsyncMock.mockReset(); + execAsyncRemoteMock.mockReset(); + execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ + stdout: "dokploy-caddy\n", + stderr: "", + }); +}); + +test("compiles explicit http and https servers with managed HTTPS redirect", () => { + const config = compileCaddyConfig({ + letsEncryptEmail: "ops@example.com", + routes: [route({ https: true })], + }); + + const servers = getServers(config); + expect(servers.http.listen).toEqual([":80"]); + expect(servers.https.listen).toEqual([":443"]); + expect(servers.http.routes[0].handle[0].handler).toBe("static_response"); + expect(servers.http.routes[0].handle[0].headers.Location).toEqual([ + "https://{http.request.host}{http.request.uri}", + ]); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "app:3000" }], + }); + expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); + expect(servers.http.trusted_proxies).toBeUndefined(); + expect(servers.https.trusted_proxies).toBeUndefined(); + expect(servers.http.client_ip_headers).toBeUndefined(); + expect(servers.https.client_ip_headers).toBeUndefined(); + expect((config as any).logging).toBeUndefined(); + expect(servers.http.logs).toBeUndefined(); + expect(servers.https.logs).toBeUndefined(); +}); + +test("compiles Caddy access-log output when request analytics are enabled", () => { + const config = compileCaddyConfig({ + routes: [route({ https: true })], + accessLogs: { enabled: true }, + }); + + const servers = getServers(config); + expect(servers.http.logs).toEqual({}); + expect(servers.https.logs).toEqual({}); + expect((config as any).logging.logs["dokploy-requests"]).toEqual({ + writer: { + output: "file", + filename: "/etc/caddy/access.log", + }, + encoder: { + format: "json", + }, + include: ["http.log.access"], + }); +}); + +test("dashboard Caddy updates preserve enabled request access logs", async () => { + await updateServerCaddy( + settings({ + requestLogsEnabled: true, + https: true, + letsEncryptEmail: "ops@example.com", + caddyTrustedProxyConfig: { + mode: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }, + }), + "dashboard.example.com", + ); + + const config = JSON.parse( + vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8") as string, + ); + const servers = (config.apps as Record).http.servers; + + expect(servers.http.logs).toEqual({}); + expect(servers.http.trusted_proxies).toEqual({ + source: "static", + ranges: ["192.0.2.0/24"], + }); + expect(servers.http.client_ip_headers).toEqual(["X-Forwarded-For"]); + expect(servers.https.trusted_proxies).toEqual(servers.http.trusted_proxies); + expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); + expect((config as any).logging.logs["dokploy-requests"]).toEqual({ + writer: { + output: "file", + filename: "/etc/caddy/access.log", + }, + encoder: { + format: "json", + }, + include: ["http.log.access"], + }); +}); + +test("restores dashboard fragments when Caddy dashboard reload fails", async () => { + const existingFragment = createCaddyDashboardRouteFragment( + settings(), + "old-dashboard.example.com", + ); + const concurrentFragment: CaddyRouteFragment = { + version: 1, + id: "application.concurrent", + source: "dokploy-application", + routes: [route({ id: "concurrent", hosts: ["concurrent.example.com"] })], + }; + await writeCaddyRouteFragment(existingFragment); + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ fragments: [existingFragment] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + let concurrentFragmentWritten = false; + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + if (!concurrentFragmentWritten) { + concurrentFragmentWritten = true; + await writeCaddyRouteFragment(concurrentFragment); + } + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + updateServerCaddy(settings(), "new-dashboard.example.com"), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([ + concurrentFragment, + existingFragment, + ]); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + previousConfig, + ); +}); + +test("does not overwrite a concurrent dashboard fragment update when dashboard reload fails", async () => { + const existingFragment = createCaddyDashboardRouteFragment( + settings(), + "old-dashboard.example.com", + ); + const concurrentDashboardFragment = createCaddyDashboardRouteFragment( + settings(), + "concurrent-dashboard.example.com", + ); + await writeCaddyRouteFragment(existingFragment); + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ fragments: [existingFragment] }), + null, + 2, + )}\n`; + const concurrentConfig = `${JSON.stringify( + compileCaddyConfig({ fragments: [concurrentDashboardFragment] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + let failedInitialValidation = false; + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate") && !failedInitialValidation) { + failedInitialValidation = true; + await writeCaddyRouteFragment(concurrentDashboardFragment); + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + updateServerCaddy(settings(), "new-dashboard.example.com"), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([ + concurrentDashboardFragment, + ]); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + concurrentConfig, + ); +}); + +test("restores removed dashboard fragments when Caddy dashboard removal reload fails", async () => { + const existingFragment = createCaddyDashboardRouteFragment( + settings(), + "old-dashboard.example.com", + ); + await writeCaddyRouteFragment(existingFragment); + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ fragments: [existingFragment] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect(updateServerCaddy(settings(), null)).rejects.toThrow( + "validation failed", + ); + + expect(await readCaddyRouteFragments()).toEqual([existingFragment]); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + previousConfig, + ); +}); + +test("compiles Cloudflare trusted proxy settings with safe client IP headers", () => { + const config = compileCaddyConfig({ + routes: [route({ https: true })], + trustedProxies: { + source: "cloudflare", + }, + }); + + const servers = getServers(config); + for (const server of [servers.http, servers.https]) { + expect(server.trusted_proxies).toEqual({ + source: "static", + ranges: [...CLOUDFLARE_TRUSTED_PROXY_RANGES], + }); + expect(server.client_ip_headers).toEqual([ + "CF-Connecting-IP", + "X-Forwarded-For", + ]); + expect(server.trusted_proxies_strict).toBe(true); + } +}); + +test("compiles custom static trusted proxy ranges", () => { + const config = compileCaddyConfig({ + routes: [route()], + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24", "2001:db8::/32"], + clientIpHeaders: ["X-Forwarded-For"], + strict: false, + }, + }); + + const servers = getServers(config); + expect(servers.http).toMatchObject({ + trusted_proxies: { + source: "static", + ranges: ["192.0.2.0/24", "2001:db8::/32"], + }, + client_ip_headers: ["X-Forwarded-For"], + }); + expect(servers.http.trusted_proxies_strict).toBeUndefined(); + expect(servers.https.trusted_proxies).toEqual(servers.http.trusted_proxies); +}); + +test("normalizes persisted Caddy trusted proxy settings", () => { + const settings = normalizeCaddyTrustedProxySettings({ + mode: "static", + ranges: [" 192.0.2.0/24 ", "192.0.2.0/24"], + clientIpHeaders: [" X-Forwarded-For ", ""], + strict: null, + }); + + expect(settings).toEqual({ + mode: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }); + expect(caddyTrustedProxySettingsToConfig(settings)).toEqual({ + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }); + expect(normalizeCaddyTrustedProxySettings({ mode: "disabled" })).toBeNull(); +}); + +test("rejects invalid trusted proxy settings before writing Caddy config", () => { + expect(() => + compileCaddyConfig({ + trustedProxies: { source: "static", ranges: ["192.0.2.0/33"] }, + }), + ).toThrow("Invalid Caddy trusted proxy prefix"); + + expect(() => + compileCaddyConfig({ + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For", "x-forwarded-for"], + }, + }), + ).toThrow("Duplicate Caddy trusted proxy client IP header"); +}); + +test("sorts routes by priority before path specificity and stable id", () => { + const config = compileCaddyConfig({ + routes: [ + route({ id: "low-specific", priority: 1, pathPrefix: "/very/specific" }), + route({ id: "high-priority", priority: 10, pathPrefix: "/api" }), + route({ id: "same-priority-longer", priority: 1, pathPrefix: "/longer" }), + route({ + id: "same-tiebreak-b", + priority: 1, + pathPrefix: "/same", + upstreams: ["http://b:3000"], + }), + route({ + id: "same-tiebreak-a", + priority: 1, + pathPrefix: "/same", + upstreams: ["http://a:3000"], + }), + ], + }); + + const [first, second, third, fourth, fifth] = getServers(config).http.routes; + expect(first.match[0].path).toEqual(["/api*"]); + expect(second.match[0].path).toEqual(["/very/specific*"]); + expect(third.match[0].path).toEqual(["/longer*"]); + expect(fourth.handle.at(-1).upstreams).toEqual([{ dial: "a:3000" }]); + expect(fifth.handle.at(-1).upstreams).toEqual([{ dial: "b:3000" }]); +}); + +test("orders migrated manual and Traefik routes before DB fallbacks for identical catch-all matches", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + id: "db-compose-fallback", + source: "dokploy-compose", + https: true, + upstreams: ["http://cms:3000"], + }), + route({ + id: "manual-waf", + source: "manual", + https: true, + upstreams: ["http://waf:8080"], + }), + route({ + id: "dynamic-blog", + source: "traefik-dynamic-file", + https: true, + upstreams: ["http://blog:8080"], + }), + route({ + id: "label-cms", + source: "traefik-compose-label", + https: true, + upstreams: ["http://cms:80"], + }), + ], + }); + + const upstreamOrder = getServers(config).https.routes.map( + (compiledRoute: any) => compiledRoute.handle.at(-1).upstreams[0].dial, + ); + expect(upstreamOrder).toEqual([ + "waf:8080", + "cms:80", + "blog:8080", + "cms:3000", + ]); +}); + +test("renders transforms, headers, and HTTPS upstream transport", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + upstreams: ["https://upstream.example.com:443"], + transforms: { + stripPrefix: "/public", + addPrefix: "/internal", + responseHeaders: { + "Cache-Control": "no-store", + }, + }, + }), + ], + }); + + const handlers = getServers(config).http.routes[0].handle; + expect(handlers[0]).toMatchObject({ + handler: "headers", + response: { set: { "Cache-Control": ["no-store"] } }, + }); + expect(handlers[1]).toMatchObject({ + handler: "rewrite", + strip_path_prefix: "/public", + }); + expect(handlers[2]).toMatchObject({ + handler: "rewrite", + uri: "/internal{http.request.uri.path}", + }); + expect(handlers[3]).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "upstream.example.com:443" }], + transport: { protocol: "http", tls: {} }, + }); +}); + +test("rejects proxy upstreams without explicit valid ports", () => { + for (const upstream of [ + "http://admin", + "https://external.example.com", + "http://app:0", + "http://app:65536", + "http://app:abc", + "app", + "app:0", + ]) { + expect(() => + compileCaddyConfig({ + routes: [route({ upstreams: [upstream] })], + }), + ).toThrow(`invalid upstream "${upstream}"`); + } +}); + +test("allows redirect-only routes without upstreams", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + upstreams: [], + redirectScheme: { scheme: "https", permanent: true }, + }), + ], + }); + + expect(getServers(config).http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); +}); + +test("allows static response routes without upstreams", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + source: "manual", + https: true, + upstreams: [], + staticResponse: { + statusCode: 404, + headers: { + "Cache-Control": "no-store", + }, + }, + }), + ], + }); + + const servers = getServers(config); + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle).toEqual([ + { + handler: "static_response", + status_code: 404, + headers: { + "Cache-Control": ["no-store"], + }, + }, + ]); +}); + +test("stores fragments and compiles them into the active config", async () => { + const fragment: CaddyRouteFragment = { + version: 1, + id: "app.example", + source: "dokploy-application", + routes: [route({ id: "stored", pathPrefix: "/stored" })], + }; + + await writeCaddyRouteFragment(fragment); + const fragments = await readCaddyRouteFragments(); + const config = await compileAndWriteCaddyConfig(); + + expect(fragments).toEqual([fragment]); + expect(getServers(config).http.routes[0].match[0].path).toEqual(["/stored*"]); +}); + +test("rejects invalid fragment ids before touching the store", async () => { + for (const id of ["../bad", "..", ".", "bad..segment"]) { + await expect( + writeCaddyRouteFragment({ + version: 1, + id, + source: "manual", + routes: [route()], + }), + ).rejects.toThrow("Invalid Caddy fragment id"); + } + expect(vol.existsSync(paths().CADDY_FRAGMENTS_PATH)).toBe(false); +}); + +test("rejects unsafe Caddy migration ids", () => { + for (const id of ["..", ".", "bad..segment"]) { + expect(() => getCaddyMigrationArtifactPaths(id)).toThrow( + "Invalid Caddy migration id", + ); + } +}); + +test("validates a config file with the Caddy binary in an isolated runtime container", async () => { + await validateCaddyConfigFileWithImage( + "/etc/dokploy/caddy/migrations/test/caddy.json", + ); + + const validateCommand = execAsyncMock.mock.calls + .map(([command]) => command as string) + .find((command) => command.includes("docker run")); + expect(validateCommand).toContain("docker run --rm --network none"); + expect(validateCommand).toContain("caddy\\:2.11.3"); + expect(validateCommand).toContain( + "/etc/dokploy/caddy/migrations/test/caddy.json\\:/etc/caddy/caddy.json\\:ro", + ); + expect(validateCommand).toContain( + "/etc/dokploy/caddy/migrations/test/.validate-runtime/config\\:/config", + ); + expect(validateCommand).toContain( + `${paths().CERTIFICATES_PATH}\\:${paths().CERTIFICATES_PATH}\\:ro`, + ); + expect(validateCommand).toContain(" caddy validate --config"); + expect(validateCommand).not.toContain("caddy\\:2.11.3 validate --config"); +}); + +test("restores the previous Caddy config when safe validation fails without reloading", async () => { + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + await expect( + compileWriteAndValidateCaddyConfigSafely({ + accessLogs: { enabled: true }, + }), + ).rejects.toThrow("validation failed"); + + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + previousConfig, + ); + expect( + execAsyncMock.mock.calls.some(([command]) => + (command as string).includes("caddy reload"), + ), + ).toBe(false); + consoleError.mockRestore(); +}); + +test("reloads the restored Caddy config when safe reload fails", async () => { + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + let reloads = 0; + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy reload")) { + reloads += 1; + if (reloads === 1) { + throw new Error("reload failed"); + } + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + compileWriteAndReloadCaddyConfigSafely({ + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + }, + }), + ).rejects.toThrow("reload failed"); + + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + previousConfig, + ); + expect(reloads).toBe(2); +}); + +test("preserves the original safe reload error when the restored Caddy reload also fails", async () => { + const previousConfig = `${JSON.stringify( + compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }), + null, + 2, + )}\n`; + vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true }); + vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig); + const restoreError = new Error("restored reload failed"); + let reloads = 0; + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy reload")) { + reloads += 1; + if (reloads === 1) { + throw new Error("new config reload failed"); + } + throw restoreError; + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + let thrownError: Error | undefined; + try { + await compileWriteAndReloadCaddyConfigSafely({ + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + }, + }); + } catch (error) { + thrownError = error as Error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError?.message).toBe("new config reload failed"); + expect((thrownError as Error & { restoreError?: unknown }).restoreError).toBe( + restoreError, + ); + expect(consoleError).toHaveBeenCalledWith( + "Failed to restore Caddy config:", + restoreError, + ); + consoleError.mockRestore(); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + previousConfig, + ); + expect(reloads).toBe(2); +}); + +test("restores previous fragments when Caddy domain reload fails", async () => { + const existingFragment: CaddyRouteFragment = { + version: 1, + id: "app.existing", + source: "dokploy-application", + routes: [route({ id: "existing", hosts: ["old.example.com"] })], + }; + await writeCaddyRouteFragment(existingFragment); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + manageCaddyDomain( + { appName: "my-app", serverId: null } as ApplicationNested, + { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + } as Domain, + ), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([existingFragment]); +}); + +test("restores removed fragments when Caddy domain removal reload fails", async () => { + const existingFragment: CaddyRouteFragment = { + version: 1, + id: "application.my-app.7", + source: "dokploy-application", + routes: [route({ id: "existing", hosts: ["old.example.com"] })], + }; + await writeCaddyRouteFragment(existingFragment); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + removeCaddyDomain( + { appName: "my-app", serverId: null } as ApplicationNested, + 7, + ), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([existingFragment]); +}); diff --git a/apps/dokploy/__test__/caddy/dashboard-route.test.ts b/apps/dokploy/__test__/caddy/dashboard-route.test.ts new file mode 100644 index 0000000000..7ed02b2096 --- /dev/null +++ b/apps/dokploy/__test__/caddy/dashboard-route.test.ts @@ -0,0 +1,88 @@ +import { + compileCaddyConfig, + createCaddyDashboardRouteFragment, +} from "@dokploy/server"; +import type { webServerSettings } from "@dokploy/server/db/schema"; +import { expect, test } from "vitest"; + +type WebServerSettings = typeof webServerSettings.$inferSelect; + +const settings = (overrides: Partial = {}) => + ({ + id: "settings-1", + webServerProvider: "caddy", + caddyTrustedProxyConfig: null, + https: false, + certificateType: "none", + host: null, + serverIp: null, + letsEncryptEmail: null, + sshPrivateKey: null, + enableDockerCleanup: false, + logCleanupCron: null, + metricsConfig: {} as WebServerSettings["metricsConfig"], + whitelabelingConfig: null, + cleanupCacheApplications: false, + cleanupCacheOnCompose: false, + cleanupCacheOnPreviews: false, + createdAt: null, + updatedAt: new Date(), + ...overrides, + }) as WebServerSettings; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +test("keeps the Caddy admin API bound to localhost", () => { + const config = compileCaddyConfig(); + + expect(config.admin.listen).toBe("localhost:2019"); +}); + +test("creates a Caddy dashboard route to the Dokploy container", () => { + const fragment = createCaddyDashboardRouteFragment( + settings(), + "dashboard.example.com", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const route = getServers(config).http.routes[0]; + + expect(fragment).toMatchObject({ + id: "dashboard.dokploy", + source: "dokploy-dashboard", + }); + expect(route.match[0]).toMatchObject({ + host: ["dashboard.example.com"], + }); + expect(route.match[0].path).toBeUndefined(); + expect(route.handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "dokploy:3000" }], + }); +}); + +test("dashboard HTTPS route uses global ACME email when compiled", () => { + const fragment = createCaddyDashboardRouteFragment( + settings({ https: true, letsEncryptEmail: "ops@example.com" }), + "панель.example.com", + ); + const config = compileCaddyConfig({ + fragments: [fragment], + letsEncryptEmail: "ops@example.com", + }); + const servers = getServers(config); + + expect(fragment.routes[0]?.hosts).toEqual(["xn--80aksgi6f.example.com"]); + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + }); + expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); +}); diff --git a/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts b/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts new file mode 100644 index 0000000000..15292a69f3 --- /dev/null +++ b/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +const compact = (source: string) => source.replace(/\s+/g, " "); + +describe("Caddy domain modal UI contract", () => { + test("wires application domain tab and edit actions through AddDomain", () => { + const applicationPage = compact( + readSource( + "../../pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx", + ), + ); + const showDomainsSource = readSource( + "../../components/dashboard/application/domains/show-domains.tsx", + ); + const showDomains = compact(showDomainsSource); + const columns = compact( + readSource("../../components/dashboard/application/domains/columns.tsx"), + ); + + expect(applicationPage).toContain( + 'import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";', + ); + expect(applicationPage).toContain('', + ); + + expect(showDomains).toContain( + 'import { AddDomain } from "./handle-domain";', + ); + expect(showDomains).toContain(""); + expect(showDomainsSource).toMatch( + /", + ); + }); + + test("resolves the active Caddy provider and scopes uploaded certificates", () => { + const source = readSource( + "../../components/dashboard/application/domains/handle-domain.tsx", + ); + const normalized = compact(source); + + expect(source).toMatch( + /api\.settings\.getActiveWebServerProvider\.useQuery\(\s*\{\s*serverId:\s*application\?\.serverId \|\| undefined\s*\},\s*\{\s*enabled:\s*!!application\s*\}/, + ); + expect(normalized).toContain( + 'const isCaddyProvider = activeProvider === "caddy";', + ); + expect(source).toMatch( + /api\.certificates\.all\.useQuery\(undefined,\s*\{\s*enabled:\s*isOpen && isCaddyProvider,\s*\}\)/, + ); + expect(normalized).toContain( + "certificate.serverId === application.serverId", + ); + expect(normalized).toContain(": !certificate.serverId"); + }); + + test("renders Caddy certificate copy and submits uploaded certificate paths", () => { + const source = readSource( + "../../components/dashboard/application/domains/handle-domain.tsx", + ); + const normalized = compact(source); + + for (const expectedCopy of [ + "This server uses Caddy", + "Caddy route fragments", + "Caddy can manage HTTPS", + "Let Caddy manage HTTPS automatically for this host.", + "Caddy-managed HTTPS (ACME)", + "Uploaded certificate", + "Uploaded Certificate", + "Select an uploaded certificate", + "Add an uploaded certificate for this server", + ]) { + expect(source).toContain(expectedCopy); + } + + expect(normalized).toContain('name="customCertResolver"'); + expect(normalized).toContain("key={certificate.certificateId}"); + expect(normalized).toContain("value={certificate.certificatePath}"); + }); + + test("keeps Traefik-only domain controls hidden when Caddy is active", () => { + const source = readSource( + "../../components/dashboard/application/domains/handle-domain.tsx", + ); + + expect(source).toMatch( + /\{!isCaddyProvider && \(\s* ({ + assertCaddyDomainSupported: vi.fn(), + createComposeDomain: vi.fn(), + createDomain: vi.fn(), + findApplicationById: vi.fn(), + findComposeById: vi.fn(), + findDomainById: vi.fn(), + findDomainsByApplicationId: vi.fn(), + findDomainsByComposeId: vi.fn(), + findPreviewDeploymentById: vi.fn(), + findServerById: vi.fn(), + generateTraefikMeDomain: vi.fn(), + getWebServerSettings: vi.fn(), + manageWebServerDomain: vi.fn(), + refreshCaddyComposeRoutes: vi.fn(), + removeDomainById: vi.fn(), + removeWebServerDomain: vi.fn(), + resolveWebServerProvider: vi.fn(), + updateDomainById: vi.fn(), + validateDomain: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +import { + findApplicationById, + findComposeById, + findDomainById, + findDomainsByComposeId, + findPreviewDeploymentById, + manageWebServerDomain, + refreshCaddyComposeRoutes, + removeDomainById, + removeWebServerDomain, + resolveWebServerProvider, + updateDomainById, +} from "@dokploy/server"; +import { domainRouter } from "@/server/api/routers/domain"; + +const application = { + applicationId: "app-1", + appName: "my-app", + serverId: null, +}; + +const currentDomain = { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "old.example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "letsencrypt", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", +}; + +const compose = { + composeId: "compose-1", + appName: "my-compose", + serverId: null, +}; + +const currentComposeDomain = { + ...currentDomain, + applicationId: null, + composeId: "compose-1", + domainType: "compose" as const, + serviceName: "web", +}; + +const siblingComposeDomain = { + ...currentComposeDomain, + domainId: "domain-2", + host: "sibling.example.com", + uniqueConfigKey: 8, +}; + +const previewDeployment = { + previewDeploymentId: "preview-1", + applicationId: "app-1", + appName: "my-app-pr-42", +}; + +const currentPreviewDomain = { + ...currentDomain, + applicationId: null, + previewDeploymentId: "preview-1", + domainType: "preview" as const, + host: "preview.example.com", +}; + +const updateInput = { + domainId: "domain-1", + domainType: "application" as const, + host: "new.example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "letsencrypt" as const, + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, +}; + +const caller = domainRouter.createCaller({ + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + ownerId: "user-1", + email: "owner@example.com", + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + req: { headers: {} }, + res: {}, +} as never); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(findDomainById).mockResolvedValue(currentDomain as never); + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(findComposeById).mockResolvedValue(compose as never); + vi.mocked(findPreviewDeploymentById).mockResolvedValue( + previewDeployment as never, + ); + vi.mocked(findDomainsByComposeId).mockResolvedValue([ + currentComposeDomain, + siblingComposeDomain, + ] as never); + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never); + vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never); + vi.mocked(refreshCaddyComposeRoutes).mockResolvedValue(undefined as never); +}); + +test("restores the previous Caddy application route when domain update persistence fails", async () => { + vi.mocked(updateDomainById).mockRejectedValueOnce( + new Error("db update failed") as never, + ); + + await expect(caller.update(updateInput)).rejects.toThrow("db update failed"); + + expect(manageWebServerDomain).toHaveBeenNthCalledWith( + 1, + application, + expect.objectContaining({ + domainId: "domain-1", + host: "new.example.com", + }), + ); + expect(manageWebServerDomain).toHaveBeenNthCalledWith( + 2, + application, + currentDomain, + ); +}); + +test("preserves application domain rows when Caddy route removal fails before delete", async () => { + vi.mocked(removeWebServerDomain).mockRejectedValueOnce( + new Error("caddy route removal failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "caddy route removal failed", + ); + + expect(removeWebServerDomain).toHaveBeenCalledWith(application, 7); + expect(removeDomainById).not.toHaveBeenCalled(); + expect(manageWebServerDomain).not.toHaveBeenCalled(); +}); + +test("restores the removed Caddy application route when domain delete persistence fails", async () => { + vi.mocked(removeDomainById).mockRejectedValueOnce( + new Error("db delete failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "db delete failed", + ); + + expect(removeWebServerDomain).toHaveBeenCalledWith(application, 7); + expect(manageWebServerDomain).toHaveBeenCalledWith( + application, + currentDomain, + ); +}); + +test("preserves preview domain rows when Caddy route removal fails before delete", async () => { + vi.mocked(findDomainById).mockResolvedValueOnce( + currentPreviewDomain as never, + ); + vi.mocked(removeWebServerDomain).mockRejectedValueOnce( + new Error("preview caddy route removal failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "preview caddy route removal failed", + ); + + expect(findPreviewDeploymentById).toHaveBeenCalledWith("preview-1"); + expect(removeWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ appName: "my-app-pr-42" }), + 7, + ); + expect(removeDomainById).not.toHaveBeenCalled(); +}); + +test("restores previous compose domain fields when Caddy route refresh fails after update", async () => { + const updatedDomain = { + ...currentComposeDomain, + host: "new.example.com", + }; + vi.mocked(findDomainById).mockResolvedValueOnce( + currentComposeDomain as never, + ); + vi.mocked(updateDomainById) + .mockResolvedValueOnce(updatedDomain as never) + .mockResolvedValueOnce(currentComposeDomain as never); + vi.mocked(refreshCaddyComposeRoutes) + .mockRejectedValueOnce(new Error("caddy refresh failed") as never) + .mockResolvedValueOnce(undefined as never); + + await expect( + caller.update({ + ...updateInput, + domainType: "compose", + serviceName: "web", + }), + ).rejects.toThrow("caddy refresh failed"); + + expect(updateDomainById).toHaveBeenNthCalledWith( + 1, + "domain-1", + expect.objectContaining({ + host: "new.example.com", + domainType: "compose", + serviceName: "web", + }), + ); + expect(updateDomainById).toHaveBeenNthCalledWith( + 2, + "domain-1", + expect.objectContaining({ + host: "old.example.com", + domainType: "compose", + serviceName: "web", + }), + ); + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 2, + compose, + undefined, + "caddy", + "org-1", + ); +}); + +test("preserves compose domain rows when Caddy route refresh fails before delete", async () => { + vi.mocked(findDomainById).mockResolvedValueOnce( + currentComposeDomain as never, + ); + vi.mocked(refreshCaddyComposeRoutes) + .mockRejectedValueOnce(new Error("caddy refresh failed") as never) + .mockResolvedValueOnce(undefined as never); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "caddy refresh failed", + ); + + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 1, + compose, + [siblingComposeDomain], + "caddy", + "org-1", + ); + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 2, + compose, + undefined, + "caddy", + "org-1", + ); + expect(removeDomainById).not.toHaveBeenCalled(); +}); + +test("restores all compose routes when compose domain delete persistence fails", async () => { + vi.mocked(findDomainById).mockResolvedValueOnce( + currentComposeDomain as never, + ); + vi.mocked(removeDomainById).mockRejectedValueOnce( + new Error("db delete failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "db delete failed", + ); + + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 1, + compose, + [siblingComposeDomain], + "caddy", + "org-1", + ); + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 2, + compose, + undefined, + "caddy", + "org-1", + ); +}); diff --git a/apps/dokploy/__test__/caddy/domain-validation.test.ts b/apps/dokploy/__test__/caddy/domain-validation.test.ts new file mode 100644 index 0000000000..1d314014c0 --- /dev/null +++ b/apps/dokploy/__test__/caddy/domain-validation.test.ts @@ -0,0 +1,169 @@ +import { + apiCreateDomain, + apiUpdateDomain, +} from "@dokploy/server/db/schema/domain"; +import { domain, domainCompose } from "@dokploy/server/db/validations/domain"; +import { describe, expect, test } from "vitest"; + +describe("domain validation", () => { + test("does not require a custom certificate resolver when HTTPS is disabled", () => { + expect( + domain.safeParse({ + host: "example.com", + https: false, + certificateType: "none", + }).success, + ).toBe(true); + + expect( + domainCompose.safeParse({ + host: "example.com", + https: false, + certificateType: "none", + serviceName: "web", + }).success, + ).toBe(true); + }); + + test("requires a custom certificate resolver for HTTPS custom certificates", () => { + const result = domain.safeParse({ + host: "example.com", + https: true, + certificateType: "custom", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["customCertResolver"], + message: "Required when certificate type is custom", + }), + ]), + ); + } + }); + + const baseApplicationDomain = { + applicationId: "app-1", + domainType: "application" as const, + host: "app.example.com", + path: "/", + internalPath: "/", + stripPath: false, + port: 3000, + middlewares: [] as string[], + }; + + test("accepts application domain create payloads used by the Caddy modal", () => { + expect( + apiCreateDomain.parse({ + ...baseApplicationDomain, + https: true, + certificateType: "letsencrypt", + }), + ).toMatchObject({ + applicationId: "app-1", + domainType: "application", + https: true, + certificateType: "letsencrypt", + }); + + expect( + apiCreateDomain.parse({ + ...baseApplicationDomain, + https: true, + certificateType: "custom", + customCertResolver: "local-uploaded-cert-path", + }), + ).toMatchObject({ + customCertResolver: "local-uploaded-cert-path", + certificateType: "custom", + }); + + expect( + apiCreateDomain.parse({ + ...baseApplicationDomain, + https: false, + certificateType: "none", + }), + ).toMatchObject({ + https: false, + certificateType: "none", + }); + }); + + test("accepts application domain update payloads used by the Caddy modal", () => { + const baseUpdate = { + ...baseApplicationDomain, + domainId: "domain-1", + }; + + expect( + apiUpdateDomain.parse({ + ...baseUpdate, + https: true, + certificateType: "letsencrypt", + }), + ).toMatchObject({ + domainId: "domain-1", + domainType: "application", + https: true, + certificateType: "letsencrypt", + }); + + expect( + apiUpdateDomain.parse({ + ...baseUpdate, + https: true, + certificateType: "custom", + customCertResolver: "local-uploaded-cert-path", + }), + ).toMatchObject({ + domainId: "domain-1", + customCertResolver: "local-uploaded-cert-path", + certificateType: "custom", + }); + + expect( + apiUpdateDomain.parse({ + ...baseUpdate, + https: false, + certificateType: "none", + }), + ).toMatchObject({ + domainId: "domain-1", + https: false, + certificateType: "none", + }); + }); + + test("rejects Caddy custom certificate payloads without an uploaded certificate reference", () => { + const createResult = apiCreateDomain.safeParse({ + ...baseApplicationDomain, + https: true, + certificateType: "custom", + }); + const updateResult = apiUpdateDomain.safeParse({ + ...baseApplicationDomain, + domainId: "domain-1", + https: true, + certificateType: "custom", + }); + + for (const result of [createResult, updateResult]) { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["customCertResolver"], + message: "Required when certificate type is custom", + }), + ]), + ); + } + } + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts new file mode 100644 index 0000000000..0e87695167 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts @@ -0,0 +1,700 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +vi.mock("@dokploy/server/services/settings", () => ({ + getDockerResourceSnapshot: vi.fn(), + readEnvironmentVariables: vi.fn(), + readPorts: vi.fn(), + stopDockerResource: vi.fn(), + startDockerResourceFromSnapshot: vi.fn(), + ensureTraefikRunningFromSnapshot: vi.fn(), + waitForDockerResourceRunning: vi.fn(), + writeCaddySetup: vi.fn(), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getCaddyCompileSettings: vi.fn(), + resolveWebServerProvider: vi.fn(), + updateLocalWebServerProvider: vi.fn(), + updateRemoteWebServerProvider: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/caddy/config", () => ({ + reloadCaddyAfterValidation: vi.fn(), + validateCaddyConfigFileWithImage: vi.fn(), + validateCaddyConfigWithContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/caddy/migration/upstream-preflight", () => ({ + runCaddyMigrationUpstreamPreflight: vi.fn(), +})); + +import { paths } from "@dokploy/server/constants"; +import * as settingsService from "@dokploy/server/services/settings"; +import * as providerService from "@dokploy/server/services/web-server-settings"; +import * as caddyConfig from "@dokploy/server/utils/caddy/config"; +import { applyCaddyMigration } from "@dokploy/server/utils/caddy/migration/apply"; +import { getCaddyMigrationArtifactPaths } from "@dokploy/server/utils/caddy/migration/files"; +import { rollbackCaddyMigration } from "@dokploy/server/utils/caddy/migration/rollback"; +import type { CaddyMigrationReport } from "@dokploy/server/utils/caddy/migration/types"; +import * as upstreamPreflight from "@dokploy/server/utils/caddy/migration/upstream-preflight"; + +const createReport = (migrationId: string): CaddyMigrationReport => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId); + return { + migrationId, + serverId: null, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-22T00:00:00.000Z", + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`, + traefikStaticConfigFound: true, + dynamicFiles: [], + dbApplicationDomains: 0, + dbComposeDomains: 0, + composeFilesScanned: [], + composeFilesSkipped: [], + }, + summary: { + fragments: 1, + routes: 1, + warnings: 0, + blockingWarnings: 0, + }, + validation: { status: "passed", message: "ok" }, + compileSettings: { + letsEncryptEmail: null, + trustedProxies: null, + }, + warnings: [], + events: [], + }; +}; + +const seedMigration = (migrationId: string) => { + const report = createReport(migrationId); + const currentPaths = paths(); + vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true }); + vol.writeFileSync( + `${report.artifactPaths.fragmentsDir}/app.json`, + JSON.stringify({ version: 1, id: "app", source: "manual", routes: [] }), + ); + vol.writeFileSync(report.artifactPaths.caddyJson, '{"apps":{"http":{}}}\n'); + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify(report, null, 2)}\n`, + ); + vol.mkdirSync(currentPaths.MAIN_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints: {}\n", + ); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/app.yml`, + "http: {}\n", + ); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync(`${currentPaths.CADDY_FRAGMENTS_PATH}/old.json`, "{}\n"); + vol.writeFileSync(currentPaths.CADDY_CONFIG_PATH, '{"old":true}\n'); + return report; +}; + +describe("applyCaddyMigration", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + vi.mocked(providerService.resolveWebServerProvider).mockResolvedValue( + "traefik", + ); + vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValue({ + trustedProxies: null, + }); + vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation( + async (resourceName: string) => ({ + resourceName, + resourceType: + resourceName === "dokploy-traefik" ? "standalone" : "unknown", + running: resourceName === "dokploy-traefik", + }), + ); + vi.mocked(settingsService.readEnvironmentVariables).mockResolvedValue(""); + vi.mocked(settingsService.readPorts).mockResolvedValue([]); + vi.mocked(settingsService.waitForDockerResourceRunning).mockResolvedValue({ + resourceName: "dokploy-caddy", + resourceType: "standalone", + running: true, + }); + vi.mocked( + settingsService.ensureTraefikRunningFromSnapshot, + ).mockResolvedValue(undefined); + vi.mocked(caddyConfig.reloadCaddyAfterValidation).mockResolvedValue( + {} as any, + ); + vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mockResolvedValue( + {} as any, + ); + vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mockResolvedValue( + {} as any, + ); + vi.mocked( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).mockResolvedValue({ + status: "passed", + checkedAt: "2026-05-22T00:00:00.000Z", + network: "dokploy-network", + probeImage: "busybox:1.36", + checks: [], + }); + }); + + test("rejects a concurrent migration operation while a lock exists", async () => { + const report = seedMigration("caddy-apply-locked"); + vol.mkdirSync(`${report.artifactPaths.root}/.operation.lock`); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("already has an apply or rollback operation in progress"); + expect( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).not.toHaveBeenCalled(); + }); + + test("writes approved artifacts, starts Caddy, validates, then updates provider", async () => { + const report = seedMigration("caddy-apply-success"); + vi.mocked(settingsService.readPorts).mockImplementation( + async (resourceName: string) => + resourceName === "dokploy-traefik" + ? [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + { targetPort: 8082, publishedPort: 8082, protocol: "tcp" }, + { targetPort: 2019, publishedPort: 2019, protocol: "tcp" }, + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ] + : [], + ); + + const applied = await applyCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(applied.status).toBe("applied"); + expect(settingsService.stopDockerResource).toHaveBeenCalledWith( + "dokploy-traefik", + undefined, + ); + expect( + vi.mocked(upstreamPreflight.runCaddyMigrationUpstreamPreflight).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(settingsService.stopDockerResource).mock + .invocationCallOrder[0] ?? 0, + ); + expect(caddyConfig.validateCaddyConfigFileWithImage).toHaveBeenCalledWith( + report.artifactPaths.caddyJson, + undefined, + ); + expect( + vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(settingsService.stopDockerResource).mock + .invocationCallOrder[0] ?? 0, + ); + expect(settingsService.writeCaddySetup).toHaveBeenCalledWith( + expect.objectContaining({ + additionalPorts: [ + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ], + }), + ); + expect(caddyConfig.validateCaddyConfigWithContainer).toHaveBeenCalled(); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "caddy", + ); + expect( + vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(providerService.updateLocalWebServerProvider).mock + .invocationCallOrder[0] ?? 0, + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"apps":{"http":{}}}\n', + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe( + true, + ); + }); + + test("applies and rolls back approved Caddy custom certificate artifacts", async () => { + const report = seedMigration("caddy-custom-cert-apply-rollback"); + const certificatePath = `${paths().CERTIFICATES_PATH}/certificate-uploaded`; + const loadFiles = [ + { + certificate: `${certificatePath}/chain.crt`, + key: `${certificatePath}/privkey.key`, + }, + ]; + vol.writeFileSync( + report.artifactPaths.caddyJson, + `${JSON.stringify( + { + apps: { + http: {}, + tls: { + certificates: { + load_files: loadFiles, + }, + }, + }, + }, + null, + 2, + )}\n`, + ); + vi.mocked( + caddyConfig.validateCaddyConfigFileWithImage, + ).mockImplementationOnce(async (filePath: string) => { + const validatedConfig = JSON.parse( + vol.readFileSync(filePath, "utf8") as string, + ); + expect(validatedConfig.apps.tls.certificates.load_files).toEqual( + loadFiles, + ); + return {} as any; + }); + + const applied = await applyCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(applied.status).toBe("applied"); + expect(caddyConfig.validateCaddyConfigFileWithImage).toHaveBeenCalledWith( + report.artifactPaths.caddyJson, + undefined, + ); + const activeConfig = JSON.parse( + vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8") as string, + ); + expect(activeConfig.apps.tls.certificates.load_files).toEqual(loadFiles); + + const rolledBack = await rollbackCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(rolledBack.status).toBe("rolled_back"); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"old":true}\n', + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/old.json`)).toBe( + true, + ); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + }); + + test("rejects apply when Caddy compile settings changed after prepare", async () => { + const report = seedMigration("caddy-apply-stale-settings"); + vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValueOnce({ + trustedProxies: { + source: "cloudflare", + }, + }); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("Caddy compile settings changed after prepare"); + + expect( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).not.toHaveBeenCalled(); + expect(settingsService.stopDockerResource).not.toHaveBeenCalled(); + expect(settingsService.writeCaddySetup).not.toHaveBeenCalled(); + const failedReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(failedReport.status).toBe("failed"); + expect(failedReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "apply-failed", + message: expect.stringContaining("Caddy compile settings changed"), + }), + ]), + ); + }); + + test("rejects apply when Caddy request-log compile settings changed after prepare", async () => { + const report = seedMigration("caddy-apply-stale-request-logs"); + vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValueOnce({ + trustedProxies: null, + accessLogs: { enabled: true }, + }); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("Caddy compile settings changed after prepare"); + + expect( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).not.toHaveBeenCalled(); + expect(settingsService.stopDockerResource).not.toHaveBeenCalled(); + expect(settingsService.writeCaddySetup).not.toHaveBeenCalled(); + const failedReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(failedReport.status).toBe("failed"); + expect(failedReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "apply-failed", + message: expect.stringContaining("Caddy compile settings changed"), + }), + ]), + ); + }); + + test("rewrites the mounted Caddy config file in place after setup", async () => { + const report = seedMigration("caddy-apply-preserves-config-inode"); + let mountedConfigFd: number | undefined; + vi.mocked(settingsService.writeCaddySetup).mockImplementationOnce( + async () => { + vol.writeFileSync( + paths().CADDY_CONFIG_PATH, + '{"generatedBySetup":true}\n', + ); + mountedConfigFd = fs.openSync(paths().CADDY_CONFIG_PATH, "r"); + }, + ); + + await applyCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(mountedConfigFd).toBeDefined(); + const mountedContent = Buffer.alloc(1024); + const bytesRead = fs.readSync( + mountedConfigFd as number, + mountedContent, + 0, + mountedContent.length, + 0, + ); + fs.closeSync(mountedConfigFd as number); + expect(mountedContent.toString("utf8", 0, bytesRead)).toBe( + '{"apps":{"http":{}}}\n', + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"apps":{"http":{}}}\n', + ); + }); + + test("runtime upstream preflight failure stops apply before Traefik is stopped", async () => { + const report = seedMigration("caddy-preflight-fails"); + vi.mocked( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).mockResolvedValueOnce({ + status: "failed", + checkedAt: "2026-05-22T00:00:00.000Z", + network: "dokploy-network", + probeImage: "busybox:1.36", + checks: [ + { + dial: "missing:3000", + host: "missing", + port: 3000, + network: "dokploy-network", + status: "failed", + reason: "DNS resolution failed", + routes: [], + }, + ], + }); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("Runtime upstream preflight failed"); + + expect(settingsService.stopDockerResource).not.toHaveBeenCalledWith( + "dokploy-traefik", + undefined, + ); + expect(settingsService.writeCaddySetup).not.toHaveBeenCalled(); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).not.toHaveBeenCalled(); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.runtimePreflight?.status).toBe("failed"); + }); + + test("rolls back to Traefik and keeps provider Traefik when Caddy setup fails", async () => { + const report = seedMigration("caddy-apply-fails"); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("caddy"); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + expect(settingsService.stopDockerResource).toHaveBeenCalledWith( + "dokploy-caddy", + undefined, + ); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: "dokploy-traefik" }), + undefined, + ); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("rolled_back"); + }); + + test("uses restore-only unredacted resource snapshot during rollback", async () => { + const report = seedMigration("caddy-restore-snapshot"); + vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation( + async (resourceName: string) => ({ + resourceName, + resourceType: + resourceName === "dokploy-traefik" ? "standalone" : "unknown", + running: resourceName === "dokploy-traefik", + env: resourceName === "dokploy-traefik" ? "SECRET=value" : undefined, + image: + resourceName === "dokploy-traefik" + ? "private.registry.example/traefik:latest" + : undefined, + binds: + resourceName === "dokploy-traefik" + ? ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"] + : undefined, + labels: + resourceName === "dokploy-traefik" + ? { "secret.label": "private" } + : undefined, + }), + ); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.backup?.traefikResource?.env).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.binds).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.labels).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.image).toBeUndefined(); + expect(finalReport.backup?.traefikResource).toMatchObject({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }); + expect(finalReport.backup?.restoreSnapshotPath).toBeTruthy(); + const restoreSnapshots = JSON.parse( + vol.readFileSync( + finalReport.backup?.restoreSnapshotPath ?? "", + "utf8", + ) as string, + ) as { traefikResource: { env?: string; binds?: string[] } }; + expect(restoreSnapshots.traefikResource.env).toBe("SECRET=value"); + expect(restoreSnapshots.traefikResource.binds).toEqual([ + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + ]); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ + env: "SECRET=value", + binds: ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"], + }), + undefined, + ); + }); + + test("rollback failure leaves provider unchanged and writes failed report", async () => { + const report = seedMigration("caddy-rollback-traefik-fails"); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + vi.mocked( + settingsService.ensureTraefikRunningFromSnapshot, + ).mockRejectedValueOnce(new Error("traefik missing")); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("traefik"); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "rollback-failed", + message: "traefik missing", + blocking: true, + }), + ]), + ); + }); + + test("restores backed-up Caddy files when post-start validation fails", async () => { + const report = seedMigration("caddy-validation-fails"); + vi.mocked( + caddyConfig.validateCaddyConfigWithContainer, + ).mockRejectedValueOnce(new Error("validation failed")); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("validation failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("caddy"); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"old":true}\n', + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/old.json`)).toBe( + true, + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe( + false, + ); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("rolled_back"); + }); + + test("fails closed when an expected rollback backup path is missing", async () => { + const report = seedMigration("caddy-missing-traefik-backup"); + const traefikConfigPath = `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`; + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify( + { + ...report, + status: "applied", + backup: { + createdAt: "2026-05-22T00:00:00.000Z", + traefikResource: { + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }, + files: [ + { + label: "traefik-static", + source: traefikConfigPath, + backupPath: `${report.artifactPaths.backupsDir}/missing-traefik.yml`, + existed: true, + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + + await expect( + rollbackCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("backup path is missing"); + + expect(vol.readFileSync(traefikConfigPath, "utf8")).toBe( + "entryPoints: {}\n", + ); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).not.toHaveBeenCalled(); + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("traefik"); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "rollback-failed", + blocking: true, + }), + ]), + ); + }); + + test("rolls back older reports that have backup metadata without file entries", async () => { + const report = seedMigration("caddy-old-backup-report"); + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify( + { + ...report, + status: "applied", + backup: { + createdAt: "2026-05-22T00:00:00.000Z", + traefikResource: { + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }, + }, + }, + null, + 2, + )}\n`, + ); + + const rolledBack = await rollbackCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(rolledBack.status).toBe("rolled_back"); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: "dokploy-traefik" }), + undefined, + ); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts new file mode 100644 index 0000000000..49a301ab44 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const rollbackCaddyMigrationMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server", () => ({ + rollbackCaddyMigration: rollbackCaddyMigrationMock, +})); + +import { + parseCaddyRollbackArgs, + runCaddyMigrationRollbackCli, +} from "../../../scripts/caddy-migration-rollback"; + +const createIo = () => ({ + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, +}); + +describe("caddy migration rollback CLI", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("requires a migration id", async () => { + expect(() => parseCaddyRollbackArgs([])).toThrow("Missing --migration-id"); + + const io = createIo(); + const code = await runCaddyMigrationRollbackCli([], io); + + expect(code).toBe(1); + expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled(); + expect(io.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Missing --migration-id"), + ); + }); + + test("prints help and exits zero without invoking rollback", async () => { + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli(["--help"], io); + + expect(code).toBe(0); + expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled(); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining("Usage: caddy-migration-rollback"), + ); + expect(io.stderr.write).not.toHaveBeenCalled(); + }); + + test("awaits rollback and exits zero for rolled_back reports", async () => { + rollbackCaddyMigrationMock.mockResolvedValueOnce({ + migrationId: "caddy-123", + status: "rolled_back", + warnings: [], + summary: { warnings: 0, blockingWarnings: 0, fragments: 1, routes: 1 }, + artifactPaths: { reportJson: "/tmp/report.json" }, + }); + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli( + ["--migration-id", "caddy-123", "--server-id", "server-1"], + io, + ); + + expect(code).toBe(0); + expect(rollbackCaddyMigrationMock).toHaveBeenCalledWith({ + migrationId: "caddy-123", + serverId: "server-1", + }); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining('"status": "rolled_back"'), + ); + }); + + test("exits non-zero for non-rolled-back terminal reports", async () => { + rollbackCaddyMigrationMock.mockResolvedValueOnce({ + migrationId: "caddy-123", + status: "failed", + warnings: [], + summary: { warnings: 1, blockingWarnings: 1, fragments: 1, routes: 1 }, + artifactPaths: { reportJson: "/tmp/report.json" }, + }); + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli( + ["--migration-id", "caddy-123"], + io, + ); + + expect(code).toBe(1); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining('"status": "failed"'), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts new file mode 100644 index 0000000000..4360b8e6d5 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts @@ -0,0 +1,329 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + compileCaddyConfig, + translateTraefikComposeLabelsToCaddyFragment, +} from "@dokploy/server"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +const fixture = (name: string) => + parse(readFileSync(path.join(__dirname, "fixtures", name), "utf8")) as any; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +const composeLabels = (serviceName: string) => + fixture("generic-compose-labels.yml").services[serviceName].deploy?.labels ?? + fixture("generic-compose-labels.yml").services[serviceName].labels; + +describe("Traefik compose labels to Caddy migration", () => { + test("translates representative Dokploy-generated HTTP/HTTPS label pairs", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("frontend"), + { + sourceFile: "compose-sample-stack/docker-compose.yml", + appName: "compose-sample-stack", + serviceName: "frontend", + upstreamServiceName: "frontend", + }, + ); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + + expect(result.warnings).toEqual([]); + expect(result.classifications.some((item) => item.dokployGenerated)).toBe( + true, + ); + + const secureRoute = result.routes.find((route) => + route.id.includes("websecure"), + ); + expect(secureRoute).toMatchObject({ + source: "traefik-compose-label", + hosts: ["app.example.com"], + https: true, + upstreams: ["http://frontend:3000"], + }); + + const redirectRoute = result.routes.find((route) => + route.id.endsWith("-web"), + ); + expect(redirectRoute).toMatchObject({ + redirectScheme: { scheme: "https", permanent: true }, + upstreams: [], + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "frontend:3000" }], + }); + }); + + test("translates manual compose labels with OR host rules and file middleware definitions", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("cms"), + { + sourceFile: "cms-site/docker-compose.yml", + serviceName: "cms", + upstreamServiceName: "cms", + fileMiddlewares: { + "cms-security-headers": { + headers: { + stsSeconds: 31536000, + stsIncludeSubdomains: true, + stsPreload: true, + referrerPolicy: "strict-origin-when-cross-origin", + contentTypeNosniff: true, + customFrameOptionsValue: "SAMEORIGIN", + }, + }, + }, + }, + ); + + const prodRoute = result.routes.find((route) => + route.id.includes("cms-prod"), + ); + expect(result.warnings).toEqual([]); + expect(prodRoute).toMatchObject({ + hosts: ["example.com", "www.example.com"], + https: true, + upstreams: ["http://cms:8080"], + transforms: { + responseHeaders: { + "Strict-Transport-Security": + "max-age=31536000; includeSubDomains; preload", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + }, + }, + }); + }); + + test("translates path-specific manual labels with priority and dynamic file middleware mapping", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("admin"), + { + sourceFile: "admin-console/docker-compose.yml", + serviceName: "admin", + upstreamServiceName: "admin", + fileMiddlewares: { + "admin-no-store": { + headers: { + customResponseHeaders: { + "Cache-Control": "private, no-store", + }, + }, + }, + }, + }, + ); + + expect(result.warnings).toEqual([]); + const adminRoutes = result.routes.filter((route) => + route.id.includes("admin-console"), + ); + expect(adminRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + priority: 100, + pathPrefix: "/admin", + transforms: expect.objectContaining({ + responseHeaders: expect.objectContaining({ + "Cache-Control": "private, no-store", + }), + }), + }), + expect.objectContaining({ + priority: 100, + pathExact: "/login", + }), + ]), + ); + }); + + test("treats tls=true router labels as HTTPS intent", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.app.rule=Host(`tls.example.com`)", + "traefik.http.routers.app.entrypoints=websecure", + "traefik.http.routers.app.tls=true", + "traefik.http.routers.app.service=app", + "traefik.http.services.app.loadbalancer.server.port=3000", + ], + { + sourceFile: "tls/docker-compose.yml", + serviceName: "app", + upstreamServiceName: "app", + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + hosts: ["tls.example.com"], + https: true, + upstreams: ["http://app:3000"], + }); + }); + + test("translates compose-label routes that reference ipAllowList file middleware", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.dash.rule=Host(`dash.example.com`)", + "traefik.http.routers.dash.entrypoints=websecure", + "traefik.http.routers.dash.tls.certresolver=letsencrypt", + "traefik.http.routers.dash.service=dash", + "traefik.http.routers.dash.middlewares=internal-allowlist@file", + "traefik.http.services.dash.loadbalancer.server.port=8000", + ], + { + sourceFile: "dash/docker-compose.yml", + serviceName: "dash", + upstreamServiceName: "dash", + fileMiddlewares: { + "internal-allowlist": { + ipAllowList: { + sourceRange: ["192.0.2.0/24"], + }, + } as any, + }, + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24"], + }); + }); + + test("drops generated labels when manual labels are present after security middleware is migratable", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.enable=true", + "traefik.http.routers.sample-app-42-websecure.rule=Host(`dashboard.example.com`)", + "traefik.http.routers.sample-app-42-websecure.entrypoints=websecure", + "traefik.http.routers.sample-app-42-websecure.tls.certresolver=letsencrypt", + "traefik.http.routers.sample-app-42-websecure.middlewares=internal-allowlist@file", + "traefik.http.routers.sample-app-42-websecure.service=sample-app-42-websecure", + "traefik.http.services.sample-app-42-websecure.loadbalancer.server.port=8000", + "traefik.http.routers.custom.rule=Host(`custom.example.com`)", + "traefik.http.routers.custom.entrypoints=websecure", + "traefik.http.routers.custom.tls.certresolver=letsencrypt", + "traefik.http.routers.custom.service=custom", + "traefik.http.services.custom.loadbalancer.server.port=3000", + ], + { + sourceFile: "sample-app/dashboard-api/labels", + appName: "sample-app", + domains: [ + { + host: "dashboard.example.com", + uniqueConfigKey: 42, + https: true, + } as any, + ], + serviceName: "dashboard-api", + upstreamServiceName: "dashboard-api", + fileMiddlewares: { + "internal-allowlist": { + ipAllowList: { + sourceRange: ["192.0.2.0/24"], + }, + } as any, + }, + }, + ); + + expect(result.routes).toEqual([ + expect.objectContaining({ + hosts: ["custom.example.com"], + upstreams: ["http://dashboard-api:3000"], + }), + ]); + expect(result.warnings).toEqual([]); + }); + + test("parses inline ipWhiteList labels as Caddy remote IP restrictions", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.admin.rule=Host(`admin.example.com`)", + "traefik.http.routers.admin.entrypoints=websecure", + "traefik.http.routers.admin.tls.certresolver=letsencrypt", + "traefik.http.routers.admin.service=admin", + "traefik.http.routers.admin.middlewares=admin-allow", + "traefik.http.services.admin.loadbalancer.server.port=8080", + "traefik.http.middlewares.admin-allow.ipWhiteList.sourceRange=192.0.2.0/24,127.0.0.1/32", + ], + { + sourceFile: "admin/docker-compose.yml", + serviceName: "admin", + upstreamServiceName: "admin", + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24", "127.0.0.1/32"], + }); + }); + + test("does not classify app-name-shaped labels as generated when DB domains are provided", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + "traefik.http.routers.my-compose-custom-web.entrypoints=web", + "traefik.http.services.my-compose-custom-web.loadbalancer.server.port=8080", + ], + { + sourceFile: "my-compose/docker-compose.yml", + appName: "my-compose", + domains: [], + serviceName: "web", + upstreamServiceName: "web", + }, + ); + + expect(result.classifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + dokployGenerated: false, + }), + ]), + ); + expect(result.routes[0]).toMatchObject({ + hosts: ["custom.example.com"], + upstreams: ["http://web:8080"], + }); + }); + + test("returns blocking warnings for unsupported label rules and middleware constructs", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("unsupported"), + { + sourceFile: "unsupported/docker-compose.yml", + serviceName: "unsupported", + }, + ); + + expect(result.routes).toHaveLength(0); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-matcher", + routerName: "unsupported", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-middleware", + middlewareName: "plugin-only", + blocking: true, + }), + ]), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts new file mode 100644 index 0000000000..a04a43c439 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts @@ -0,0 +1,266 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + compileCaddyConfig, + translateTraefikDynamicConfigToCaddyFragment, +} from "@dokploy/server"; +import { describe, expect, test } from "vitest"; + +const fixture = (name: string) => + readFileSync(path.join(__dirname, "fixtures", name), "utf8"); + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +describe("Traefik dynamic file to Caddy migration", () => { + test("translates priority dynamic-file routers, headers, redirects, and external upstreams", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + fixture("priority-dynamic.yml"), + { sourceFile: "priority-dynamic.yml" }, + ); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + + expect(result.warnings).toEqual([]); + expect(result.fragment).toMatchObject({ + id: "migration.traefik-dynamic.priority-dynamic", + source: "traefik-dynamic-file", + }); + + const activityRoutes = result.routes.filter((route) => + route.id.includes("activity-feed"), + ); + expect(activityRoutes).toHaveLength(3); + expect(activityRoutes.map((route) => route.priority)).toEqual([ + 10000, 10000, 10000, + ]); + expect(activityRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pathPrefix: "/activity", + upstreams: ["https://activity.example.net:443"], + }), + expect.objectContaining({ pathExact: "/.well-known/webfinger" }), + expect.objectContaining({ pathExact: "/.well-known/nodeinfo" }), + ]), + ); + + const adminRoute = result.routes.find((route) => + route.id.includes("admin-console"), + ); + expect(adminRoute).toMatchObject({ + hosts: ["example.com", "www.example.com"], + pathPrefix: "/admin", + priority: 9000, + transforms: { + responseHeaders: { + "Cache-Control": + "private, no-store, no-cache, must-revalidate, max-age=0", + Pragma: "no-cache", + Expires: "0", + }, + }, + }); + + const redirectRoute = result.routes.find((route) => + route.id.includes("site-redirect"), + ); + expect(redirectRoute).toMatchObject({ + redirectScheme: { scheme: "https", permanent: true }, + upstreams: [], + }); + + const activityProxy = servers.https.routes.find((route: any) => + route.match[0].path?.includes("/activity*"), + ); + expect(activityProxy.handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "activity.example.net:443" }], + transport: { protocol: "http", tls: {} }, + }); + }); + + test("normalizes Traefik service URLs with default scheme ports", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin", + ].join("\n"), + { sourceFile: "admin.yml" }, + ); + + expect(result.routes[0]).toMatchObject({ + upstreams: ["http://admin:80"], + }); + expect(() => + compileCaddyConfig({ fragments: [result.fragment] }), + ).not.toThrow(); + }); + + test("skips Traefik api@internal dashboard routers as non-migratable internals", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " traefik-dashboard:", + " rule: PathPrefix(`/dashboard`) || PathPrefix(`/api`)", + " entryPoints: [traefik]", + " service: api@internal", + " middlewares: [internal-allowlist]", + ].join("\n"), + { sourceFile: "routers.yml" }, + ); + + expect(result.routes).toEqual([]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unsupported-router", + routerName: "traefik-dashboard", + serviceName: "api@internal", + blocking: false, + message: expect.stringContaining("Skipped Traefik internal router"), + }), + ]); + }); + + test("translates active routes that use ipAllowList file middleware", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " dash:", + " rule: Host(`dash.example.com`)", + " entryPoints: [websecure]", + " service: dash", + " tls:", + " certResolver: letsencrypt", + " middlewares: [internal-allowlist]", + " services:", + " dash:", + " loadBalancer:", + " servers:", + " - url: http://dash:8000", + " middlewares:", + " internal-allowlist:", + " ipAllowList:", + " sourceRange:", + " - 192.0.2.0/24", + ].join("\n"), + { sourceFile: "dash.yml" }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24"], + }); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + expect(servers.https.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + match: [ + expect.objectContaining({ + remote_ip: { ranges: ["192.0.2.0/24"] }, + }), + ], + }), + expect.objectContaining({ + handle: [ + expect.objectContaining({ + handler: "static_response", + status_code: 403, + }), + ], + }), + ]), + ); + }); + + test("translates strip/add prefix, basicAuth, chain, and blocks unsupported constructs", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + fixture("middleware-coverage.yml"), + { sourceFile: "middleware-coverage.yml" }, + ); + + const toolRoute = result.routes.find((route) => route.id.endsWith("-tool")); + expect(toolRoute).toMatchObject({ + transforms: { + stripPrefix: "/public", + addPrefix: "/internal", + requestHeaders: { "X-Forwarded-Proto": "https" }, + responseHeaders: { "X-Test": "true" }, + }, + basicAuth: [{ username: "admin", hash: "$2y$05$abcdef" }], + }); + + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-service", + serviceName: "tool", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-middleware", + middlewareName: "plugin-auth", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-router", + routerName: "unsupported-plugin", + message: expect.stringContaining("Custom certResolver"), + blocking: true, + }), + ]), + ); + }); + + test("blocks basicAuth hashes that Caddy cannot safely consume", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " secure:", + " rule: Host(`secure.example.com`)", + " service: secure", + " middlewares: [legacy-auth]", + " services:", + " secure:", + " loadBalancer:", + " servers:", + " - url: http://secure:3000", + " middlewares:", + " legacy-auth:", + " basicAuth:", + " users:", + " - admin:$apr1$legacy-hash", + ].join("\n"), + { sourceFile: "legacy-auth.yml" }, + ); + + expect(result.routes[0]?.basicAuth).toBeNull(); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-security-middleware", + middlewareName: "legacy-auth", + blocking: true, + message: expect.stringContaining("hash format"), + }), + ]), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml new file mode 100644 index 0000000000..2500c4eb69 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml @@ -0,0 +1,58 @@ +# Representative Traefik compose labels for Caddy migration tests. +services: + frontend: + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.compose-sample-stack-frontend-web.rule=Host(`app.example.com`) + - traefik.http.routers.compose-sample-stack-frontend-web.entrypoints=web + - traefik.http.services.compose-sample-stack-frontend-web.loadbalancer.server.port=3000 + - traefik.http.routers.compose-sample-stack-frontend-web.service=compose-sample-stack-frontend-web + - traefik.http.routers.compose-sample-stack-frontend-web.middlewares=redirect-to-https@file + - traefik.http.routers.compose-sample-stack-frontend-websecure.rule=Host(`app.example.com`) + - traefik.http.routers.compose-sample-stack-frontend-websecure.entrypoints=websecure + - traefik.http.services.compose-sample-stack-frontend-websecure.loadbalancer.server.port=3000 + - traefik.http.routers.compose-sample-stack-frontend-websecure.service=compose-sample-stack-frontend-websecure + - traefik.http.routers.compose-sample-stack-frontend-websecure.tls.certresolver=letsencrypt + - traefik.http.routers.compose-sample-stack-frontend-websecure.middlewares=security-headers@file + - traefik.docker.network=dokploy-network + cms: + deploy: + labels: + - traefik.enable=true + - traefik.docker.network=dokploy-network + - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`) + - traefik.http.routers.cms-prod.entrypoints=websecure + - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt + - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file + - traefik.http.routers.cms-prod.service=cms-prod + - traefik.http.routers.cms-preview.rule=Host(`cms.test.example.com`) || Host(`preview.test.example.com`) + - traefik.http.routers.cms-preview.entrypoints=websecure + - traefik.http.routers.cms-preview.tls.certresolver=letsencrypt + - traefik.http.routers.cms-preview.middlewares=cms-security-headers@file + - traefik.http.routers.cms-preview.service=cms-prod + - traefik.http.services.cms-prod.loadbalancer.server.port=8080 + admin: + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.admin.rule=Host(`admin.example.com`) + - traefik.http.routers.admin.entrypoints=websecure + - traefik.http.routers.admin.service=admin + - traefik.http.routers.admin.tls.certresolver=letsencrypt + - traefik.http.routers.admin.middlewares=security-headers@file + - traefik.http.routers.admin-console.rule=Host(`admin.example.com`) && (PathPrefix(`/admin`) || Path(`/login`)) + - traefik.http.routers.admin-console.entrypoints=websecure + - traefik.http.routers.admin-console.service=admin + - traefik.http.routers.admin-console.tls.certresolver=letsencrypt + - traefik.http.routers.admin-console.middlewares=security-headers@file,admin-no-store@file + - traefik.http.routers.admin-console.priority=100 + - traefik.http.services.admin.loadbalancer.server.port=80 + unsupported: + labels: + - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`) + - traefik.http.routers.unsupported.entrypoints=websecure + - traefik.http.routers.unsupported.service=unsupported + - traefik.http.services.unsupported.loadbalancer.server.port=9000 + - traefik.http.routers.unsupported.middlewares=plugin-only + - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml new file mode 100644 index 0000000000..964c992f75 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml @@ -0,0 +1,54 @@ +http: + routers: + tool: + rule: "Host(`tools.example.com`) && PathPrefix(`/public`)" + entryPoints: + - websecure + service: tool + tls: + certResolver: letsencrypt + middlewares: + - tool-chain + unsupported-plugin: + rule: "Host(`blocked.example.com`)" + entryPoints: + - websecure + service: tool + tls: + certResolver: customresolver + middlewares: + - plugin-auth + services: + tool: + loadBalancer: + servers: + - url: http://tool:8080 + passHostHeader: false + middlewares: + tool-chain: + chain: + middlewares: + - strip-public + - add-internal + - basic-users + - response-headers + strip-public: + stripPrefix: + prefixes: + - /public + add-internal: + addPrefix: + prefix: /internal + basic-users: + basicAuth: + users: + - admin:$2y$05$abcdef + response-headers: + headers: + customRequestHeaders: + X-Forwarded-Proto: "https" + customResponseHeaders: + X-Test: "true" + plugin-auth: + plugin: + unsupported: {} diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml new file mode 100644 index 0000000000..ace2ca7a5c --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml @@ -0,0 +1,58 @@ +# Representative Traefik dynamic-file routes for Caddy migration tests. +http: + routers: + activity-feed: + rule: "Host(`example.com`) && (PathPrefix(`/activity`) || Path(`/.well-known/webfinger`) || Path(`/.well-known/nodeinfo`))" + entryPoints: + - websecure + service: hosted-activity + tls: + certResolver: letsencrypt + middlewares: + - security-headers + priority: 10000 + admin-console: + rule: "(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/admin`)" + entryPoints: + - websecure + service: site + tls: + certResolver: letsencrypt + middlewares: + - security-headers + - admin-no-store + priority: 9000 + site: + rule: "Host(`example.com`) || Host(`www.example.com`)" + entryPoints: + - websecure + service: site + tls: + certResolver: letsencrypt + middlewares: + - security-headers + site-redirect: + rule: "Host(`example.com`) || Host(`www.example.com`)" + entryPoints: + - web + service: site + middlewares: + - redirect-to-https + services: + site: + loadBalancer: + servers: + - url: http://site:8080 + passHostHeader: true + hosted-activity: + loadBalancer: + servers: + - url: https://activity.example.net:443 + passHostHeader: true + middlewares: + admin-no-store: + headers: + customResponseHeaders: + Cache-Control: "private, no-store, no-cache, must-revalidate, max-age=0" + Pragma: "no-cache" + Expires: "0" diff --git a/apps/dokploy/__test__/caddy/migration/prepare.test.ts b/apps/dokploy/__test__/caddy/migration/prepare.test.ts new file mode 100644 index 0000000000..41702311dc --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/prepare.test.ts @@ -0,0 +1,724 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { findMany: vi.fn() }, + certificates: { findFirst: vi.fn() }, + compose: { findMany: vi.fn() }, + }, + }, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getCaddyCompileSettings: vi.fn().mockResolvedValue({ + letsEncryptEmail: "ops@example.com", + trustedProxies: null, + }), + getWebServerSettings: vi.fn().mockResolvedValue({ + letsEncryptEmail: "ops@example.com", + }), +})); + +vi.mock("@dokploy/server/utils/docker/domain", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@dokploy/server/utils/docker/domain") + >(); + return { + ...actual, + loadDockerCompose: vi.fn().mockResolvedValue(null), + loadDockerComposeRemote: vi.fn().mockResolvedValue(null), + }; +}); + +const remoteDockerMock = vi.hoisted(() => ({ + listServices: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + listTasks: vi.fn().mockResolvedValue([]), + getContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: vi.fn().mockResolvedValue(remoteDockerMock), +})); + +import { paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { prepareCaddyMigration } from "@dokploy/server/utils/caddy/migration/prepare"; + +const domain = { + domainId: "domain-1", + host: "app.example.com", + https: true, + port: 3000, + customEntrypoint: null, + path: "/", + serviceName: null, + domainType: "application", + uniqueConfigKey: 1, + createdAt: new Date().toISOString(), + composeId: null, + customCertResolver: null, + applicationId: "app-1", + previewDeploymentId: null, + certificateType: "letsencrypt", + internalPath: "/", + stripPath: false, + middlewares: [], +}; + +const genericComposeFixture = [ + "services:", + " cms:", + " deploy:", + " labels:", + " - traefik.enable=true", + " - traefik.docker.network=dokploy-network", + " - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`)", + " - traefik.http.routers.cms-prod.entrypoints=websecure", + " - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt", + " - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file", + " - traefik.http.routers.cms-prod.service=cms-prod", + " - traefik.http.services.cms-prod.loadbalancer.server.port=8080", + " unsupported:", + " labels:", + " - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`)", + " - traefik.http.routers.unsupported.entrypoints=websecure", + " - traefik.http.routers.unsupported.service=unsupported", + " - traefik.http.services.unsupported.loadbalancer.server.port=9000", + " - traefik.http.routers.unsupported.middlewares=plugin-only", + " - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true", +].join("\n"); + +const writeCertificateFiles = (certificatePath: string) => { + const certDir = `${paths().CERTIFICATES_PATH}/${certificatePath}`; + vol.mkdirSync(certDir, { recursive: true }); + vol.writeFileSync(`${certDir}/chain.crt`, "cert"); + vol.writeFileSync(`${certDir}/privkey.key`, "key"); +}; + +describe("prepareCaddyMigration", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + remoteDockerMock.listServices.mockResolvedValue([]); + remoteDockerMock.listContainers.mockResolvedValue([]); + remoteDockerMock.listTasks.mockResolvedValue([]); + remoteDockerMock.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({}), + }); + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + appName: "test-app", + serverId: null, + environment: { project: { organizationId: "org-1" } }, + domains: [domain], + } as any, + ]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + vi.mocked(db.query.certificates.findFirst).mockResolvedValue(undefined); + }); + + test("writes reviewable dry-run artifacts without touching live Caddy config", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`, + [ + "http:", + " routers:", + " manual:", + " rule: Host(`manual.example.com`)", + " entryPoints: [websecure]", + " service: manual", + " tls:", + " certResolver: letsencrypt", + " services:", + " manual:", + " loadBalancer:", + " servers:", + " - url: http://manual:8080", + ].join("\n"), + ); + + const report = await prepareCaddyMigration(); + + expect(report.status).toBe("prepared"); + expect(report.summary.blockingWarnings).toBe(0); + expect(report.summary.fragments).toBe(2); + expect(report.inputs.dynamicFiles).toHaveLength(1); + expect(vol.existsSync(report.artifactPaths.reportJson)).toBe(true); + expect(vol.existsSync(report.artifactPaths.reportMd)).toBe(true); + expect(vol.existsSync(report.artifactPaths.caddyJson)).toBe(true); + expect(vol.existsSync(currentPaths.CADDY_CONFIG_PATH)).toBe(false); + + const draft = JSON.parse( + vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string, + ) as any; + expect(draft.apps.tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); + const fragmentFiles = vol.readdirSync(report.artifactPaths.fragmentsDir); + expect(fragmentFiles).toEqual( + expect.arrayContaining([ + "application.test-app.1.json", + "migration.traefik-dynamic.manual.json", + ]), + ); + }); + + test("blocks DB fallback routes with missing uploaded custom certificates", async () => { + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + appName: "custom-cert-app", + serverId: null, + environment: { project: { organizationId: "org-1" } }, + domains: [ + { + ...domain, + certificateType: "custom", + customCertResolver: "legacy-traefik-resolver", + }, + ], + } as any, + ]); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(1); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + blocking: true, + code: "missing-certificate", + source: "custom-cert-app", + message: expect.stringContaining("legacy-traefik-resolver"), + }), + ]), + ); + expect(report.summary.fragments).toBe(0); + }); + + test("blocks DB fallback routes with cross-organization uploaded certificates", async () => { + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + appName: "custom-cert-app", + serverId: null, + environment: { project: { organizationId: "org-1" } }, + domains: [ + { + ...domain, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + }, + ], + } as any, + ]); + vi.mocked(db.query.certificates.findFirst).mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-2", + } as any); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(1); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + blocking: true, + code: "missing-certificate", + source: "custom-cert-app", + message: expect.stringContaining("server and organization"), + }), + ]), + ); + expect(report.summary.fragments).toBe(0); + }); + + test("keeps DB fallback routes with readable uploaded custom certificates", async () => { + writeCertificateFiles("certificate-uploaded"); + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + appName: "custom-cert-app", + serverId: null, + environment: { project: { organizationId: "org-1" } }, + domains: [ + { + ...domain, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + }, + ], + } as any, + ]); + vi.mocked(db.query.certificates.findFirst).mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: null, + organizationId: "org-1", + } as any); + + const report = await prepareCaddyMigration(); + const draft = JSON.parse( + vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string, + ) as any; + const certificatePath = `${paths().CERTIFICATES_PATH}/certificate-uploaded`; + + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "missing-certificate" }), + ]), + ); + expect(report.summary.fragments).toBe(1); + expect(draft.apps.tls.certificates.load_files).toEqual([ + { + certificate: `${certificatePath}/chain.crt`, + key: `${certificatePath}/privkey.key`, + }, + ]); + }); + + test("carries existing manual Caddy fragments into the migration draft", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.archive.json`, + JSON.stringify( + { + version: 1, + id: "manual.archive", + source: "manual", + routes: [ + { + id: "archive-404", + source: "manual", + hosts: ["archive.example.com"], + https: true, + upstreams: [], + staticResponse: { statusCode: 404 }, + }, + ], + }, + null, + 2, + ), + ); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect( + vol.existsSync( + `${report.artifactPaths.fragmentsDir}/manual.archive.json`, + ), + ).toBe(true); + const draft = JSON.parse( + vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string, + ) as any; + const archiveRoute = draft.apps.http.servers.https.routes.find( + (item: any) => JSON.stringify(item.match).includes("archive.example.com"), + ); + expect(archiveRoute.handle[0]).toMatchObject({ + handler: "static_response", + status_code: 404, + }); + }); + + test("blocks carried manual Caddy fragments that overlap generated migration routes", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.conflict.json`, + JSON.stringify( + { + version: 1, + id: "manual.conflict", + source: "manual", + routes: [ + { + id: "manual-conflict", + source: "manual", + hosts: ["manual.example.com"], + https: true, + upstreams: [], + staticResponse: { statusCode: 404 }, + }, + ], + }, + null, + 2, + ), + ); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`, + [ + "http:", + " routers:", + " manual:", + " rule: Host(`manual.example.com`)", + " entryPoints: [websecure]", + " service: manual", + " tls:", + " certResolver: letsencrypt", + " services:", + " manual:", + " loadBalancer:", + " servers:", + " - url: http://manual:8080", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(1); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "conflicting-manual-fragment", + source: "manual.conflict", + blocking: true, + }), + ]), + ); + expect(vol.readFileSync(report.artifactPaths.reportMd, "utf8")).toContain( + "conflicting-manual-fragment", + ); + }); + + test("reports compose-label warnings with source references during dry run", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`, + [ + "http:", + " middlewares:", + " cms-security-headers:", + " headers:", + " stsSeconds: 31536000", + " stsIncludeSubdomains: true", + " stsPreload: true", + " referrerPolicy: strict-origin-when-cross-origin", + " contentTypeNosniff: true", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([ + { + appName: "generic-fixture", + serverId: null, + composeFile: genericComposeFixture, + composeType: "docker-compose", + randomize: false, + isolatedDeployment: false, + suffix: null, + domains: [], + } as any, + ]); + + const report = await prepareCaddyMigration(); + + expect(report.inputs.composeFilesScanned).toEqual(["generic-fixture"]); + expect(report.summary.fragments).toBe(1); + expect(report.summary.blockingWarnings).toBeGreaterThan(0); + expect(report.warnings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unresolved-middleware", + source: "generic-fixture/cms/deploy.labels", + middlewareName: "cms-security-headers@file", + }), + ]), + ); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-matcher", + source: "generic-fixture/unsupported/labels", + routerName: "unsupported", + blocking: true, + }), + ]), + ); + + const reportJson = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ); + expect(reportJson.inputs.composeFilesScanned).toEqual(["generic-fixture"]); + const reportMd = vol.readFileSync( + report.artifactPaths.reportMd, + "utf8", + ) as string; + expect(reportMd).toContain("Blocking warnings"); + expect(reportMd).not.toContain("cms-security-headers@file"); + expect(reportMd).toContain("generic-fixture/unsupported/labels"); + }); + + test("normalizes dynamic-file upstream URLs with default scheme ports", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`, + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + + const report = await prepareCaddyMigration(); + + expect(report.validation.status).toBe("passed"); + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).toEqual([]); + expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain( + '"dial": "admin:80"', + ); + }); + + test("preserves generated-only compose label IP restrictions before discarding generated labels", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`, + [ + "http:", + " middlewares:", + " internal-allowlist:", + " ipAllowList:", + " sourceRange:", + " - 192.0.2.0/24", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([ + { + appName: "sample-dashboard", + serverId: null, + composeFile: [ + "services:", + " dash-api:", + " image: dash", + " labels:", + " - traefik.enable=true", + " - traefik.docker.network=dokploy-network", + " - traefik.http.routers.sample-dashboard-42-websecure.rule=Host(`dashboard.example.com`)", + " - traefik.http.routers.sample-dashboard-42-websecure.entrypoints=websecure", + " - traefik.http.routers.sample-dashboard-42-websecure.tls.certresolver=letsencrypt", + " - traefik.http.routers.sample-dashboard-42-websecure.middlewares=internal-allowlist@file", + " - traefik.http.routers.sample-dashboard-42-websecure.service=sample-dashboard-42-websecure", + " - traefik.http.services.sample-dashboard-42-websecure.loadbalancer.server.port=8000", + ].join("\n"), + composeType: "docker-compose", + randomize: false, + isolatedDeployment: false, + suffix: null, + domains: [ + { + ...domain, + domainId: "dash-domain", + applicationId: null, + composeId: "dash-compose", + domainType: "compose", + host: "dashboard.example.com", + port: 8000, + serviceName: "dash-api", + uniqueConfigKey: 42, + }, + ], + } as any, + ]); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).toEqual([ + expect.objectContaining({ + code: "shadowed-route", + blocking: false, + }), + ]); + expect(report.summary.fragments).toBe(1); + const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8"); + expect(caddyJson).toContain('"remote_ip"'); + expect(caddyJson).toContain('"status_code": 403'); + }); + + test("skips live Docker Traefik label services with no running tasks", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + remoteDockerMock.listServices.mockResolvedValue([ + { + ID: "stopped-service-id", + Spec: { + Name: "stopped-stack_web", + Mode: { Replicated: { Replicas: 1 } }, + Labels: { + "traefik.http.routers.stopped.rule": "Host(`stopped.example.com`)", + "traefik.http.routers.stopped.entrypoints": "websecure", + "traefik.http.routers.stopped.service": "stopped", + "traefik.http.routers.stopped.tls.certresolver": "letsencrypt", + "traefik.http.services.stopped.loadbalancer.server.port": "8080", + }, + }, + }, + { + ID: "running-service-id", + Spec: { + Name: "running-stack_web", + Mode: { Replicated: { Replicas: 1 } }, + Labels: { + "traefik.http.routers.running.rule": "Host(`running.example.com`)", + "traefik.http.routers.running.entrypoints": "websecure", + "traefik.http.routers.running.service": "running", + "traefik.http.routers.running.tls.certresolver": "letsencrypt", + "traefik.http.services.running.loadbalancer.server.port": "8080", + }, + }, + }, + ]); + remoteDockerMock.listTasks.mockResolvedValue([ + { + ServiceID: "running-service-id", + DesiredState: "running", + Status: { State: "running" }, + }, + ]); + + const report = await prepareCaddyMigration(); + const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8"); + + expect(report.summary.blockingWarnings).toBe(0); + expect(caddyJson).toContain("running.example.com"); + expect(caddyJson).toContain("running-stack_web:8080"); + expect(caddyJson).not.toContain("stopped.example.com"); + expect(caddyJson).not.toContain("stopped-stack_web:8080"); + }); + + test("uses inspected container network aliases for reachability checks", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`, + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin:80", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + remoteDockerMock.listContainers.mockResolvedValue([ + { + Id: "traefik-container", + Names: ["/dokploy-traefik"], + NetworkSettings: { Networks: {} }, + Labels: {}, + }, + { + Id: "admin-container", + Names: ["/admin-console-admin-1"], + NetworkSettings: { Networks: {} }, + Labels: {}, + }, + ]); + remoteDockerMock.getContainer.mockImplementation((id: string) => ({ + inspect: vi.fn().mockResolvedValue( + id === "admin-container" + ? { + NetworkSettings: { + Networks: { + "dokploy-network": { + Aliases: ["admin-console-admin-1", "admin"], + }, + }, + }, + } + : { + NetworkSettings: { + Networks: { + "dokploy-network": { Aliases: ["dokploy-traefik"] }, + }, + }, + }, + ), + })); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "unreachable-upstream" }), + ]), + ); + expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain( + '"dial": "admin:80"', + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts new file mode 100644 index 0000000000..56720c50ae --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); +const initializeTraefikServiceMock = vi.hoisted(() => vi.fn()); +const initializeStandaloneTraefikMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { findMany: vi.fn().mockResolvedValue([]) }, + }, + }, +})); + +vi.mock("@dokploy/server/setup/caddy-setup", () => ({ + initializeCaddyService: vi.fn(), + initializeStandaloneCaddy: vi.fn(), +})); + +vi.mock("@dokploy/server/setup/traefik-setup", () => ({ + initializeTraefikService: initializeTraefikServiceMock, + initializeStandaloneTraefik: initializeStandaloneTraefikMock, +})); + +import { + ensureTraefikRunningFromSnapshot, + getDockerResourceSnapshot, +} from "@dokploy/server/services/settings"; + +describe("Traefik rollback recovery", () => { + let resourceTypes: Record; + + beforeEach(() => { + vi.clearAllMocks(); + resourceTypes = { + "dokploy-traefik": "unknown", + dokploy: "service", + }; + initializeTraefikServiceMock.mockImplementation(async () => { + resourceTypes["dokploy-traefik"] = "service"; + }); + initializeStandaloneTraefikMock.mockImplementation(async () => { + resourceTypes["dokploy-traefik"] = "standalone"; + }); + execAsyncMock.mockImplementation(async (command: string) => { + const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1]; + if (resourceName) { + return { + stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`, + stderr: "", + }; + } + if (command.includes("docker start dokploy-traefik")) { + throw new Error("No such container: dokploy-traefik"); + } + if (command.includes("docker service inspect dokploy-traefik")) { + return { + stdout: JSON.stringify({ Replicated: { Replicas: 1 } }), + stderr: "", + }; + } + if (command.includes("docker service ps dokploy-traefik")) { + return { stdout: "Running 1 second ago\n", stderr: "" }; + } + if ( + command.includes("docker container inspect dokploy-traefik") && + command.includes("'{{json .}}'") + ) { + return { + stdout: `${JSON.stringify({ + State: { + Running: resourceTypes["dokploy-traefik"] === "standalone", + }, + HostConfig: { Binds: [], RestartPolicy: { Name: "always" } }, + NetworkSettings: { Networks: { "dokploy-network": {} } }, + Config: { Labels: {} }, + })}\n`, + stderr: "", + }; + } + if ( + command.includes("docker container inspect dokploy-traefik") && + command.includes(".State.Running") + ) { + return { stdout: "true\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + execAsyncRemoteMock.mockImplementation(execAsyncMock); + }); + + test("recreates missing standalone Traefik with captured snapshot metadata", async () => { + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + env: "FOO=bar\nBAZ=qux", + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + networks: ["dokploy-network"], + labels: { "example.label": "preserved" }, + restartPolicy: { Name: "unless-stopped" }, + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({ + env: ["FOO=bar", "BAZ=qux"], + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + serverId: undefined, + binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + networks: ["dokploy-network"], + labels: { "example.label": "preserved" }, + serviceMounts: undefined, + restartPolicy: { Name: "unless-stopped" }, + serviceNetworks: [], + servicePlacement: undefined, + serviceLabels: { "example.label": "preserved" }, + serviceEndpointPorts: undefined, + }); + expect(initializeTraefikServiceMock).not.toHaveBeenCalled(); + }); + + test("captures standalone Traefik binds and networks in rollback snapshot", async () => { + resourceTypes["dokploy-traefik"] = "standalone"; + execAsyncMock.mockImplementation(async (command: string) => { + const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1]; + if (resourceName) { + return { + stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`, + stderr: "", + }; + } + if (command.includes("docker container inspect dokploy-traefik")) { + if (command.includes("'{{json .}}'")) { + return { + stdout: `${JSON.stringify({ + State: { Running: true }, + HostConfig: { + Binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + RestartPolicy: { Name: "always" }, + }, + NetworkSettings: { Networks: { "dokploy-network": {} } }, + Config: { Labels: { "example.label": "preserved" } }, + })}\n`, + stderr: "", + }; + } + if (command.includes(".Config.Image")) { + return { stdout: "traefik:v3.7.1\n", stderr: "" }; + } + } + if (command.includes("docker container inspect dokploy-traefik")) { + return { stdout: "{}\n", stderr: "" }; + } + return { stdout: "[]\n", stderr: "" }; + }); + + const snapshot = await getDockerResourceSnapshot("dokploy-traefik"); + + expect(snapshot.binds).toEqual([ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ]); + expect(snapshot.networks).toEqual(["dokploy-network"]); + expect(snapshot.labels).toEqual({ "example.label": "preserved" }); + expect(snapshot.restartPolicy).toEqual({ Name: "always" }); + }); + + test("recreates missing Traefik service with captured service shape", async () => { + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "service", + running: false, + replicas: 2, + env: "FOO=bar", + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + mounts: [ + { + Type: "bind", + Source: "/etc/dokploy/traefik/acme.json", + Target: "/letsencrypt/acme.json", + }, + ], + networks: [{ Target: "dokploy-network" }], + labels: { "service.label": "preserved" }, + containerLabels: { "container.label": "preserved" }, + placement: { Constraints: ["node.role==manager"] }, + endpointPorts: [ + { + TargetPort: 443, + PublishedPort: 443, + Protocol: "tcp", + PublishMode: "host", + }, + ], + }); + + expect(initializeTraefikServiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: ["FOO=bar"], + replicas: 2, + serviceMounts: [ + { + Type: "bind", + Source: "/etc/dokploy/traefik/acme.json", + Target: "/letsencrypt/acme.json", + }, + ], + serviceNetworks: [{ Target: "dokploy-network" }], + serviceLabels: { "service.label": "preserved" }, + serviceContainerLabels: { "container.label": "preserved" }, + servicePlacement: { Constraints: ["node.role==manager"] }, + serviceEndpointPorts: [ + { + TargetPort: 443, + PublishedPort: 443, + Protocol: "tcp", + PublishMode: "host", + }, + ], + }), + ); + }); + + test("falls back to generic setup when exact recreation fails", async () => { + initializeStandaloneTraefikMock.mockRejectedValueOnce( + new Error("stale mount"), + ); + + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + image: "traefik:v3.7.1", + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledOnce(); + expect(initializeTraefikServiceMock).toHaveBeenCalledWith( + expect.objectContaining({ image: "traefik:v3.7.1" }), + ); + }); + + test("falls back to standalone recreation when no service shape can be inferred", async () => { + resourceTypes.dokploy = "unknown"; + + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "unknown", + running: false, + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({ + env: undefined, + additionalPorts: [], + image: undefined, + serverId: undefined, + }); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts new file mode 100644 index 0000000000..0ae1d2d7f2 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts @@ -0,0 +1,270 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +import type { CaddyMigrationReport, CaddyRouteFragment } from "@dokploy/server"; +import { + getCaddyMigrationArtifactPaths, + runCaddyMigrationUpstreamPreflight, +} from "@dokploy/server"; + +const createReport = (migrationId: string): CaddyMigrationReport => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId); + return { + migrationId, + serverId: null, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-22T00:00:00.000Z", + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: "", + traefikStaticConfigFound: true, + dynamicFiles: [], + dbApplicationDomains: 0, + dbComposeDomains: 0, + composeFilesScanned: [], + composeFilesSkipped: [], + }, + summary: { + fragments: 1, + routes: 1, + warnings: 0, + blockingWarnings: 0, + }, + validation: { status: "passed", message: "ok" }, + warnings: [], + events: [], + }; +}; + +const writeFragment = ( + report: CaddyMigrationReport, + fragment: CaddyRouteFragment, +) => { + vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true }); + vol.writeFileSync( + `${report.artifactPaths.fragmentsDir}/${fragment.id}.json`, + `${JSON.stringify(fragment, null, 2)}\n`, + ); +}; + +describe("Caddy migration upstream preflight", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + execAsyncMock.mockResolvedValue({ stdout: "passed\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ stdout: "passed\n", stderr: "" }); + }); + + test("deduplicates upstream probes while preserving every route reference", async () => { + const report = createReport("caddy-preflight-dedupe"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "route-a", + source: "manual", + hosts: ["a.example.com"], + upstreams: ["http://app:3000"], + }, + { + id: "route-b", + source: "manual", + hosts: ["b.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(execAsyncMock).toHaveBeenCalledTimes(1); + expect(preflight.checks).toHaveLength(1); + expect(preflight.checks[0]).toMatchObject({ + dial: "app:3000", + host: "app", + port: 3000, + network: "dokploy-network", + status: "passed", + routes: [ + expect.objectContaining({ routeId: "route-a" }), + expect.objectContaining({ routeId: "route-b" }), + ], + }); + }); + + test("reports invalid no-port upstreams without running Docker", async () => { + const report = createReport("caddy-preflight-invalid"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "admin", + source: "manual", + hosts: ["admin.example.com"], + upstreams: ["http://admin"], + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("failed"); + expect(execAsyncMock).not.toHaveBeenCalled(); + expect(preflight.checks[0]).toMatchObject({ + dial: "http://admin", + network: "dokploy-network", + status: "failed", + reason: expect.stringContaining("explicit port"), + routes: [expect.objectContaining({ routeId: "admin" })], + }); + }); + + test("records DNS and TCP probe failures with route context", async () => { + const report = createReport("caddy-preflight-dns"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "missing", + source: "manual", + hosts: ["missing.example.com"], + upstreams: ["http://missing:3000"], + }, + ], + }); + execAsyncMock.mockRejectedValueOnce( + Object.assign(new Error("probe failed"), { stdout: "dns_failed\n" }), + ); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("failed"); + expect(preflight.checks[0]).toMatchObject({ + dial: "missing:3000", + host: "missing", + port: 3000, + network: "dokploy-network", + status: "failed", + reason: "DNS resolution failed", + routes: [expect.objectContaining({ routeId: "missing" })], + }); + }); + + test("probes identical dials separately when route networks differ", async () => { + const report = createReport("caddy-preflight-networks"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "shared-a", + source: "manual", + hosts: ["a.example.com"], + upstreams: ["http://web:8080"], + upstreamNetwork: "project-a", + }, + { + id: "shared-b", + source: "manual", + hosts: ["b.example.com"], + upstreams: ["http://web:8080"], + upstreamNetwork: "project-b", + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.network).toBe("mixed"); + expect(preflight.networks).toEqual(["project-a", "project-b"]); + expect(preflight.checks).toHaveLength(2); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("--network project-a"); + expect(execAsyncMock.mock.calls[1]?.[0]).toContain("--network project-b"); + }); + + test("uses a one-shot container probe on the route network", async () => { + const report = createReport("caddy-preflight-service"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "app", + source: "manual", + hosts: ["app.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + execAsyncMock.mockResolvedValueOnce({ stdout: "passed\n", stderr: "" }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.probeMode).toBe("standalone"); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm"); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain( + "--network dokploy-network", + ); + }); + + test("falls back to a temporary service probe when the overlay network is not attachable", async () => { + const report = createReport("caddy-preflight-service-fallback"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "app", + source: "manual", + hosts: ["app.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + execAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error("docker run failed"), { + stderr: + "Error response from daemon: network dokploy-network is not manually attachable", + }), + ) + .mockResolvedValueOnce({ stdout: "passed\n", stderr: "" }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.probeMode).toBe("service"); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm"); + expect(execAsyncMock.mock.calls[1]?.[0]).toContain("docker service create"); + }); +}); diff --git a/apps/dokploy/__test__/caddy/preview-deployment.test.ts b/apps/dokploy/__test__/caddy/preview-deployment.test.ts new file mode 100644 index 0000000000..c5d5db3cfa --- /dev/null +++ b/apps/dokploy/__test__/caddy/preview-deployment.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const previewDeploymentRow = { + previewDeploymentId: "preview-1", + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + pullRequestCommentId: "comment-1", + domainId: null, +}; + +const insertedPreviewDeployment = { ...previewDeploymentRow }; + +const dbMock = vi.hoisted(() => ({ + query: { + organization: { findFirst: vi.fn() }, + previewDeployments: { findFirst: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: dbMock, +})); + +vi.mock("@dokploy/server/templates", () => ({ + generatePassword: vi.fn(() => "fixedpw"), +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + removeDeploymentsByPreviewDeploymentId: vi.fn(), +})); + +vi.mock("@dokploy/server/services/domain", () => ({ + createDomain: vi.fn(), + removeDomainById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/github", () => ({ + getIssueComment: vi.fn(() => "preview comment"), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + removeService: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryCode: vi.fn(), +})); + +const createCommentMock = vi.hoisted(() => vi.fn()); +const deleteCommentMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/providers/github", () => ({ + authGithub: vi.fn(() => ({ + rest: { + issues: { + createComment: createCommentMock, + deleteComment: deleteCommentMock, + }, + }, + })), +})); + +vi.mock("@dokploy/server/utils/traefik/application", () => ({ + removeTraefikConfig: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/web-server/domain", () => ({ + manageWebServerDomain: vi.fn(), + removeWebServerDomain: vi.fn(), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { + createDomain, + removeDomainById, +} from "@dokploy/server/services/domain"; +import { + createPreviewDeployment, + removePreviewDeployment, +} from "@dokploy/server/services/preview-deployment"; +import { + manageWebServerDomain, + removeWebServerDomain, +} from "@dokploy/server/utils/web-server/domain"; + +const createApplication = () => ({ + applicationId: "app-1", + appName: "my-app", + name: "My App", + owner: "dokploy", + repository: "dokploy", + github: {}, + serverId: null, + server: { + ipAddress: "192.0.2.10", + }, + previewWildcard: "*.sslip.io", + previewPath: "/", + previewPort: 3000, + previewHttps: true, + previewCertificateType: "letsencrypt", + previewCustomCertResolver: null, + environment: { + project: { + organizationId: "org-1", + }, + }, +}); + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(insertedPreviewDeployment, previewDeploymentRow); + dbMock.query.organization.findFirst.mockResolvedValue({ ownerId: "user-1" }); + createCommentMock.mockResolvedValue({ data: { id: 12345 } }); + dbMock.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([insertedPreviewDeployment]), + }), + }); + dbMock.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + dbMock.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + }), + }); + vi.mocked(removeDomainById).mockResolvedValue(undefined as never); + vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never); + deleteCommentMock.mockResolvedValue(undefined); +}); + +test("creates preview domains through the active web server provider", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + + await createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never); + + expect(createDomain).toHaveBeenCalledWith( + expect.objectContaining({ + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + path: "/", + port: 3000, + https: true, + certificateType: "letsencrypt", + domainType: "preview", + previewDeploymentId: "preview-1", + }), + ); + expect(manageWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + }), + domain, + ); +}); + +test("cleans up preview domain state when provider route creation fails", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + vi.mocked(manageWebServerDomain).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never), + ).rejects.toThrow("caddy reload failed"); + + expect(removeDomainById).toHaveBeenCalledWith("domain-1"); + expect(removeWebServerDomain).not.toHaveBeenCalled(); + expect(dbMock.delete).toHaveBeenCalled(); + expect(deleteCommentMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "dokploy", + repo: "dokploy", + comment_id: 12345, + }), + ); +}); + +test("removes preview routes when preview domain linking fails after route creation", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + dbMock.update.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockRejectedValue(new Error("db update failed")), + }), + }); + + await expect( + createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never), + ).rejects.toThrow("db update failed"); + + expect(removeWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + appName: "preview-my-app-fixedpw", + }), + 17, + ); + expect(removeDomainById).toHaveBeenCalledWith("domain-1"); + expect(dbMock.delete).toHaveBeenCalled(); +}); + +test("removes preview domains through the active web server provider", async () => { + const application = createApplication(); + vi.mocked(findApplicationById).mockResolvedValue(application as never); + dbMock.query.previewDeployments.findFirst.mockResolvedValue({ + ...previewDeploymentRow, + domain: { + domainId: "domain-1", + uniqueConfigKey: 17, + }, + application: { + applicationId: "app-1", + serverId: null, + }, + }); + + await removePreviewDeployment("preview-1"); + + expect(removeWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + }), + 17, + ); +}); + +test("keeps preview deployment row when provider route removal fails", async () => { + const application = createApplication(); + vi.mocked(findApplicationById).mockResolvedValue(application as never); + dbMock.query.previewDeployments.findFirst.mockResolvedValue({ + ...previewDeploymentRow, + domain: { + domainId: "domain-1", + uniqueConfigKey: 17, + }, + application: { + applicationId: "app-1", + serverId: null, + }, + }); + vi.mocked(removeWebServerDomain).mockRejectedValueOnce( + new Error("caddy route cleanup failed") as never, + ); + + await expect(removePreviewDeployment("preview-1")).rejects.toThrow( + "caddy route cleanup failed", + ); + + expect(dbMock.delete).not.toHaveBeenCalled(); +}); 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/request-log-cleanup.test.ts b/apps/dokploy/__test__/caddy/request-log-cleanup.test.ts new file mode 100644 index 0000000000..888e3cf335 --- /dev/null +++ b/apps/dokploy/__test__/caddy/request-log-cleanup.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const existsSyncMock = vi.hoisted(() => vi.fn()); +const execAsyncMock = vi.hoisted(() => vi.fn()); +const getWebServerSettingsMock = vi.hoisted(() => vi.fn()); +const updateWebServerSettingsMock = vi.hoisted(() => vi.fn()); +const resolveWebServerProviderMock = vi.hoisted(() => vi.fn()); +const scheduleJobMock = vi.hoisted(() => vi.fn()); +const scheduledJobsMock = vi.hoisted(() => ({}) as Record); +let scheduledCallback: (() => Promise) | undefined; + +vi.mock("node:fs", () => ({ + default: { + existsSync: existsSyncMock, + }, + existsSync: existsSyncMock, +})); + +vi.mock("node-schedule", () => ({ + scheduledJobs: scheduledJobsMock, + scheduleJob: scheduleJobMock, +})); + +vi.mock("@dokploy/server/constants", () => ({ + ACCESS_LOG_RETAINED_LINES: 1000, + paths: () => ({ + DYNAMIC_TRAEFIK_PATH: "/etc/dokploy/traefik/dynamic", + CADDY_ACCESS_LOG_PATH: "/etc/dokploy/caddy/access.log", + }), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: getWebServerSettingsMock, + updateWebServerSettings: updateWebServerSettingsMock, + resolveWebServerProvider: resolveWebServerProviderMock, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, +})); + +import { startLogCleanup } from "@dokploy/server/utils/access-log/handler"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(scheduledJobsMock)) { + delete scheduledJobsMock[key]; + } + scheduledCallback = undefined; + existsSyncMock.mockReturnValue(true); + execAsyncMock.mockResolvedValue({ stdout: "", stderr: "" }); + updateWebServerSettingsMock.mockResolvedValue({}); + scheduleJobMock.mockImplementation((name, _cron, callback) => { + if (name === "access-log-cleanup") { + scheduledCallback = callback; + } + return { cancel: vi.fn() }; + }); +}); + +test("keeps Traefik access-log cleanup and signal behavior", async () => { + resolveWebServerProviderMock.mockResolvedValue("traefik"); + + await startLogCleanup("0 0 * * *"); + await scheduledCallback?.(); + + expect(execAsyncMock).toHaveBeenNthCalledWith( + 1, + "tail -n 1000 /etc/dokploy/traefik/dynamic/access.log > /etc/dokploy/traefik/dynamic/access.log.tmp && mv /etc/dokploy/traefik/dynamic/access.log.tmp /etc/dokploy/traefik/dynamic/access.log", + ); + expect(execAsyncMock).toHaveBeenNthCalledWith( + 2, + "docker exec dokploy-traefik kill -USR1 1", + ); +}); + +test("cleans Caddy access logs without signaling Traefik", async () => { + resolveWebServerProviderMock.mockResolvedValue("caddy"); + + await startLogCleanup("0 0 * * *"); + await scheduledCallback?.(); + + expect(execAsyncMock).toHaveBeenCalledTimes(1); + expect(execAsyncMock).toHaveBeenCalledWith( + "tail -n 1000 /etc/dokploy/caddy/access.log > /etc/dokploy/caddy/access.log.tmp && cat /etc/dokploy/caddy/access.log.tmp > /etc/dokploy/caddy/access.log && rm /etc/dokploy/caddy/access.log.tmp", + ); + expect(execAsyncMock).not.toHaveBeenCalledWith( + "docker exec dokploy-traefik kill -USR1 1", + ); +}); + +test("does not persist invalid cleanup cron schedules", async () => { + const existingCancel = vi.fn(); + scheduledJobsMock["access-log-cleanup"] = { cancel: existingCancel }; + scheduleJobMock.mockReturnValueOnce(null); + + const result = await startLogCleanup("not a cron"); + + expect(result).toBe(false); + expect(existingCancel).not.toHaveBeenCalled(); + expect(updateWebServerSettingsMock).not.toHaveBeenCalled(); +}); diff --git a/apps/dokploy/__test__/caddy/request-log-utils.test.ts b/apps/dokploy/__test__/caddy/request-log-utils.test.ts new file mode 100644 index 0000000000..fdb328e993 --- /dev/null +++ b/apps/dokploy/__test__/caddy/request-log-utils.test.ts @@ -0,0 +1,110 @@ +import { parseRawConfig, processLogs } from "@dokploy/server"; +import { expect, test } from "vitest"; + +const caddyLogEntry = JSON.stringify({ + level: "info", + ts: 1_724_603_677.306, + logger: "http.log.access", + msg: "handled request", + server_name: "http", + request: { + remote_ip: "192.0.2.10", + remote_port: "54321", + client_ip: "198.51.100.20", + proto: "HTTP/2.0", + method: "GET", + host: "app.example.com", + uri: "/dashboard?_rsc=1", + tls: { + resumed: false, + }, + headers: { + "User-Agent": ["curl/8.0"], + }, + }, + bytes_read: 7, + duration: 0.014729375, + size: 1234, + status: 200, +}); + +const traefikLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"app.traefik.test","RequestContentSize":0,"RequestCount":122,"RequestHost":"app.traefik.test","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"app-router@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"app-service@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`; + +test("normalizes Caddy JSON access logs into the Requests table shape", () => { + const result = parseRawConfig(caddyLogEntry); + + expect(result.totalCount).toBe(1); + expect(result.data[0]).toMatchObject({ + Provider: "caddy", + ClientAddr: "192.0.2.10:54321", + ClientHost: "198.51.100.20", + ClientPort: "54321", + DownstreamStatus: 200, + Duration: 14_729_375, + RequestHost: "app.example.com", + RequestMethod: "GET", + RequestPath: "/dashboard?_rsc=1", + RequestPort: "443", + RequestProtocol: "HTTP/2.0", + RequestScheme: "https", + ServiceName: "http", + request_User_Agent: "curl/8.0", + }); +}); + +test("normalizes Caddy user-agent headers case-insensitively", () => { + const result = parseRawConfig( + JSON.stringify({ + ...JSON.parse(caddyLogEntry), + request: { + ...JSON.parse(caddyLogEntry).request, + headers: { + "user-agent": ["lowercase-agent"], + }, + }, + }), + ); + + expect(result.data[0]?.request_User_Agent).toBe("lowercase-agent"); +}); + +test("filters and groups normalized Caddy request logs", () => { + const rawConfig = `${caddyLogEntry}\n${JSON.stringify({ + ...JSON.parse(caddyLogEntry), + ts: 1_724_607_277.306, + status: 404, + request: { + ...JSON.parse(caddyLogEntry).request, + host: "api.example.com", + uri: "/missing", + tls: undefined, + }, + })}`; + + expect( + parseRawConfig(rawConfig, undefined, undefined, "app.example", ["success"]) + .totalCount, + ).toBe(1); + expect( + parseRawConfig(rawConfig, undefined, undefined, "api.example", ["client"]) + .totalCount, + ).toBe(1); + expect( + processLogs(rawConfig, { + start: "2024-08-25T16:00:00.000Z", + end: "2024-08-25T17:00:00.000Z", + }), + ).toEqual([{ hour: "2024-08-25T16:00:00Z", count: 1 }]); +}); + +test("preserves existing Traefik request log parsing", () => { + const result = parseRawConfig(traefikLogEntry); + + expect(result.totalCount).toBe(1); + expect(result.data[0]).toMatchObject({ + Provider: "traefik", + RequestHost: "app.traefik.test", + DownstreamStatus: 304, + Duration: 14_729_375, + }); +}); diff --git a/apps/dokploy/__test__/caddy/request-logs-router.test.ts b/apps/dokploy/__test__/caddy/request-logs-router.test.ts new file mode 100644 index 0000000000..dbd0880208 --- /dev/null +++ b/apps/dokploy/__test__/caddy/request-logs-router.test.ts @@ -0,0 +1,378 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const serverMocks = vi.hoisted(() => ({ + applyCaddyMigration: vi.fn(), + checkGPUStatus: vi.fn(), + checkPortInUse: vi.fn(), + checkPostgresHealth: vi.fn(), + checkRedisHealth: vi.fn(), + checkWebServerHealth: vi.fn(), + cleanupAll: vi.fn(), + cleanupAllBackground: vi.fn(), + cleanupBuilders: vi.fn(), + cleanupContainers: vi.fn(), + cleanupImages: vi.fn(), + cleanupSystem: vi.fn(), + cleanupVolumes: vi.fn(), + compileAndWriteCaddyConfig: vi.fn(), + compileWriteAndValidateCaddyConfigSafely: vi.fn(), + compileWriteAndReloadCaddyConfigSafely: vi.fn(), + execAsync: vi.fn(), + findServerById: vi.fn(), + getCaddyCompileSettings: vi.fn(), + getCaddyMigrationReport: vi.fn(), + getCaddyTrustedProxySettings: vi.fn(), + getDockerDiskUsage: vi.fn(), + getDokployImageTag: vi.fn(), + getLogCleanupStatus: vi.fn(), + getUpdateData: vi.fn(), + getWebServerPaths: vi.fn(), + getWebServerResourceName: vi.fn(), + getWebServerSettings: vi.fn(), + isCaddyAdminAdditionalPort: vi.fn(), + isCaddyReservedAdditionalPort: vi.fn(), + parseRawConfig: vi.fn(), + paths: vi.fn(), + prepareCaddyMigration: vi.fn(), + prepareEnvironmentVariables: vi.fn(), + processLogs: vi.fn(), + readCaddyConfigFileIfExists: vi.fn(), + readConfig: vi.fn(), + readConfigInPath: vi.fn(), + readDirectory: vi.fn(), + readEnvironmentVariables: vi.fn(), + readMainConfig: vi.fn(), + readMonitoringConfig: vi.fn(), + readPorts: vi.fn(), + recreateDirectory: vi.fn(), + reloadCaddyAfterValidation: vi.fn(), + reloadDockerResource: vi.fn(), + resolveWebServerProvider: vi.fn(), + rollbackCaddyMigration: vi.fn(), + sendDockerCleanupNotifications: vi.fn(), + setupGPUSupport: vi.fn(), + spawnAsync: vi.fn(), + startLogCleanup: vi.fn(), + stopLogCleanup: vi.fn(), + updateCaddyTrustedProxySettings: vi.fn(), + updateLetsEncryptEmail: vi.fn(), + updateLocalWebServerProvider: vi.fn(), + updateRemoteWebServerProvider: vi.fn(), + updateServerById: vi.fn(), + updateServerCaddy: vi.fn(), + updateServerTraefik: vi.fn(), + updateWebServerSettings: vi.fn(), + writeConfig: vi.fn(), + writeMainConfig: vi.fn(), + writeTraefikConfigInPath: vi.fn(), + writeTraefikSetup: vi.fn(), + writeWebServerSetup: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + ...serverMocks, + ACCESS_LOG_RETAINED_LINES: 1000, + CLEANUP_CRON_JOB: "cleanup", + DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false }, + IS_CLOUD: false, +})); + +vi.mock("@dokploy/trpc-openapi", () => ({ + generateOpenApiDocument: vi.fn(), +})); + +vi.mock("@/server/api/root", () => ({ + appRouter: {}, +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +import { + checkPortInUse, + compileAndWriteCaddyConfig, + compileWriteAndValidateCaddyConfigSafely, + getCaddyCompileSettings, + getWebServerResourceName, + getWebServerSettings, + parseRawConfig, + paths, + prepareEnvironmentVariables, + processLogs, + readEnvironmentVariables, + readMainConfig, + readMonitoringConfig, + readPorts, + resolveWebServerProvider, + updateWebServerSettings, + writeMainConfig, + writeWebServerSetup, +} from "@dokploy/server"; +import { apiUpdateWebServerSettings } from "@dokploy/server/db/schema"; +import { settingsRouter } from "@/server/api/routers/settings"; +import { audit } from "@/server/api/utils/audit"; + +const caller = settingsRouter.createCaller({ + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + ownerId: "user-1", + email: "owner@example.com", + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + req: { headers: {} }, + res: {}, +} as never); + +const tempRoot = join(tmpdir(), "dokploy-caddy-request-logs-test"); +const caddyAccessLogPath = join(tempRoot, "caddy", "access.log"); + +let requestLogsEnabled = false; + +beforeEach(() => { + vi.clearAllMocks(); + rmSync(tempRoot, { recursive: true, force: true }); + mkdirSync(dirname(caddyAccessLogPath), { recursive: true }); + requestLogsEnabled = false; + vi.mocked(paths).mockReturnValue({ + DYNAMIC_TRAEFIK_PATH: join(tempRoot, "traefik", "dynamic"), + CADDY_ACCESS_LOG_PATH: caddyAccessLogPath, + } as never); + vi.mocked(getWebServerResourceName).mockImplementation((provider) => + provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik", + ); + vi.mocked(getWebServerSettings).mockImplementation( + async () => ({ requestLogsEnabled }) as never, + ); + vi.mocked(updateWebServerSettings).mockImplementation(async (updates) => { + if ("requestLogsEnabled" in updates) { + requestLogsEnabled = Boolean(updates.requestLogsEnabled); + } + return { requestLogsEnabled } as never; + }); + vi.mocked(getCaddyCompileSettings).mockImplementation(async () => ({ + letsEncryptEmail: "ops@example.com", + accessLogs: requestLogsEnabled ? { enabled: true } : null, + })); + vi.mocked(compileAndWriteCaddyConfig).mockResolvedValue({} as never); + vi.mocked(compileWriteAndValidateCaddyConfigSafely).mockResolvedValue( + {} as never, + ); + vi.mocked(checkPortInUse).mockResolvedValue({ isInUse: false } as never); + vi.mocked(readEnvironmentVariables).mockResolvedValue("CADDY_ENV=1"); + vi.mocked(prepareEnvironmentVariables).mockReturnValue(["CADDY_ENV=1"]); + vi.mocked(readPorts).mockResolvedValue([ + { targetPort: 80, publishedPort: 80, protocol: "tcp" }, + ] as never); + vi.mocked(writeWebServerSetup).mockResolvedValue(undefined as never); + vi.mocked(readMainConfig).mockReturnValue("entryPoints: {}\n"); + vi.mocked(readMonitoringConfig).mockResolvedValue("traefik-log\n"); + vi.mocked(parseRawConfig).mockReturnValue({ + data: [], + totalCount: 1, + } as never); + vi.mocked(processLogs).mockReturnValue([ + { hour: "2026-06-02T00:00:00Z", count: 1 }, + ] as never); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +test("keeps Traefik request toggles on the existing Traefik YAML path", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + + await caller.toggleRequests({ enable: true }); + + expect(writeMainConfig).toHaveBeenCalledWith( + expect.stringContaining("/etc/dokploy/traefik/dynamic/access.log"), + ); + expect(compileAndWriteCaddyConfig).not.toHaveBeenCalled(); + expect(compileWriteAndValidateCaddyConfigSafely).not.toHaveBeenCalled(); + expect(audit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ resourceName: "toggle-requests" }), + ); +}); + +test("reports Caddy request analytics state from persisted settings", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + requestLogsEnabled = true; + + const state = await caller.getRequestAnalyticsState(); + + expect(state).toEqual({ + provider: "caddy", + enabled: true, + reloadResourceName: "dokploy-caddy", + accessLogPath: caddyAccessLogPath, + }); + expect(readMainConfig).not.toHaveBeenCalled(); +}); + +test("toggles Caddy request logs without writing Traefik main config", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await caller.toggleRequests({ enable: true }); + + expect(updateWebServerSettings).toHaveBeenCalledWith({ + requestLogsEnabled: true, + }); + expect(getCaddyCompileSettings).toHaveBeenCalledWith(); + expect(compileWriteAndValidateCaddyConfigSafely).toHaveBeenCalledWith({ + letsEncryptEmail: "ops@example.com", + accessLogs: { enabled: true }, + }); + expect(compileAndWriteCaddyConfig).not.toHaveBeenCalled(); + expect(writeMainConfig).not.toHaveBeenCalled(); + expect(requestLogsEnabled).toBe(true); +}); + +test("preserves Caddy request-log settings when rewriting web-server setup", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + requestLogsEnabled = true; + + await caller.writeWebServerEnv({ env: "CADDY_ENV=1" }); + + expect(writeWebServerSetup).toHaveBeenCalledWith( + "caddy", + expect.objectContaining({ + env: ["CADDY_ENV=1"], + additionalPorts: [{ targetPort: 80, publishedPort: 80, protocol: "tcp" }], + letsEncryptEmail: "ops@example.com", + accessLogs: { enabled: true }, + }), + ); + + vi.mocked(writeWebServerSetup).mockClear(); + await caller.updateWebServerPorts({ + additionalPorts: [ + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ], + }); + + expect(checkPortInUse).toHaveBeenCalledWith(9000, undefined); + expect(writeWebServerSetup).toHaveBeenCalledWith( + "caddy", + expect.objectContaining({ + env: ["CADDY_ENV=1"], + additionalPorts: [ + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ], + letsEncryptEmail: "ops@example.com", + accessLogs: { enabled: true }, + }), + ); +}); + +test("restores Caddy request-log setting when generated config write fails", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + requestLogsEnabled = true; + vi.mocked(compileWriteAndValidateCaddyConfigSafely).mockRejectedValueOnce( + new Error("caddy write failed") as never, + ); + + await expect(caller.toggleRequests({ enable: false })).rejects.toThrow( + "caddy write failed", + ); + + expect(updateWebServerSettings).toHaveBeenNthCalledWith(1, { + requestLogsEnabled: false, + }); + expect(updateWebServerSettings).toHaveBeenNthCalledWith(2, { + requestLogsEnabled: true, + }); + expect(requestLogsEnabled).toBe(true); + expect(audit).not.toHaveBeenCalled(); +}); + +test("does not allow generic web-server settings updates to toggle Caddy request logs", () => { + const parsed = apiUpdateWebServerSettings.parse({ + requestLogsEnabled: true, + }); + + expect(parsed).not.toHaveProperty("requestLogsEnabled"); +}); + +test("reads Caddy request stats from the Caddy access-log path", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + writeFileSync(caddyAccessLogPath, '{"caddy":true}\n'); + + const logs = await caller.readStatsLogs({ + page: { pageIndex: 0, pageSize: 10 }, + }); + const stats = await caller.readStats(); + + expect(readMonitoringConfig).not.toHaveBeenCalled(); + expect(parseRawConfig).toHaveBeenCalledWith( + '{"caddy":true}\n', + { pageIndex: 0, pageSize: 10 }, + undefined, + undefined, + undefined, + undefined, + ); + expect(processLogs).toHaveBeenCalledWith('{"caddy":true}\n', undefined); + expect(logs.totalCount).toBe(1); + expect(stats).toEqual([{ hour: "2026-06-02T00:00:00Z", count: 1 }]); +}); + +test("reads the latest Caddy request log entries for paginated stats", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + writeFileSync( + caddyAccessLogPath, + Array.from({ length: 1001 }, (_, index) => + JSON.stringify({ caddy: true, index }), + ).join("\n"), + ); + + await caller.readStatsLogs({ + page: { pageIndex: 0, pageSize: 10 }, + }); + + const rawLog = vi.mocked(parseRawConfig).mock.calls[0]?.[0] as string; + expect(rawLog.split("\n").filter(Boolean)).toHaveLength(1000); + expect(rawLog).not.toContain('"index":0'); + expect(rawLog).toContain('"index":1000'); +}); + +test("bounds Caddy date-range request stats reads to the retained line window", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + writeFileSync( + caddyAccessLogPath, + Array.from({ length: 1001 }, (_, index) => + JSON.stringify({ caddy: true, index }), + ).join("\n"), + ); + + await caller.readStats({ + dateRange: { + start: "2026-06-02T00:00:00.000Z", + end: "2026-06-02T23:59:59.999Z", + }, + }); + + const rawLog = vi.mocked(processLogs).mock.calls[0]?.[0] as string; + expect(rawLog.split("\n").filter(Boolean)).toHaveLength(1000); + expect(rawLog).not.toContain('"index":0'); + expect(rawLog).toContain('"index":1000'); +}); diff --git a/apps/dokploy/__test__/caddy/setup.test.ts b/apps/dokploy/__test__/caddy/setup.test.ts new file mode 100644 index 0000000000..586ea9b805 --- /dev/null +++ b/apps/dokploy/__test__/caddy/setup.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +const validateCaddyConfigWithContainerMock = vi.hoisted(() => vi.fn()); +const ensureDefaultCaddyConfigMock = vi.hoisted(() => vi.fn()); +const getRemoteDockerMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/caddy/config", () => ({ + ensureDefaultCaddyConfig: ensureDefaultCaddyConfigMock, + reloadCaddyAfterValidation: vi.fn(), + validateCaddyConfigWithContainer: validateCaddyConfigWithContainerMock, +})); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const loadCaddySetup = async () => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("CADDY_VERSION", ""); + return import("@dokploy/server/setup/caddy-setup"); +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe("Caddy runtime setup", () => { + test("defaults to the pinned Caddy 2.11.3 image tag", async () => { + const { CADDY_VERSION } = await loadCaddySetup(); + + expect(CADDY_VERSION).toBe("2.11.3"); + }); + + test("uses the pinned default image for standalone Caddy", async () => { + const createContainer = vi.fn(); + const start = vi.fn(); + const remove = vi.fn().mockRejectedValue(new Error("missing")); + const docker = { + pull: vi.fn( + (_imageName: string, callback: (error: Error | null) => void) => { + callback(null); + }, + ), + modem: { followProgress: vi.fn() }, + createContainer, + getContainer: vi.fn(() => ({ remove, start })), + }; + getRemoteDockerMock.mockResolvedValue(docker); + ensureDefaultCaddyConfigMock.mockResolvedValue(undefined); + validateCaddyConfigWithContainerMock.mockResolvedValue(undefined); + const { initializeStandaloneCaddy } = await loadCaddySetup(); + + await initializeStandaloneCaddy(); + + expect(docker.pull).toHaveBeenCalledWith( + "caddy:2.11.3", + expect.any(Function), + ); + expect(createContainer).toHaveBeenCalledWith( + expect.objectContaining({ + Image: "caddy:2.11.3", + HostConfig: expect.objectContaining({ + Binds: expect.arrayContaining([ + expect.stringMatching(/\/caddy:\/etc\/caddy$/), + expect.stringMatching(/\/certificates:.*\/certificates:ro$/), + ]), + }), + }), + ); + const binds = (createContainer.mock.calls[0]?.[0] as any).HostConfig.Binds; + expect(binds).toEqual( + expect.arrayContaining([expect.stringMatching(/\/caddy:\/etc\/caddy$/)]), + ); + expect(binds).not.toEqual( + expect.arrayContaining([ + expect.stringMatching( + /\/caddy\/caddy\.json:\/etc\/caddy\/caddy\.json$/, + ), + ]), + ); + }); + + test("does not publish the Caddy admin port for standalone Caddy", async () => { + const createContainer = vi.fn(); + const start = vi.fn(); + const remove = vi.fn().mockRejectedValue(new Error("missing")); + const docker = { + pull: vi.fn( + (_imageName: string, callback: (error: Error | null) => void) => { + callback(null); + }, + ), + modem: { followProgress: vi.fn() }, + createContainer, + getContainer: vi.fn(() => ({ remove, start })), + }; + getRemoteDockerMock.mockResolvedValue(docker); + ensureDefaultCaddyConfigMock.mockResolvedValue(undefined); + validateCaddyConfigWithContainerMock.mockResolvedValue(undefined); + const { initializeStandaloneCaddy } = await loadCaddySetup(); + + await initializeStandaloneCaddy({ + additionalPorts: [ + { targetPort: 2019, publishedPort: 2019, protocol: "tcp" }, + { targetPort: 8080, publishedPort: 18080, protocol: "tcp" }, + { targetPort: 8082, publishedPort: 18082, protocol: "tcp" }, + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ], + }); + + const createOptions = createContainer.mock.calls[0]?.[0] as any; + expect(createOptions.ExposedPorts).not.toHaveProperty("2019/tcp"); + expect(createOptions.HostConfig.PortBindings).not.toHaveProperty( + "2019/tcp", + ); + expect(createOptions.ExposedPorts).toHaveProperty("8080/tcp"); + expect(createOptions.HostConfig.PortBindings["8080/tcp"]).toEqual([ + { HostPort: "18080" }, + ]); + expect(createOptions.ExposedPorts).toHaveProperty("8082/tcp"); + expect(createOptions.HostConfig.PortBindings["8082/tcp"]).toEqual([ + { HostPort: "18082" }, + ]); + expect(createOptions.ExposedPorts).toHaveProperty("9000/tcp"); + expect(createOptions.HostConfig.PortBindings["9000/tcp"]).toEqual([ + { HostPort: "9000" }, + ]); + }); + + test("passes access-log settings into standalone Caddy config generation", async () => { + const createContainer = vi.fn(); + const start = vi.fn(); + const remove = vi.fn().mockRejectedValue(new Error("missing")); + const docker = { + pull: vi.fn( + (_imageName: string, callback: (error: Error | null) => void) => { + callback(null); + }, + ), + modem: { followProgress: vi.fn() }, + createContainer, + getContainer: vi.fn(() => ({ remove, start })), + }; + getRemoteDockerMock.mockResolvedValue(docker); + ensureDefaultCaddyConfigMock.mockResolvedValue(undefined); + validateCaddyConfigWithContainerMock.mockResolvedValue(undefined); + const { initializeStandaloneCaddy } = await loadCaddySetup(); + + await initializeStandaloneCaddy({ + letsEncryptEmail: "ops@example.com", + accessLogs: { enabled: true }, + }); + + expect(ensureDefaultCaddyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + letsEncryptEmail: "ops@example.com", + accessLogs: { enabled: true }, + }), + ); + }); + + test("does not publish the Caddy admin port for Caddy services", async () => { + const createService = vi.fn(); + const docker = { + pull: vi.fn( + (_imageName: string, callback: (error: Error | null) => void) => { + callback(null); + }, + ), + modem: { followProgress: vi.fn() }, + createService, + getService: vi.fn(() => ({ + inspect: vi.fn().mockRejectedValue(new Error("missing")), + })), + }; + getRemoteDockerMock.mockResolvedValue(docker); + ensureDefaultCaddyConfigMock.mockResolvedValue(undefined); + const { initializeCaddyService } = await loadCaddySetup(); + + await initializeCaddyService({ + accessLogs: { enabled: true }, + additionalPorts: [ + { targetPort: 2019, publishedPort: 2019, protocol: "tcp" }, + { targetPort: 8080, publishedPort: 18080, protocol: "tcp" }, + { targetPort: 8082, publishedPort: 18082, protocol: "tcp" }, + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, + ], + }); + + const createOptions = createService.mock.calls[0]?.[0] as any; + expect(createOptions.TaskTemplate.ContainerSpec.Mounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Type: "bind", + Source: expect.stringMatching(/\/caddy$/), + Target: "/etc/caddy", + }), + ]), + ); + expect(createOptions.EndpointSpec.Ports).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ TargetPort: 2019, Protocol: "tcp" }), + ]), + ); + expect(createOptions.EndpointSpec.Ports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + TargetPort: 8080, + PublishedPort: 18080, + Protocol: "tcp", + }), + expect.objectContaining({ + TargetPort: 8082, + PublishedPort: 18082, + Protocol: "tcp", + }), + expect.objectContaining({ + TargetPort: 9000, + PublishedPort: 9000, + Protocol: "tcp", + }), + ]), + ); + expect(ensureDefaultCaddyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ accessLogs: { enabled: true } }), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts b/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts new file mode 100644 index 0000000000..12aed5ac98 --- /dev/null +++ b/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts @@ -0,0 +1,534 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const serverMocks = vi.hoisted(() => ({ + applyCaddyMigration: vi.fn(), + checkGPUStatus: vi.fn(), + checkPortInUse: vi.fn(), + checkPostgresHealth: vi.fn(), + checkRedisHealth: vi.fn(), + checkWebServerHealth: vi.fn(), + cleanupAll: vi.fn(), + cleanupAllBackground: vi.fn(), + cleanupBuilders: vi.fn(), + cleanupContainers: vi.fn(), + cleanupImages: vi.fn(), + cleanupSystem: vi.fn(), + cleanupVolumes: vi.fn(), + compileWriteAndReloadCaddyConfigSafely: vi.fn(), + execAsync: vi.fn(), + findServerById: vi.fn(), + getCaddyCompileSettings: vi.fn(), + getCaddyMigrationReport: vi.fn(), + getCaddyTrustedProxySettings: vi.fn(), + getDockerDiskUsage: vi.fn(), + getDokployImageTag: vi.fn(), + getLogCleanupStatus: vi.fn(), + getUpdateData: vi.fn(), + getWebServerPaths: vi.fn(), + getWebServerResourceName: vi.fn(), + getWebServerSettings: vi.fn(), + isCaddyAdminAdditionalPort: vi.fn(), + isCaddyReservedAdditionalPort: vi.fn(), + parseRawConfig: vi.fn(), + paths: vi.fn(), + prepareCaddyMigration: vi.fn(), + prepareEnvironmentVariables: vi.fn(), + processLogs: vi.fn(), + readCaddyConfigFileIfExists: vi.fn(), + readConfig: vi.fn(), + readConfigInPath: vi.fn(), + readDirectory: vi.fn(), + readEnvironmentVariables: vi.fn(), + readMainConfig: vi.fn(), + readMonitoringConfig: vi.fn(), + readPorts: vi.fn(), + recreateDirectory: vi.fn(), + reloadCaddyAfterValidation: vi.fn(), + reloadDockerResource: vi.fn(), + resolveWebServerProvider: vi.fn(), + rollbackCaddyMigration: vi.fn(), + sendDockerCleanupNotifications: vi.fn(), + setupGPUSupport: vi.fn(), + spawnAsync: vi.fn(), + startLogCleanup: vi.fn(), + stopLogCleanup: vi.fn(), + updateCaddyTrustedProxySettings: vi.fn(), + updateLetsEncryptEmail: vi.fn(), + updateLocalWebServerProvider: vi.fn(), + updateRemoteWebServerProvider: vi.fn(), + updateServerById: vi.fn(), + updateServerCaddy: vi.fn(), + updateServerTraefik: vi.fn(), + updateWebServerSettings: vi.fn(), + writeConfig: vi.fn(), + writeMainConfig: vi.fn(), + writeTraefikConfigInPath: vi.fn(), + writeTraefikSetup: vi.fn(), + writeWebServerSetup: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + ...serverMocks, + CLEANUP_CRON_JOB: "cleanup", + DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false }, + IS_CLOUD: false, +})); + +vi.mock("@dokploy/trpc-openapi", () => ({ + generateOpenApiDocument: vi.fn(), +})); + +vi.mock("@/server/api/root", () => ({ + appRouter: {}, +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +import { + checkPortInUse, + compileWriteAndReloadCaddyConfigSafely, + findServerById, + getCaddyCompileSettings, + getCaddyTrustedProxySettings, + getWebServerResourceName, + getWebServerSettings, + isCaddyAdminAdditionalPort, + prepareEnvironmentVariables, + readEnvironmentVariables, + readPorts, + resolveWebServerProvider, + updateCaddyTrustedProxySettings, + updateServerCaddy, + updateWebServerSettings, + writeTraefikSetup, + writeWebServerSetup, +} from "@dokploy/server"; +import { settingsRouter } from "@/server/api/routers/settings"; +import { audit } from "@/server/api/utils/audit"; + +const caller = settingsRouter.createCaller({ + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + ownerId: "user-1", + email: "owner@example.com", + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + req: { headers: {} }, + res: {}, +} as never); + +const staticInput = { + mode: "static" as const, + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, +}; + +type TrustedProxySettings = + | typeof staticInput + | { + mode: "cloudflare"; + ranges?: string[] | null; + clientIpHeaders?: string[] | null; + strict?: boolean | null; + } + | null; + +let persistedTrustedProxySettings: TrustedProxySettings; +let trustedProxyCallOrder: string[]; + +const trustedProxyCompileConfig = (settings: TrustedProxySettings) => { + if (!settings) return null; + if (settings.mode === "cloudflare") { + return { + source: "cloudflare" as const, + clientIpHeaders: settings.clientIpHeaders ?? undefined, + strict: settings.strict ?? true, + }; + } + return { + source: "static" as const, + ranges: settings.ranges, + clientIpHeaders: settings.clientIpHeaders, + strict: settings.strict, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + persistedTrustedProxySettings = null; + trustedProxyCallOrder = []; + vi.mocked(getCaddyCompileSettings).mockImplementation(async () => { + trustedProxyCallOrder.push("read-compile-settings"); + return { + letsEncryptEmail: "ops@example.com", + trustedProxies: trustedProxyCompileConfig(persistedTrustedProxySettings), + } as never; + }); + vi.mocked(getCaddyTrustedProxySettings).mockImplementation( + async () => persistedTrustedProxySettings as never, + ); + vi.mocked(updateCaddyTrustedProxySettings).mockImplementation( + async (settings) => { + trustedProxyCallOrder.push("persist-settings"); + persistedTrustedProxySettings = settings as TrustedProxySettings; + return {} as never; + }, + ); + vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockResolvedValue( + undefined as never, + ); + vi.mocked(readEnvironmentVariables).mockResolvedValue(""); + vi.mocked(prepareEnvironmentVariables).mockReturnValue([]); + vi.mocked(getWebServerResourceName).mockImplementation((provider) => + provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik", + ); + vi.mocked(checkPortInUse).mockResolvedValue({ isInUse: false } as never); + vi.mocked(findServerById).mockResolvedValue({ + serverId: "server-1", + organizationId: "org-1", + } as never); + vi.mocked(isCaddyAdminAdditionalPort).mockImplementation( + (port) => port.targetPort === 2019 && (port.protocol ?? "tcp") === "tcp", + ); + vi.mocked(writeWebServerSetup).mockResolvedValue(undefined as never); + vi.mocked(writeTraefikSetup).mockResolvedValue(undefined as never); +}); + +test("persists Caddy trusted proxy settings without rebuilding when Traefik is active", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + + const result = await caller.updateCaddyTrustedProxySettings(staticInput); + + expect(updateCaddyTrustedProxySettings).toHaveBeenCalledTimes(1); + expect(updateCaddyTrustedProxySettings).toHaveBeenCalledWith( + staticInput, + undefined, + ); + expect(compileWriteAndReloadCaddyConfigSafely).not.toHaveBeenCalled(); + expect(audit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ resourceName: "caddy-trusted-proxy" }), + ); + expect(result).toEqual(staticInput); +}); + +test("rebuilds Caddy with persisted compile settings when Caddy is active", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await caller.updateCaddyTrustedProxySettings(staticInput); + + expect(getCaddyCompileSettings).toHaveBeenCalledWith(undefined); + expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({ + serverId: undefined, + letsEncryptEmail: "ops@example.com", + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }, + }); + expect(trustedProxyCallOrder).toEqual([ + "persist-settings", + "read-compile-settings", + ]); + expect(audit).toHaveBeenCalled(); +}); + +test("rebuilds remote Caddy with the remote persisted compile settings", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await caller.updateCaddyTrustedProxySettings({ + ...staticInput, + serverId: "server-1", + }); + + expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1"); + expect(updateCaddyTrustedProxySettings).toHaveBeenCalledWith( + staticInput, + "server-1", + ); + expect(getCaddyCompileSettings).toHaveBeenCalledWith("server-1"); + expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({ + serverId: "server-1", + letsEncryptEmail: "ops@example.com", + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }, + }); +}); + +test("restores previous trusted proxy settings when active Caddy rebuild fails", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + persistedTrustedProxySettings = { + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }; + vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + caller.updateCaddyTrustedProxySettings(staticInput), + ).rejects.toThrow("caddy reload failed"); + + expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith( + 1, + staticInput, + undefined, + ); + expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith( + 2, + { + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }, + undefined, + ); + expect(persistedTrustedProxySettings).toEqual({ + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }); + expect(audit).not.toHaveBeenCalled(); +}); + +test("restores remote trusted proxy settings when remote Caddy rebuild fails", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + persistedTrustedProxySettings = { + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }; + vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockRejectedValueOnce( + new Error("remote caddy reload failed") as never, + ); + + await expect( + caller.updateCaddyTrustedProxySettings({ + ...staticInput, + serverId: "server-1", + }), + ).rejects.toThrow("remote caddy reload failed"); + + expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1"); + expect(getCaddyCompileSettings).toHaveBeenCalledWith("server-1"); + expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({ + serverId: "server-1", + letsEncryptEmail: "ops@example.com", + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }, + }); + expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith( + 1, + staticInput, + "server-1", + ); + expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith( + 2, + { + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }, + "server-1", + ); + expect(persistedTrustedProxySettings).toEqual({ + mode: "cloudflare", + clientIpHeaders: ["CF-Connecting-IP"], + strict: true, + }); + expect(audit).not.toHaveBeenCalled(); +}); + +test("reports Caddy dashboard disabled instead of exposing the Caddy admin API", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + const result = await caller.getWebServerDashboardState({}); + + expect(result).toEqual({ provider: "caddy", enabled: false }); + expect(readPorts).not.toHaveBeenCalled(); +}); + +test("keeps Traefik dashboard state based on the Traefik dashboard port", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + vi.mocked(readPorts).mockResolvedValue([ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ] as never); + + const result = await caller.getWebServerDashboardState({}); + + expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", undefined); + expect(result).toEqual({ provider: "traefik", enabled: true }); +}); + +test("rejects direct dashboard toggles when Caddy is active", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await expect( + caller.toggleDashboard({ enableDashboard: true }), + ).rejects.toThrow("Caddy admin API is kept local-only"); + + expect(readPorts).not.toHaveBeenCalled(); + expect(readEnvironmentVariables).not.toHaveBeenCalled(); + expect(prepareEnvironmentVariables).not.toHaveBeenCalled(); + expect(checkPortInUse).not.toHaveBeenCalled(); + expect(writeTraefikSetup).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); +}); + +test("keeps dashboard toggles available when Traefik is active", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + vi.mocked(readPorts).mockResolvedValue([]); + vi.mocked(readEnvironmentVariables).mockResolvedValue("TRAEFIK_ENV=1"); + vi.mocked(prepareEnvironmentVariables).mockReturnValue(["TRAEFIK_ENV=1"]); + + await caller.toggleDashboard({ enableDashboard: true }); + + expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", undefined); + expect(readEnvironmentVariables).toHaveBeenCalledWith( + "dokploy-traefik", + undefined, + ); + expect(checkPortInUse).toHaveBeenCalledWith(8080, undefined); + expect(writeTraefikSetup).toHaveBeenCalledWith({ + env: ["TRAEFIK_ENV=1"], + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + serverId: undefined, + }); + expect(audit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ resourceName: "toggle-dashboard" }), + ); +}); + +test("restores dashboard settings when active Caddy domain rewrite fails", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + const previousSettings = { + host: "old-dashboard.example.com", + letsEncryptEmail: "old-ops@example.com", + certificateType: "letsencrypt", + https: true, + }; + const updatedSettings = { + ...previousSettings, + host: "new-dashboard.example.com", + letsEncryptEmail: "new-ops@example.com", + }; + vi.mocked(getWebServerSettings).mockResolvedValue(previousSettings as never); + vi.mocked(updateWebServerSettings) + .mockResolvedValueOnce(updatedSettings as never) + .mockResolvedValueOnce(previousSettings as never); + vi.mocked(updateServerCaddy).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + caller.assignDomainServer({ + host: "new-dashboard.example.com", + letsEncryptEmail: "new-ops@example.com", + certificateType: "letsencrypt", + https: true, + }), + ).rejects.toThrow("caddy reload failed"); + + expect(updateWebServerSettings).toHaveBeenNthCalledWith(1, { + host: "new-dashboard.example.com", + letsEncryptEmail: "new-ops@example.com", + certificateType: "letsencrypt", + https: true, + }); + expect(updateWebServerSettings).toHaveBeenNthCalledWith(2, previousSettings); + expect(updateServerCaddy).toHaveBeenCalledWith( + updatedSettings, + "new-dashboard.example.com", + ); + expect(audit).not.toHaveBeenCalled(); +}); + +test("reads remote Traefik dashboard state from the remote web server", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + vi.mocked(readPorts).mockResolvedValue([ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ] as never); + + const result = await caller.getWebServerDashboardState({ + serverId: "server-1", + }); + + expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1"); + expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", "server-1"); + expect(result).toEqual({ provider: "traefik", enabled: true }); +}); + +test("rejects Caddy admin port publishing before rebuilding the web server", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await expect( + caller.updateWebServerPorts({ + additionalPorts: [ + { targetPort: 2019, publishedPort: 2019, protocol: "tcp" }, + ], + }), + ).rejects.toThrow("reserved and cannot be published"); + + expect(checkPortInUse).not.toHaveBeenCalled(); + expect(writeWebServerSetup).not.toHaveBeenCalled(); +}); + +test("allows non-admin Caddy additional ports before rebuilding the web server", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + + await caller.updateWebServerPorts({ + additionalPorts: [ + { targetPort: 8080, publishedPort: 18080, protocol: "tcp" }, + { targetPort: 8082, publishedPort: 18082, protocol: "tcp" }, + ], + }); + + expect(checkPortInUse).toHaveBeenCalledWith(18080, undefined); + expect(checkPortInUse).toHaveBeenCalledWith(18082, undefined); + expect(writeWebServerSetup).toHaveBeenCalledWith( + "caddy", + expect.objectContaining({ + additionalPorts: [ + { targetPort: 8080, publishedPort: 18080, protocol: "tcp" }, + { targetPort: 8082, publishedPort: 18082, protocol: "tcp" }, + ], + serverId: undefined, + }), + ); + expect(audit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ resourceName: "web-server-ports" }), + ); +}); diff --git a/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts b/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts new file mode 100644 index 0000000000..8da82ddd0a --- /dev/null +++ b/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const serverMocks = vi.hoisted(() => ({ + applyCaddyMigration: vi.fn(), + checkGPUStatus: vi.fn(), + checkPortInUse: vi.fn(), + checkPostgresHealth: vi.fn(), + checkRedisHealth: vi.fn(), + checkWebServerHealth: vi.fn(), + cleanupAll: vi.fn(), + cleanupAllBackground: vi.fn(), + cleanupBuilders: vi.fn(), + cleanupContainers: vi.fn(), + cleanupImages: vi.fn(), + cleanupSystem: vi.fn(), + cleanupVolumes: vi.fn(), + compileWriteAndReloadCaddyConfigSafely: vi.fn(), + execAsync: vi.fn(), + findServerById: vi.fn(), + getCaddyCompileSettings: vi.fn(), + getCaddyMigrationReport: vi.fn(), + getCaddyTrustedProxySettings: vi.fn(), + getDockerDiskUsage: vi.fn(), + getDokployImageTag: vi.fn(), + getLogCleanupStatus: vi.fn(), + getUpdateData: vi.fn(), + getWebServerPaths: vi.fn(), + getWebServerResourceName: vi.fn(), + getWebServerSettings: vi.fn(), + isCaddyAdminAdditionalPort: vi.fn(), + isCaddyReservedAdditionalPort: vi.fn(), + parseRawConfig: vi.fn(), + paths: vi.fn(), + prepareCaddyMigration: vi.fn(), + prepareEnvironmentVariables: vi.fn(), + processLogs: vi.fn(), + readCaddyConfigFileIfExists: vi.fn(), + readConfig: vi.fn(), + readConfigInPath: vi.fn(), + readDirectory: vi.fn(), + readEnvironmentVariables: vi.fn(), + readMainConfig: vi.fn(), + readMonitoringConfig: vi.fn(), + readPorts: vi.fn(), + recreateDirectory: vi.fn(), + reloadCaddyAfterValidation: vi.fn(), + reloadDockerResource: vi.fn(), + resolveWebServerProvider: vi.fn(), + rollbackCaddyMigration: vi.fn(), + sendDockerCleanupNotifications: vi.fn(), + setupGPUSupport: vi.fn(), + spawnAsync: vi.fn(), + startLogCleanup: vi.fn(), + stopLogCleanup: vi.fn(), + updateCaddyTrustedProxySettings: vi.fn(), + updateLetsEncryptEmail: vi.fn(), + updateLocalWebServerProvider: vi.fn(), + updateRemoteWebServerProvider: vi.fn(), + updateServerById: vi.fn(), + updateServerCaddy: vi.fn(), + updateServerTraefik: vi.fn(), + updateWebServerSettings: vi.fn(), + writeConfig: vi.fn(), + writeMainConfig: vi.fn(), + writeTraefikConfigInPath: vi.fn(), + writeTraefikSetup: vi.fn(), + writeWebServerSetup: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + ...serverMocks, + CLEANUP_CRON_JOB: "cleanup", + DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false }, + IS_CLOUD: false, +})); + +vi.mock("@dokploy/trpc-openapi", () => ({ + generateOpenApiDocument: vi.fn(), +})); + +vi.mock("@/server/api/root", () => ({ + appRouter: {}, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +import { + findServerById, + getWebServerPaths, + paths, + readConfigInPath, + readDirectory, + resolveWebServerProvider, + writeTraefikConfigInPath, +} from "@dokploy/server"; +import { settingsRouter } from "@/server/api/routers/settings"; +import { audit } from "@/server/api/utils/audit"; + +const caller = settingsRouter.createCaller({ + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + ownerId: "user-1", + email: "owner@example.com", + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + req: { headers: {} }, + res: {}, +} as never); + +const caddyPaths = { + MAIN_CADDY_PATH: "/etc/dokploy/caddy", + CADDY_CONFIG_PATH: "/etc/dokploy/caddy/caddy.json", + CADDY_FRAGMENTS_PATH: "/etc/dokploy/caddy/fragments", + CADDY_MIGRATIONS_PATH: "/etc/dokploy/caddy/migrations", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(findServerById).mockResolvedValue({ + serverId: "server-1", + organizationId: "org-1", + } as never); + vi.mocked(paths).mockReturnValue({ + MAIN_TRAEFIK_PATH: "/etc/dokploy/traefik", + ...caddyPaths, + } as never); + vi.mocked(getWebServerPaths).mockImplementation( + (provider) => + ({ + basePath: + provider === "caddy" + ? caddyPaths.MAIN_CADDY_PATH + : "/etc/dokploy/traefik", + activeConfigPath: + provider === "caddy" + ? caddyPaths.CADDY_CONFIG_PATH + : "/etc/dokploy/traefik/traefik.yml", + fragmentsPath: + provider === "caddy" + ? caddyPaths.CADDY_FRAGMENTS_PATH + : "/etc/dokploy/traefik/dynamic", + }) as never, + ); + vi.mocked(readConfigInPath).mockImplementation( + async (filePath: string) => `contents:${filePath}`, + ); +}); + +test("reads and updates Traefik web-server files through provider-aware endpoints", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik"); + vi.mocked(readDirectory).mockResolvedValue([ + { + id: "/etc/dokploy/traefik/dynamic/app.yml", + name: "app.yml", + type: "file", + }, + ] as never); + + const directories = await caller.readWebServerDirectories({ + serverId: "server-1", + }); + const file = await caller.readWebServerFile({ + serverId: "server-1", + path: "dynamic/app.yml", + }); + const updated = await caller.updateWebServerFile({ + serverId: "server-1", + path: "dynamic/app.yml", + webServerConfig: "http: {}\n", + }); + + expect(directories).toHaveLength(1); + expect(readDirectory).toHaveBeenCalledWith( + "/etc/dokploy/traefik", + "server-1", + ); + expect(readConfigInPath).toHaveBeenCalledWith( + "/etc/dokploy/traefik/dynamic/app.yml", + "server-1", + ); + expect(writeTraefikConfigInPath).toHaveBeenCalledWith( + "/etc/dokploy/traefik/dynamic/app.yml", + "http: {}\n", + "server-1", + ); + expect(audit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ resourceName: "web-server-file" }), + ); + expect(file).toBe("contents:/etc/dokploy/traefik/dynamic/app.yml"); + expect(updated).toBe(true); +}); + +test("limits Caddy web-server file access to safe generated artifacts", async () => { + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + vi.mocked(readDirectory).mockImplementation(async (dirPath: string) => { + if (dirPath === caddyPaths.CADDY_FRAGMENTS_PATH) { + return [ + { + id: `${caddyPaths.CADDY_FRAGMENTS_PATH}/app.json`, + name: "app.json", + type: "file", + }, + ] as never; + } + if (dirPath === caddyPaths.CADDY_MIGRATIONS_PATH) { + return [ + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1`, + name: "caddy-1", + type: "directory", + children: [ + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/caddy.json`, + name: "caddy.json", + type: "file", + }, + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups`, + name: "backups", + type: "directory", + children: [ + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups/traefik.yml`, + name: "traefik.yml", + type: "file", + }, + ], + }, + ], + }, + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/backups`, + name: "backups", + type: "directory", + children: [], + }, + ] as never; + } + return [] as never; + }); + + const directories = await caller.readWebServerDirectories({ + serverId: "server-1", + }); + const file = await caller.readWebServerFile({ + serverId: "server-1", + path: caddyPaths.CADDY_CONFIG_PATH, + }); + + expect(directories).toEqual([ + { + id: caddyPaths.CADDY_CONFIG_PATH, + name: "caddy.json", + type: "file", + }, + { + id: caddyPaths.CADDY_FRAGMENTS_PATH, + name: "fragments", + type: "directory", + children: [ + { + id: `${caddyPaths.CADDY_FRAGMENTS_PATH}/app.json`, + name: "app.json", + type: "file", + }, + ], + }, + { + id: caddyPaths.CADDY_MIGRATIONS_PATH, + name: "migrations", + type: "directory", + children: [ + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1`, + name: "caddy-1", + type: "directory", + children: [ + { + id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/caddy.json`, + name: "caddy.json", + type: "file", + }, + ], + }, + ], + }, + ]); + expect(readConfigInPath).toHaveBeenCalledWith( + caddyPaths.CADDY_CONFIG_PATH, + "server-1", + ); + expect(file).toBe(`contents:${caddyPaths.CADDY_CONFIG_PATH}`); + await expect( + caller.readWebServerFile({ + serverId: "server-1", + path: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups/traefik.yml`, + }), + ).rejects.toThrow("migration backups"); + await expect( + caller.readWebServerFile({ + serverId: "server-1", + path: "/etc/dokploy/traefik/traefik.yml", + }), + ).rejects.toThrow("outside of active web server directory"); + await expect( + caller.updateWebServerFile({ + serverId: "server-1", + path: caddyPaths.CADDY_CONFIG_PATH, + webServerConfig: "{}", + }), + ).rejects.toThrow("read-only"); + expect(writeTraefikConfigInPath).not.toHaveBeenCalled(); +}); 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/__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/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index eda4dace58..562111b723 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -18,7 +18,10 @@ type WebServerSettings = typeof webServerSettings.$inferSelect; const baseSettings: WebServerSettings = { id: "", + webServerProvider: "traefik", + caddyTrustedProxyConfig: null, https: false, + requestLogsEnabled: false, certificateType: "none", host: null, serverIp: null, 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/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/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/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/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/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index cc4f1764a0..0213b27263 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -41,10 +41,13 @@ export type LogEntry = NonNullable< >[0]; export const ShowRequests = () => { - const { data: isActive, refetch } = - api.settings.haveActivateRequests.useQuery(); + const { data: requestAnalyticsState, refetch } = + api.settings.getRequestAnalyticsState.useQuery(); const { mutateAsync: toggleRequests } = api.settings.toggleRequests.useMutation(); + const isActive = requestAnalyticsState?.enabled ?? false; + const providerLabel = + requestAnalyticsState?.provider === "caddy" ? "Caddy" : "Traefik"; const { data: logCleanupStatus } = api.settings.getLogCleanupStatus.useQuery(); @@ -101,13 +104,13 @@ export const ShowRequests = () => { Requests - See all the incoming requests that pass trough Traefik + See incoming requests handled by the active web server. {shouldShowWarning && ( - When you activate, you need to reload traefik to apply the - changes, you can reload traefik in{" "} + After activation, reload {providerLabel} to apply access-log + changes. You can reload the active web server in{" "} {

At the scheduled time, the cleanup job will keep only the last 1000 entries in the access log file - and signal Traefik to reopen its log files. The - default schedule is daily at midnight (0 0 * * *). + for the active web server. The default schedule is + daily at midnight (0 0 * * *).

@@ -174,7 +177,7 @@ export const ShowRequests = () => {
{ await toggleRequests({ enable: !isActive }) @@ -256,8 +259,8 @@ export const ShowRequests = () => {

Activate requests to see incoming traffic statistics and - monitor your application's usage. After activation, you'll - need to reload Traefik for the changes to take effect. + monitor your application's usage. After activation, reload + the active web server for the changes to take effect.

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/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/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.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" && ( +
+ +