From 149a4b916b37ff0eb1f5fa496a9f6dccdb2cddcf Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 28 Sep 2025 11:25:11 +0530 Subject: [PATCH] basic setup for rewriting requests to another path --- messages/en-US.json | 6 +- server/db/pg/schema.ts | 4 +- server/db/sqlite/schema.ts | 4 +- server/routers/target/createTarget.ts | 4 +- server/routers/target/listTargets.ts | 4 +- server/routers/target/updateTarget.ts | 4 +- server/routers/traefik/getTraefikConfig.ts | 392 +++++++++++++++--- .../resources/[niceId]/proxy/page.tsx | 284 +++++++++---- 8 files changed, 557 insertions(+), 145 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d9a60837..f4e063ce 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1522,5 +1522,7 @@ "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", - "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here." -} \ No newline at end of file + "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "rewritePath": "Rewrite Path", + "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." +} diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 94e9d98a..18b29f35 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -123,7 +123,9 @@ export const targets = pgTable("targets", { internalPort: integer("internalPort"), enabled: boolean("enabled").notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType") // exact, prefix, regex + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); export const exitNodes = pgTable("exitNodes", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 0fb65d03..e38d6b67 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -135,7 +135,9 @@ export const targets = sqliteTable("targets", { internalPort: integer("internalPort"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType") // exact, prefix, regex + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); export const exitNodes = sqliteTable("exitNodes", { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index fb85f566..6ed9d9aa 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -32,7 +32,9 @@ const createTargetSchema = z port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() // NEW: rewrite path type }) .strict(); diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index ca1159d2..4a1d99a0 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -46,7 +46,9 @@ function queryTargets(resourceId: number) { siteId: targets.siteId, siteType: sites.type, path: targets.path, - pathMatchType: targets.pathMatchType + pathMatchType: targets.pathMatchType, + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 928a1a55..1e47ce96 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -28,7 +28,9 @@ const updateTargetBodySchema = z port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional(), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 4bbb13d3..b713f98a 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -90,6 +90,217 @@ export async function traefikConfigProvider( } } + +function validatePathRewriteConfig( + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): { isValid: boolean; error?: string } { + // If no path matching is configured, no rewriting is possible + if (!path || !pathMatchType) { + if (rewritePath || rewritePathType) { + return { + isValid: false, + error: "Path rewriting requires path matching to be configured" + }; + } + return { isValid: true }; + } + + if ((rewritePath && !rewritePathType) || (!rewritePath && rewritePathType)) { + return { + isValid: false, + error: "Both rewritePath and rewritePathType must be specified together" + }; + } + + if (!rewritePath || !rewritePathType) { + return { isValid: true }; + } + + const validPathMatchTypes = ["exact", "prefix", "regex"]; + if (!validPathMatchTypes.includes(pathMatchType)) { + return { + isValid: false, + error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}` + }; + } + + const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"]; + if (!validRewritePathTypes.includes(rewritePathType)) { + return { + isValid: false, + error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}` + }; + } + + if (pathMatchType === "regex") { + try { + new RegExp(path); + } catch (e) { + return { + isValid: false, + error: `Invalid regex pattern in path: ${path}` + }; + } + } + + if (rewritePathType === "regex") { + // For regex rewrite type, we don't validate the replacement pattern + // as it may contain capture groups like $1, $2, etc. + // The regex engine will handle validation at runtime + } + + // Validate path formats for non-regex types + if (pathMatchType !== "regex" && !path.startsWith("/")) { + return { + isValid: false, + error: "Path must start with '/' for exact and prefix matching" + }; + } + + // Additional validation for stripPrefix + if (rewritePathType === "stripPrefix") { + if (pathMatchType !== "prefix") { + logger.warn(`stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}`); + } + // For stripPrefix, rewritePath is optional (can be empty to just strip) + if (rewritePath && !rewritePath.startsWith("/") && rewritePath !== "") { + return { + isValid: false, + error: "stripPrefix rewritePath must start with '/' or be empty" + }; + } + } + + return { isValid: true }; +} + + +function createPathRewriteMiddleware( + middlewareName: string, + path: string, + pathMatchType: string, + rewritePath: string, + rewritePathType: string +): { [key: string]: any } { + const middlewares: { [key: string]: any } = {}; + + switch (rewritePathType) { + case "exact": + // Replace the entire path with the exact rewrite path + middlewares[middlewareName] = { + replacePathRegex: { + regex: "^.*$", + replacement: rewritePath + } + }; + break; + + case "prefix": + // Replace matched prefix with new prefix, preserve the rest + switch (pathMatchType) { + case "prefix": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}(.*)`, + replacement: `${rewritePath}$1` + } + }; + break; + case "exact": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}$`, + replacement: rewritePath + } + }; + break; + case "regex": + // For regex path matching with prefix rewrite, we assume the regex has capture groups + middlewares[middlewareName] = { + replacePathRegex: { + regex: path, + replacement: rewritePath + } + }; + break; + } + break; + + case "regex": + // Use advanced regex replacement - works with any match type + let regexPattern: string; + if (pathMatchType === "regex") { + regexPattern = path; + } else if (pathMatchType === "prefix") { + regexPattern = `^${escapeRegex(path)}(.*)`; + } else { // exact + regexPattern = `^${escapeRegex(path)}$`; + } + + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: rewritePath + } + }; + break; + + case "stripPrefix": + // Strip the matched prefix and optionally add new path + if (pathMatchType === "prefix") { + middlewares[middlewareName] = { + stripPrefix: { + prefixes: [path] + } + }; + + // If rewritePath is provided and not empty, add it as a prefix after stripping + if (rewritePath && rewritePath !== "" && rewritePath !== "/") { + const addPrefixMiddlewareName = `${middlewareName.replace('-rewrite', '')}-add-prefix-middleware`; + middlewares[addPrefixMiddlewareName] = { + addPrefix: { + prefix: rewritePath + } + }; + // Return both middlewares with a special flag to indicate chaining + return { + middlewares, + chain: [middlewareName, addPrefixMiddlewareName] + }; + } + } else { + // For exact and regex matches, use replacePathRegex to strip + let regexPattern: string; + if (pathMatchType === "exact") { + regexPattern = `^${escapeRegex(path)}$`; + } else if (pathMatchType === "regex") { + regexPattern = path; + } else { + // This shouldn't happen due to earlier validation, but handle gracefully + regexPattern = `^${escapeRegex(path)}`; + } + + const replacement = rewritePath || "/"; + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: replacement + } + }; + } + break; + + default: + logger.error(`Unknown rewritePathType: ${rewritePathType}`); + throw new Error(`Unknown rewritePathType: ${rewritePathType}`); + } + + return { middlewares }; +} + export async function getTraefikConfig( exitNodeId: number, siteTypes: string[] @@ -133,7 +344,8 @@ export async function getTraefikConfig( internalPort: targets.internalPort, path: targets.path, pathMatchType: targets.pathMatchType, - + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType, // Site fields siteId: sites.siteId, siteType: sites.type, @@ -163,12 +375,28 @@ export async function getTraefikConfig( const resourceId = row.resourceId; const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths const pathMatchType = row.pathMatchType || ""; + const rewritePath = row.rewritePath || ""; + const rewritePathType = row.rewritePathType || ""; - // Create a unique key combining resourceId and path+pathMatchType - const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-"); + // Create a unique key combining resourceId, path config, and rewrite config + const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType] + .filter(Boolean) + .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); if (!resourcesMap.has(mapKey)) { + const validation = validatePathRewriteConfig( + row.path, + row.pathMatchType, + row.rewritePath, + row.rewritePathType + ); + + if (!validation.isValid) { + logger.error(`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`); + return; + } + resourcesMap.set(mapKey, { resourceId: row.resourceId, fullDomain: row.fullDomain, @@ -186,7 +414,9 @@ export async function getTraefikConfig( targets: [], headers: row.headers, path: row.path, // the targets will all have the same path - pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType + pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType + rewritePath: row.rewritePath, + rewritePathType: row.rewritePathType }); } @@ -199,6 +429,8 @@ export async function getTraefikConfig( port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, + rewritePath: row.rewritePath, + rewritePathType: row.rewritePathType, site: { siteId: row.siteId, type: row.siteType, @@ -241,19 +473,14 @@ export async function getTraefikConfig( } if (resource.http) { - if (!resource.domainId) { + if (!resource.domainId || !resource.fullDomain) { continue; } - if (!resource.fullDomain) { - continue; - } - - // add routers and services empty objects if they don't exist + // Initialize routers and services if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; } - if (!config_output.http.services) { config_output.http.services = {}; } @@ -288,24 +515,66 @@ export async function getTraefikConfig( certResolver: certResolver, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } + domains: [ + { + main: wildCard + } + ] + } : {}) }; } - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, ...additionalMiddlewares ]; + // Handle path rewriting middleware + if (resource.rewritePath && + resource.path && + resource.pathMatchType && + resource.rewritePathType) { + + const rewriteMiddlewareName = `${resource.id}-${key}-rewrite`; + + try { + const rewriteResult = createPathRewriteMiddleware( + rewriteMiddlewareName, + resource.path, + resource.pathMatchType, + resource.rewritePath, + resource.rewritePathType + ); + + // Initialize middlewares object if it doesn't exist + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // Add the middleware(s) to the config + Object.assign(config_output.http.middlewares, rewriteResult.middlewares); + + // Add middleware(s) to the router middleware chain + if (rewriteResult.chain) { + // For chained middlewares (like stripPrefix + addPrefix) + routerMiddlewares.push(...rewriteResult.chain); + } else { + // Single middleware + routerMiddlewares.push(rewriteMiddlewareName); + } + + logger.info(`Created path rewrite middleware for ${key}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`); + } catch (error) { + logger.error(`Failed to create path rewrite middleware for ${key}: ${error}`); + // Continue without the rewrite middleware rather than failing completely + } + } + + // Handle custom headers middleware if (resource.headers || resource.setHostHeader) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; @@ -406,15 +675,15 @@ export async function getTraefikConfig( return ( (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { - if (!target.enabled) { - return false; - } + .filter((target: TargetWithSite) => { + if (!target.enabled) { + return false; + } // If any sites are online, exclude offline sites - if (anySitesOnline && !target.site.online) { - return false; - } + if (anySitesOnline && !target.site.online) { + return false; + } if ( target.site.type === "local" || @@ -427,33 +696,33 @@ export async function getTraefikConfig( ) { return false; } - } else if (target.site.type === "newt") { + } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || !target.site.subnet ) { - return false; + return false; } } return true; - }) - .map((target: TargetWithSite) => { + }) + .map((target: TargetWithSite) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { - return { - url: `${target.method}://${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { + return { + url: `${target.method}://${target.ip}:${target.port}` + }; + } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; - return { - url: `${target.method}://${ip}:${target.internalPort}` - }; - } - }) + return { + url: `${target.method}://${ip}:${target.internalPort}` + }; + } + }) // filter out duplicates .filter( (v, i, a) => @@ -465,14 +734,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { + sticky: { + cookie: { name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -549,7 +818,7 @@ export async function getTraefikConfig( !target.internalPort || !target.site.subnet ) { - return false; + return false; } } return true; @@ -573,13 +842,13 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -590,10 +859,19 @@ export async function getTraefikConfig( function sanitizePath(path: string | null | undefined): string | undefined { if (!path) return undefined; - // clean any non alphanumeric characters from the path and replace with dashes - // the path cant be too long either, so limit to 50 characters + + // For path rewriting, we need to be more careful about sanitization + // Only limit length and ensure it's a valid path structure if (path.length > 50) { path = path.substring(0, 50); + logger.warn(`Path truncated to 50 characters: ${path}`); } - return path.replace(/[^a-zA-Z0-9]/g, ""); + + // Don't remove special characters as they might be part of regex patterns + // Just ensure it's not empty after trimming + return path.trim() || undefined; } + +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 1232e542..23a55494 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -105,7 +105,9 @@ const addTargetSchema = z.object({ port: z.coerce.number().int().positive(), siteId: z.number().int().positive(), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }).refine( (data) => { // If path is provided, pathMatchType must be provided @@ -138,7 +140,23 @@ const addTargetSchema = z.object({ { message: "Invalid path configuration" } -); +) + .refine( + (data) => { + // If rewritePath is provided, rewritePathType must be provided + if (data.rewritePath && !data.rewritePathType) { + return false; + } + // If rewritePathType is provided, rewritePath must be provided + if (data.rewritePathType && !data.rewritePath) { + return false; + } + return true; + }, + { + message: "Invalid rewrite path configuration" + } + ); const targetsSettingsSchema = z.object({ stickySession: z.boolean() @@ -259,8 +277,10 @@ export default function ReverseProxyTargets(props: { method: resource.http ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null - } + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + } as z.infer }); const watchedIp = addTargetForm.watch("ip"); @@ -436,6 +456,8 @@ export default function ReverseProxyTargets(props: { ...data, path: data.path || null, pathMatchType: data.pathMatchType || null, + rewritePath: data.rewritePath || null, + rewritePathType: data.rewritePathType || null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), @@ -449,7 +471,9 @@ export default function ReverseProxyTargets(props: { method: resource.http ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null + pathMatchType: null, + rewritePath: null, + rewritePathType: null, }); } @@ -469,11 +493,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -494,7 +518,9 @@ export default function ReverseProxyTargets(props: { enabled: target.enabled, siteId: target.siteId, path: target.path, - pathMatchType: target.pathMatchType + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType }; if (target.new) { @@ -590,7 +616,7 @@ export default function ReverseProxyTargets(props: { } }} > - + {t("matchPath")} + + {t("matchPath")} ); } @@ -693,7 +719,7 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -772,31 +798,31 @@ export default function ReverseProxyTargets(props: { }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -856,6 +882,102 @@ export default function ReverseProxyTargets(props: { /> ) }, + { + accessorKey: "rewritePath", + header: t("rewritePath"), + cell: ({ row }) => { + const [showRewritePathInput, setShowRewritePathInput] = useState( + !!(row.original.rewritePath || row.original.rewritePathType) + ); + + if (!showRewritePathInput) { + const noPathMatch = + !row.original.path && !row.original.pathMatchType; + return ( + + ); + } + + return ( +
+ + + + + { + const value = e.target.value.trim(); + if (!value) { + setShowRewritePathInput(false); + updateTarget(row.original.targetId, { + ...row.original, + rewritePath: null, + rewritePathType: null + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + rewritePath: value + }); + } + }} + /> +
+ ); + } + }, // { // accessorKey: "protocol", // header: t('targetProtocol'), @@ -968,21 +1090,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -1048,34 +1170,34 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" + "newt" ? (() => { - const dockerState = - getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })() + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() : null; })()} @@ -1303,12 +1425,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )}