fix(traefik): restrict default Dokploy route

This commit is contained in:
9MidhunPM 2026-08-12 09:21:41 +05:30
parent f12ecc3350
commit e9c2af4cce
3 changed files with 438 additions and 19 deletions

View File

@ -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", () => {

View File

@ -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<string, unknown> =>
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<string, HttpRouter>;
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 = () => {

View File

@ -206,6 +206,9 @@ export type HttpMiddleware =
| {
headers?: HeadersMiddleware;
}
| {
ipAllowList?: IpWhiteListMiddleware;
}
| {
ipWhiteList?: IpWhiteListMiddleware;
}