Merge branch 'canary' into 394-ability-to-backup-named-volume-to-s3

This commit is contained in:
Mauricio Siu
2025-07-05 15:40:25 -06:00
23 changed files with 6348 additions and 76 deletions

View File

@@ -51,6 +51,8 @@ export const domains = pgTable("domain", {
{ onDelete: "cascade" },
),
certificateType: certificateType("certificateType").notNull().default("none"),
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -82,6 +84,8 @@ export const apiCreateDomain = createSchema.pick({
serviceName: true,
domainType: true,
previewDeploymentId: true,
internalPath: true,
stripPath: true,
});
export const apiFindDomain = createSchema
@@ -112,5 +116,7 @@ export const apiUpdateDomain = createSchema
customCertResolver: true,
serviceName: true,
domainType: true,
internalPath: true,
stripPath: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -15,6 +15,7 @@ import { backups } from "./backups";
import { projects } from "./project";
import { schedules } from "./schedule";
import { certificateType } from "./shared";
import { paths } from "@dokploy/server/constants";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -236,7 +237,31 @@ export const apiModifyTraefikConfig = z.object({
serverId: z.string().optional(),
});
export const apiReadTraefikConfig = z.object({
path: z.string().min(1),
path: z
.string()
.min(1)
.refine(
(path) => {
// Prevent directory traversal attacks
if (path.includes("../") || path.includes("..\\")) {
return false;
}
const { MAIN_TRAEFIK_PATH } = paths();
if (path.startsWith("/") && !path.startsWith(MAIN_TRAEFIK_PATH)) {
return false;
}
// Prevent null bytes and other dangerous characters
if (path.includes("\0") || path.includes("\x00")) {
return false;
}
return true;
},
{
message:
"Invalid path: path traversal or unauthorized directory access detected",
},
),
serverId: z.string().optional(),
});

View File

@@ -4,6 +4,8 @@ export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
@@ -29,12 +31,37 @@ export const domain = z
message: "Required when certificate type is custom",
});
}
// Validate stripPath requires a valid path
if (input.stripPath && (!input.path || input.path === "/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["stripPath"],
message:
"Strip path can only be enabled when a path other than '/' is specified",
});
}
// Validate internalPath starts with /
if (
input.internalPath &&
input.internalPath !== "/" &&
!input.internalPath.startsWith("/")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["internalPath"],
message: "Internal path must start with '/'",
});
}
});
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
@@ -61,4 +88,27 @@ export const domainCompose = z
message: "Required when certificate type is custom",
});
}
// Validate stripPath requires a valid path
if (input.stripPath && (!input.path || input.path === "/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["stripPath"],
message:
"Strip path can only be enabled when a path other than '/' is specified",
});
}
// Validate internalPath starts with /
if (
input.internalPath &&
input.internalPath !== "/" &&
!input.internalPath.startsWith("/")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["internalPath"],
message: "Internal path must start with '/'",
});
}
});

View File

@@ -301,6 +301,8 @@ export const createDomainLabels = (
certificateType,
path,
customCertResolver,
stripPath,
internalPath,
} = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const labels = [
@@ -310,6 +312,34 @@ export const createDomainLabels = (
`traefik.http.routers.${routerName}.service=${routerName}`,
];
// Validate stripPath - it should only be used when path is defined and not "/"
if (stripPath) {
if (!path || path === "/") {
console.warn(
`stripPath is enabled but path is not defined or is "/" for domain ${host}`,
);
} else {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
labels.push(
`traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`,
);
}
}
// Validate internalPath - ensure it's a valid path format
if (internalPath && internalPath !== "/") {
if (!internalPath.startsWith("/")) {
console.warn(
`internalPath "${internalPath}" should start with "/" and not be empty for domain ${host}`,
);
} else {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
labels.push(
`traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`,
);
}
}
if (entrypoint === "web" && https) {
labels.push(
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,

View File

@@ -1,5 +1,7 @@
import fs, { writeFileSync } from "node:fs";
import path from "node:path";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { dump, load } from "js-yaml";
@@ -137,39 +139,40 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
}
};
export const readMonitoringConfig = (readAll = false) => {
export const readMonitoringConfig = async (readAll = false) => {
const { DYNAMIC_TRAEFIK_PATH } = paths();
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (fs.existsSync(configPath)) {
if (!readAll) {
// Read first 500 lines
// Read first 500 lines using streams
let content = "";
let chunk = "";
let validCount = 0;
for (const char of fs.readFileSync(configPath, "utf8")) {
chunk += char;
if (char === "\n") {
try {
const trimmed = chunk.trim();
if (
trimmed !== "" &&
trimmed.startsWith("{") &&
trimmed.endsWith("}")
) {
const log = JSON.parse(trimmed);
if (log.ServiceName !== "dokploy-service-app@file") {
content += chunk;
validCount++;
if (validCount >= 500) {
break;
}
const fileStream = createReadStream(configPath, { encoding: "utf8" });
const readline = createInterface({
input: fileStream,
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of readline) {
try {
const trimmed = line.trim();
if (
trimmed !== "" &&
trimmed.startsWith("{") &&
trimmed.endsWith("}")
) {
const log = JSON.parse(trimmed);
if (log.ServiceName !== "dokploy-service-app@file") {
content += `${line}\n`;
validCount++;
if (validCount >= 500) {
break;
}
}
} catch {
// Ignore invalid JSON
}
chunk = "";
} catch {
// Ignore invalid JSON
}
}
return content;

View File

@@ -10,6 +10,7 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
const { appName } = app;
@@ -46,6 +47,8 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.services[serviceName] = createServiceConfig(appName, domain);
await createPathMiddlewares(app, domain);
if (app.serverId) {
await writeTraefikConfigRemote(config, appName, app.serverId);
} else {
@@ -80,6 +83,8 @@ export const removeDomain = async (
delete config.http.services[serviceKey];
}
await removePathMiddlewares(application, uniqueKey);
// verify if is the last router if so we delete the router
if (
config?.http?.routers &&
@@ -107,7 +112,8 @@ export const createRouterConfig = async (
const { appName, redirects, security } = app;
const { certificateType } = domain;
const { host, path, https, uniqueConfigKey } = domain;
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
domain;
const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
@@ -115,6 +121,17 @@ export const createRouterConfig = async (
entryPoints: [entryPoint],
};
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
if (entryPoint === "web" && https) {
routerConfig.middlewares = ["redirect-to-https"];
}

View File

@@ -6,6 +6,7 @@ import type { ApplicationNested } from "../builders";
import { execAsyncRemote } from "../process/execAsync";
import { writeTraefikConfigRemote } from "./application";
import type { FileConfig } from "./file-types";
import type { Domain } from "@dokploy/server/services/domain";
export const addMiddleware = (config: FileConfig, middlewareName: string) => {
if (config.http?.routers) {
@@ -105,3 +106,97 @@ export const writeMiddleware = <T>(config: T) => {
const newYamlContent = dump(config);
writeFileSync(configPath, newYamlContent, "utf8");
};
export const createPathMiddlewares = async (
app: ApplicationNested,
domain: Domain,
) => {
let config: FileConfig;
if (app.serverId) {
try {
config = await loadRemoteMiddlewares(app.serverId);
} catch {
config = { http: { middlewares: {} } };
}
} else {
try {
config = loadMiddlewares<FileConfig>();
} catch {
config = { http: { middlewares: {} } };
}
}
const { appName } = app;
const { uniqueConfigKey, internalPath, stripPath, path } = domain;
if (!config.http) {
config.http = { middlewares: {} };
}
if (!config.http.middlewares) {
config.http.middlewares = {};
}
// Add internal path prefix middleware
if (internalPath && internalPath !== "/" && internalPath !== path) {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
config.http.middlewares[middlewareName] = {
addPrefix: {
prefix: internalPath,
},
};
}
// Strip external path middleware if needed
if (stripPath && path && path !== "/") {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
config.http.middlewares[middlewareName] = {
stripPrefix: {
prefixes: [path],
},
};
}
if (app.serverId) {
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
} else {
writeMiddleware(config);
}
};
export const removePathMiddlewares = async (
app: ApplicationNested,
uniqueConfigKey: number,
) => {
let config: FileConfig;
if (app.serverId) {
try {
config = await loadRemoteMiddlewares(app.serverId);
} catch {
return;
}
} else {
try {
config = loadMiddlewares<FileConfig>();
} catch {
return;
}
}
const { appName } = app;
if (config.http?.middlewares) {
const addPrefixMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
const stripPrefixMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
delete config.http.middlewares[addPrefixMiddleware];
delete config.http.middlewares[stripPrefixMiddleware];
}
if (app.serverId) {
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
} else {
writeMiddleware(config);
}
};