mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(traefik): restrict default Dokploy route
This commit is contained in:
parent
f12ecc3350
commit
e9c2af4cce
@ -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", () => {
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -206,6 +206,9 @@ export type HttpMiddleware =
|
||||
| {
|
||||
headers?: HeadersMiddleware;
|
||||
}
|
||||
| {
|
||||
ipAllowList?: IpWhiteListMiddleware;
|
||||
}
|
||||
| {
|
||||
ipWhiteList?: IpWhiteListMiddleware;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user