This commit is contained in:
Jhonatan Caldeira
2025-06-22 20:35:56 +00:00
committed by GitHub
11 changed files with 6103 additions and 1 deletions

View File

@@ -19,6 +19,8 @@ describe("createDomainLabels", () => {
path: "/", path: "/",
createdAt: "", createdAt: "",
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/",
stripPath: false,
}; };
it("should create basic labels for web entrypoint", async () => { it("should create basic labels for web entrypoint", async () => {

View File

@@ -119,6 +119,8 @@ const baseDomain: Domain = {
domainType: "application", domainType: "application",
uniqueConfigKey: 1, uniqueConfigKey: 1,
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/",
stripPath: false,
}; };
const baseRedirect: Redirect = { const baseRedirect: Redirect = {

View File

@@ -49,6 +49,8 @@ export const domain = z
.object({ .object({
host: z.string().min(1, { message: "Add a hostname" }), host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(), path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z port: z
.number() .number()
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
@@ -84,6 +86,24 @@ export const domain = z
message: "Required", message: "Required",
}); });
} }
// 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 '/'",
});
}
}); });
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -162,6 +182,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
defaultValues: { defaultValues: {
host: "", host: "",
path: undefined, path: undefined,
internalPath: undefined,
stripPath: false,
port: undefined, port: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
@@ -182,6 +204,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
...data, ...data,
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined, port: data?.port || undefined,
certificateType: data?.certificateType || undefined, certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined, customCertResolver: data?.customCertResolver || undefined,
@@ -194,6 +218,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
form.reset({ form.reset({
host: "", host: "",
path: undefined, path: undefined,
internalPath: undefined,
stripPath: false,
port: undefined, port: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
@@ -469,6 +495,49 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}} }}
/> />
<FormField
control={form.control}
name="internalPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Internal Path</FormLabel>
<FormDescription>
The path where your application expects to receive
requests internally (defaults to "/")
</FormDescription>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="stripPath"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Strip Path</FormLabel>
<FormDescription>
Remove the external path from the request before
forwarding to the application
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="port" name="port"

View File

@@ -0,0 +1,2 @@
ALTER TABLE "domain" ADD COLUMN "internalPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "stripPath" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -687,6 +687,13 @@
"when": 1750567641441, "when": 1750567641441,
"tag": "0097_hard_lizard", "tag": "0097_hard_lizard",
"breakpoints": true "breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1750570751159,
"tag": "0098_warm_tiger_shark",
"breakpoints": true
} }
] ]
} }

View File

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

View File

@@ -4,6 +4,8 @@ export const domain = z
.object({ .object({
host: z.string().min(1, { message: "Add a hostname" }), host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(), path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z port: z
.number() .number()
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
@@ -29,12 +31,32 @@ export const domain = z
message: "Required when certificate type is custom", 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 export const domainCompose = z
.object({ .object({
host: z.string().min(1, { message: "Host is required" }), host: z.string().min(1, { message: "Host is required" }),
path: z.string().min(1).optional(), path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z port: z
.number() .number()
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
@@ -61,4 +83,22 @@ export const domainCompose = z
message: "Required when certificate type is custom", 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, certificateType,
path, path,
customCertResolver, customCertResolver,
stripPath,
internalPath
} = domain; } = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const labels = [ const labels = [
@@ -310,6 +312,26 @@ export const createDomainLabels = (
`traefik.http.routers.${routerName}.service=${routerName}`, `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) { if (entrypoint === "web" && https) {
labels.push( labels.push(
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`, `traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,

View File

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

View File

@@ -6,6 +6,7 @@ import type { ApplicationNested } from "../builders";
import { execAsyncRemote } from "../process/execAsync"; import { execAsyncRemote } from "../process/execAsync";
import { writeTraefikConfigRemote } from "./application"; import { writeTraefikConfigRemote } from "./application";
import type { FileConfig } from "./file-types"; import type { FileConfig } from "./file-types";
import type { Domain } from "@dokploy/server/services/domain";
export const addMiddleware = (config: FileConfig, middlewareName: string) => { export const addMiddleware = (config: FileConfig, middlewareName: string) => {
if (config.http?.routers) { if (config.http?.routers) {
@@ -105,3 +106,97 @@ export const writeMiddleware = <T>(config: T) => {
const newYamlContent = dump(config); const newYamlContent = dump(config);
writeFileSync(configPath, newYamlContent, "utf8"); 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);
}
};