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 ba09c2c80..1ce690161 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -82,6 +82,279 @@ test("Should read the configuration file", () => { expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( "dokploy-service-app", ); + expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toEqual([ + "dokploy-local-access", + ]); + expect(config.http?.middlewares?.["dokploy-local-access"]).toEqual({ + ipAllowList: { + sourceRange: [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ], + }, + }); +}); + +test("Should migrate existing configuration with the local-access allowlist", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + fs.writeFileSync( + ".docker/traefik/dynamic/dokploy.yml", + `http: + routers: + dokploy-router-app: + rule: Host(\`dokploy.docker.localhost\`) && PathPrefix(\`/\`) + service: dokploy-service-app + entryPoints: + - web + custom-router: + rule: Host(\`custom.example.com\`) + service: custom-service + middlewares: + custom-middleware: + headers: + customRequestHeaders: + X-Test: preserved + services: + dokploy-service-app: + loadBalancer: + servers: + - url: http://dokploy:3000 + custom-service: + loadBalancer: + servers: + - url: http://custom:3000 +`, + ); + + createDefaultServerTraefikConfig(); + + const config: FileConfig = loadOrCreateConfig("dokploy"); + expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toEqual([ + "dokploy-local-access", + ]); + expect(config.http?.middlewares?.["dokploy-local-access"]).toEqual({ + ipAllowList: { + sourceRange: [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ], + }, + }); + expect(config.http?.routers?.["custom-router"]?.service).toBe( + "custom-service", + ); + expect(config.http?.middlewares?.["custom-middleware"]).toBeDefined(); +}); + +test("Should reconcile an ineffective local-access middleware", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + fs.writeFileSync( + ".docker/traefik/dynamic/dokploy.yml", + `http: + routers: + dokploy-router-app: + rule: Host(\`dokploy.docker.localhost\`) && PathPrefix(\`/\`) + service: dokploy-service-app + entryPoints: + - web + middlewares: + - dokploy-local-access + middlewares: + dokploy-local-access: + headers: + customRequestHeaders: + X-Test: ineffective +`, + ); + + createDefaultServerTraefikConfig(); + + const config: FileConfig = loadOrCreateConfig("dokploy"); + expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toEqual([ + "dokploy-local-access", + ]); + expect(config.http?.middlewares?.["dokploy-local-access"]).toEqual({ + ipAllowList: { + sourceRange: [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ], + }, + }); +}); + +test("Should reconcile the fallback host configured through server settings", () => { + updateServerTraefik( + { + ...baseSettings, + https: true, + certificateType: "letsencrypt", + }, + "dokploy.docker.localhost", + ); + + createDefaultServerTraefikConfig(); + + const config: FileConfig = loadOrCreateConfig("dokploy"); + expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toEqual([ + "redirect-to-https", + "dokploy-local-access", + ]); + expect( + config.http?.routers?.["dokploy-router-app-secure"]?.middlewares, + ).toEqual(["dokploy-local-access"]); +}); + +test("Should not migrate the local-access allowlist for a custom domain", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + fs.writeFileSync( + ".docker/traefik/dynamic/dokploy.yml", + `http: + routers: + dokploy-router-app: + rule: Host(\`dash.example.com\`) + service: dokploy-service-app + entryPoints: + - web + middlewares: + - redirect-to-https + services: + dokploy-service-app: + loadBalancer: + servers: + - url: http://dokploy:3000 +`, + ); + + createDefaultServerTraefikConfig(); + + const config: FileConfig = loadOrCreateConfig("dokploy"); + expect(config.http?.routers?.["dokploy-router-app"]).toEqual({ + rule: "Host(`dash.example.com`)", + service: "dokploy-service-app", + entryPoints: ["web"], + middlewares: ["redirect-to-https"], + }); + expect(config.http?.middlewares?.["dokploy-local-access"]).toBeUndefined(); +}); + +test("Should skip malformed default traefik configuration", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + const malformedConfig = "http:\n routers: [\n"; + fs.writeFileSync(".docker/traefik/dynamic/dokploy.yml", malformedConfig); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.readFileSync(".docker/traefik/dynamic/dokploy.yml", "utf8")).toBe( + malformedConfig, + ); +}); + +test("Should skip a configuration without HTTP routers", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + const configWithoutHttp = "tcp:\n routers: {}\n"; + fs.writeFileSync(".docker/traefik/dynamic/dokploy.yml", configWithoutHttp); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.readFileSync(".docker/traefik/dynamic/dokploy.yml", "utf8")).toBe( + configWithoutHttp, + ); +}); + +test("Should skip a configuration with HTTP but no routers", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + const configWithoutRouters = "http:\n services: {}\n"; + fs.writeFileSync(".docker/traefik/dynamic/dokploy.yml", configWithoutRouters); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.readFileSync(".docker/traefik/dynamic/dokploy.yml", "utf8")).toBe( + configWithoutRouters, + ); +}); + +test("Should migrate a default router without an HTTP middleware map", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + fs.writeFileSync( + ".docker/traefik/dynamic/dokploy.yml", + `http: + routers: + dokploy-router-app: + rule: Host(\`dokploy.docker.localhost\`) && PathPrefix(\`/\`) + service: dokploy-service-app + entryPoints: + - web +`, + ); + + createDefaultServerTraefikConfig(); + + const config: FileConfig = loadOrCreateConfig("dokploy"); + expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toEqual([ + "dokploy-local-access", + ]); + expect(config.http?.middlewares?.["dokploy-local-access"]).toBeDefined(); +}); + +test("Should skip a configuration with a malformed HTTP middleware map", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + const malformedMiddlewares = `http: + routers: + dokploy-router-app: + rule: Host(\`dokploy.docker.localhost\`) && PathPrefix(\`/\`) + service: dokploy-service-app + entryPoints: + - web + middlewares: redirect-to-https +`; + fs.writeFileSync(".docker/traefik/dynamic/dokploy.yml", malformedMiddlewares); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.readFileSync(".docker/traefik/dynamic/dokploy.yml", "utf8")).toBe( + malformedMiddlewares, + ); +}); + +test("Should skip a default router with malformed middlewares", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic", { recursive: true }); + const malformedMiddlewares = `http: + routers: + dokploy-router-app: + rule: Host(\`dokploy.docker.localhost\`) && PathPrefix(\`/\`) + service: dokploy-service-app + entryPoints: + - web + middlewares: redirect-to-https +`; + fs.writeFileSync(".docker/traefik/dynamic/dokploy.yml", malformedMiddlewares); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.readFileSync(".docker/traefik/dynamic/dokploy.yml", "utf8")).toBe( + malformedMiddlewares, + ); +}); + +test("Should skip a default traefik configuration path that is a directory", () => { + vol.reset(); + fs.mkdirSync(".docker/traefik/dynamic/dokploy.yml", { recursive: true }); + + expect(() => createDefaultServerTraefikConfig()).not.toThrow(); + expect(fs.statSync(".docker/traefik/dynamic/dokploy.yml").isDirectory()).toBe( + true, + ); }); test("Should apply redirect-to-https", () => { diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 17a9a284e..edffba38c 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -2,16 +2,22 @@ import { chmodSync, existsSync, mkdirSync, + readFileSync, + renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; import path from "node:path"; import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode"; -import { stringify } from "yaml"; +import { parse, stringify } from "yaml"; import { paths } from "../constants"; import { getRemoteDocker } from "../utils/servers/remote-docker"; -import type { FileConfig } from "../utils/traefik/file-types"; +import type { + FileConfig, + HttpMiddleware, + HttpRouter, +} from "../utils/traefik/file-types"; import type { MainTraefikConfig } from "../utils/traefik/types"; export const TRAEFIK_SSL_PORT = @@ -32,6 +38,9 @@ export interface TraefikOptions { }[]; } +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + export const initializeStandaloneTraefik = async ({ env, serverId, @@ -213,22 +222,37 @@ export const initializeTraefikService = async ({ export const createDefaultServerTraefikConfig = () => { const { DYNAMIC_TRAEFIK_PATH } = paths(); const configFilePath = path.join(DYNAMIC_TRAEFIK_PATH, "dokploy.yml"); - - if (existsSync(configFilePath)) { - console.log("Default traefik config already exists"); - return; - } - const appName = "dokploy"; + const routerName = `${appName}-router-app`; + const middlewareName = `${appName}-local-access`; + const defaultRule = `Host(\`${appName}.docker.localhost\`) && PathPrefix(\`/\`)`; + const fallbackHostRule = `Host(\`${appName}.docker.localhost\`)`; + const isFallbackRule = (rule: unknown) => + rule === defaultRule || rule === fallbackHostRule; const serviceURLDefault = `http://${appName}:${process.env.PORT || 3000}`; + const defaultRouter: HttpRouter = { + rule: defaultRule, + service: `${appName}-service-app`, + entryPoints: ["web"], + middlewares: [middlewareName], + }; + const localAccessMiddleware: HttpMiddleware = { + ipAllowList: { + sourceRange: [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ], + }, + }; const config: FileConfig = { http: { routers: { - [`${appName}-router-app`]: { - rule: `Host(\`${appName}.docker.localhost\`) && PathPrefix(\`/\`)`, - service: `${appName}-service-app`, - entryPoints: ["web"], - }, + [routerName]: defaultRouter, + }, + middlewares: { + [middlewareName]: localAccessMiddleware, }, services: { [`${appName}-service-app`]: { @@ -241,13 +265,132 @@ export const createDefaultServerTraefikConfig = () => { }, }; + if (existsSync(configFilePath)) { + let existingConfig: FileConfig; + try { + if (!statSync(configFilePath).isFile()) { + console.error( + `Default traefik config path is not a file: ${configFilePath}; migration skipped`, + ); + return; + } + const parsedConfig = parse(readFileSync(configFilePath, "utf8")); + if (!isObject(parsedConfig)) { + console.error( + `Default traefik config at ${configFilePath} is not a YAML object; migration skipped`, + ); + return; + } + existingConfig = parsedConfig as FileConfig; + } catch (error) { + console.error( + `Default traefik config at ${configFilePath} is unreadable or unparseable; migration skipped`, + error, + ); + // Do not overwrite an unreadable or unparseable config and risk losing operator changes. + return; + } + + if ( + !isObject(existingConfig.http) || + !isObject(existingConfig.http.routers) + ) { + console.error( + `Default traefik config at ${configFilePath} has no HTTP routers; migration skipped`, + ); + return; + } + if ( + existingConfig.http.middlewares !== undefined && + !isObject(existingConfig.http.middlewares) + ) { + console.error( + `Default traefik config at ${configFilePath} has invalid HTTP middlewares; migration skipped`, + ); + return; + } + + const existingHttp = existingConfig.http; + const existingRouters = existingHttp.routers as Record; + const existingRouter = existingRouters[routerName]; + if (!isObject(existingRouter)) { + console.error( + `Default router not found in ${configFilePath}; migration skipped`, + ); + return; + } + if ( + existingRouter.middlewares !== undefined && + !Array.isArray(existingRouter.middlewares) + ) { + console.error( + `Default router in ${configFilePath} has invalid middlewares; migration skipped`, + ); + return; + } + + if (!isFallbackRule(existingRouter.rule)) { + console.log( + "Custom domain detected on dokploy-router-app, skipping local-access migration", + ); + return; + } + + existingHttp.middlewares = existingHttp.middlewares || {}; + existingRouters[routerName] = { + ...defaultRouter, + ...existingRouter, + middlewares: existingRouter.middlewares?.includes(middlewareName) + ? existingRouter.middlewares + : [...(existingRouter.middlewares || []), middlewareName], + }; + existingHttp.middlewares[middlewareName] = localAccessMiddleware; + + const secureRouterName = `${routerName}-secure`; + const existingSecureRouter = existingRouters[secureRouterName]; + if ( + isObject(existingSecureRouter) && + isFallbackRule(existingSecureRouter.rule) && + (existingSecureRouter.middlewares === undefined || + Array.isArray(existingSecureRouter.middlewares)) + ) { + existingRouters[secureRouterName] = { + ...existingSecureRouter, + middlewares: existingSecureRouter.middlewares?.includes(middlewareName) + ? existingSecureRouter.middlewares + : [...(existingSecureRouter.middlewares || []), middlewareName], + }; + } + + console.log( + "Migrating default traefik config to add local-access allowlist", + ); + const temporaryConfigFilePath = `${configFilePath}.tmp`; + try { + writeFileSync(temporaryConfigFilePath, stringify(existingConfig), "utf8"); + renameSync(temporaryConfigFilePath, configFilePath); + } catch (error) { + console.error( + `Unable to write migrated default traefik config at ${configFilePath}; migration skipped`, + error, + ); + // Write to a temporary file first so a failed migration cannot truncate the original config. + } + // Callers invoke this synchronously; setup is a one-shot provisioning command before server bootstrap. + return; + } + const yamlStr = stringify(config); - mkdirSync(DYNAMIC_TRAEFIK_PATH, { recursive: true }); - writeFileSync( - path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`), - yamlStr, - "utf8", - ); + try { + mkdirSync(DYNAMIC_TRAEFIK_PATH, { recursive: true }); + writeFileSync(configFilePath, yamlStr, "utf8"); + } catch (error) { + console.error( + `Unable to create default traefik config at ${configFilePath}`, + error, + ); + // A filesystem error must not turn a missing default route into a startup outage. + } }; export const getDefaultTraefikConfig = () => { diff --git a/packages/server/src/utils/traefik/file-types.ts b/packages/server/src/utils/traefik/file-types.ts index f9149c2cc..937659590 100644 --- a/packages/server/src/utils/traefik/file-types.ts +++ b/packages/server/src/utils/traefik/file-types.ts @@ -206,6 +206,9 @@ export type HttpMiddleware = | { headers?: HeadersMiddleware; } + | { + ipAllowList?: IpWhiteListMiddleware; + } | { ipWhiteList?: IpWhiteListMiddleware; }