import { db, exitNodes, targetHealthCheck } from "@server/db"; import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; import { build } from "@server/build"; import createPathRewriteMiddleware from "./middleware"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; 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 (rewritePathType !== "stripPrefix") { 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}` }; } } // 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}`); } } return { isValid: true }; } export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, generateLoginPageRouters = false ): Promise { // Define extended target type with site information type TargetWithSite = Target & { site: { siteId: number; type: string; subnet: string | null; exitNodeId: number | null; online: boolean; }; }; // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources const resourcesWithTargetsAndSites = await db .select({ // Resource fields resourceId: resources.resourceId, fullDomain: resources.fullDomain, ssl: resources.ssl, http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, stickySession: resources.stickySession, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, ip: targets.ip, method: targets.method, port: targets.port, internalPort: targets.internalPort, hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, rewritePathType: targets.rewritePathType, priority: targets.priority, // Site fields siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, exitNodeId: sites.exitNodeId }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .where( and( eq(targets.enabled, true), eq(resources.enabled, true), or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)), or( ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), config.getRawConfig().traefik.allow_raw_resources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering // Group by resource and include targets with their unique site data const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { 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 || ""; const priority = row.priority ?? 100; // 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, ssl: row.ssl, http: row.http, proxyPort: row.proxyPort, protocol: row.protocol, subdomain: row.subdomain, domainId: row.domainId, enabled: row.enabled, stickySession: row.stickySession, tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, 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 rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, priority: priority // may be null, we fallback later }); } // Add target with its associated site data resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, method: row.method, port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, priority: row.priority, site: { siteId: row.siteId, type: row.siteType, subnet: row.subnet, exitNodeId: row.exitNodeId, online: row.siteOnline } }); }); // make sure we have at least one resource if (resourcesMap.size === 0) { return {}; } const config_output: any = { http: { middlewares: { [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" } } } } }; // get the key and the resource for (const [key, resource] of resourcesMap.entries()) { const targets = resource.targets; const sanatizedKey = sanitizeForMiddlewareName(key); const routerName = `${sanatizedKey}-router`; const serviceName = `${sanatizedKey}-service`; const fullDomain = `${resource.fullDomain}`; const transportName = `${sanatizedKey}-transport`; const headersMiddlewareName = `${sanatizedKey}-headers-middleware`; if (!resource.enabled) { continue; } if (resource.http) { if (!resource.domainId || !resource.fullDomain) { continue; } // 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 = {}; } const domainParts = fullDomain.split("."); let wildCard; if (domainParts.length <= 2) { wildCard = `*.${domainParts.join(".")}`; } else { wildCard = `*.${domainParts.slice(1).join(".")}`; } if (!resource.subdomain) { wildCard = resource.fullDomain; } const configDomain = config.getDomain(resource.domainId); let certResolver: string, preferWildcardCert: boolean; if (!configDomain) { certResolver = config.getRawConfig().traefik.cert_resolver; preferWildcardCert = config.getRawConfig().traefik.prefer_wildcard_cert; } else { certResolver = configDomain.cert_resolver; preferWildcardCert = configDomain.prefer_wildcard_cert; } let tls = {}; if (build == "oss") { tls = { certResolver: certResolver, ...(preferWildcardCert ? { domains: [ { main: wildCard } ] } : {}) }; } const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, ...additionalMiddlewares ]; // Handle path rewriting middleware if (resource.rewritePath && resource.path && resource.pathMatchType && resource.rewritePathType) { // Create a unique middleware name const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${sanitizeForMiddlewareName(key)}`; 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 = {}; } // the middleware to the config Object.assign(config_output.http.middlewares, rewriteResult.middlewares); // middlewares 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.debug(`Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`); } catch (error) { logger.error(`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`); } } // Handle custom headers middleware if (resource.headers || resource.setHostHeader) { const headersObj: { [key: string]: string } = {}; if (resource.headers) { let headersArr: { name: string; value: string }[] = []; try { headersArr = JSON.parse(resource.headers) as { name: string; value: string; }[]; } catch (e) { logger.warn(`Failed to parse headers for resource ${resource.resourceId}: ${e}`); } headersArr.forEach((header) => { headersObj[header.name] = header.value; }); } if (resource.setHostHeader) { headersObj["Host"] = resource.setHostHeader; } if (Object.keys(headersObj).length > 0) { if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[headersMiddlewareName] = { headers: { customRequestHeaders: headersObj } }; routerMiddlewares.push(headersMiddlewareName); } } // Build routing rules let rule = `Host(\`${fullDomain}\`)`; // priority logic let priority: number; if (resource.priority && resource.priority != 100) { priority = resource.priority; } else { priority = 100; if (resource.path && resource.pathMatchType) { priority += 10; if (resource.pathMatchType === "exact") { priority += 5; } else if (resource.pathMatchType === "prefix") { priority += 3; } else if (resource.pathMatchType === "regex") { priority += 2; } if (resource.path === "/") { priority = 1; // lowest for catch-all } } } if (resource.path && resource.pathMatchType) { // priority += 1; // add path to rule based on match type let path = resource.path; // if the path doesn't start with a /, add it if (!path.startsWith("/")) { path = `/${path}`; } if (resource.pathMatchType === "exact") { rule += ` && Path(\`${path}\`)`; } else if (resource.pathMatchType === "prefix") { rule += ` && PathPrefix(\`${path}\`)`; } else if (resource.pathMatchType === "regex") { rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex } } config_output.http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], middlewares: routerMiddlewares, service: serviceName, rule: rule, priority: priority, ...(resource.ssl ? { tls } : {}) }; if (resource.ssl) { config_output.http.routers![routerName + "-redirect"] = { entryPoints: [ config.getRawConfig().traefik.http_entrypoint ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: rule, priority: priority }; } config_output.http.services![serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE // RECEIVE BANDWIDTH ENDPOINT. // TODO: HOW TO HANDLE ^^^^^^ BETTER const anySitesOnline = ( targets as TargetWithSite[] ).some((target: TargetWithSite) => target.site.online); return ( (targets as TargetWithSite[]) .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if ( !target.ip || !target.port || !target.method ) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || !target.site.subnet ) { return false; } } return true; }) .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") { const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; } }) // filter out duplicates .filter( (v, i, a) => a.findIndex( (t) => t && v && t.url === v.url ) === i ) ); })(), ...(resource.stickySession ? { sticky: { cookie: { name: "p_sticky", // TODO: make this configurable via config.yml like other cookies secure: resource.ssl, httpOnly: true } } } : {}) } }; // Add the serversTransport if TLS server name is provided if (resource.tlsServerName) { if (!config_output.http.serversTransports) { config_output.http.serversTransports = {}; } config_output.http.serversTransports![transportName] = { serverName: resource.tlsServerName, //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings // if defined in the static config and here. if not set, self-signed certs won't work insecureSkipVerify: true }; config_output.http.services![ serviceName ].loadBalancer.serversTransport = transportName; } } else { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy || !resource.proxyPort) { continue; } const protocol = resource.protocol.toLowerCase(); const port = resource.proxyPort; if (!port) { continue; } if (!config_output[protocol]) { config_output[protocol] = { routers: {}, services: {} }; } config_output[protocol].routers[routerName] = { entryPoints: [`${protocol}-${port}`], service: serviceName, ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) }; config_output[protocol].services[serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online const anySitesOnline = ( targets as TargetWithSite[] ).some((target: TargetWithSite) => target.site.online); return (targets as TargetWithSite[]) .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if (!target.ip || !target.port) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.site.subnet ) { return false; } } return true; }) .map((target: TargetWithSite) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { return { address: `${target.ip}:${target.port}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; } }); })(), ...(resource.stickySession ? { sticky: { ipStrategy: { depth: 0, sourcePort: true } } } : {}) } }; } } return config_output; } function sanitizePath(path: string | null | undefined): string | undefined { if (!path) return undefined; const trimmed = path.trim(); if (!trimmed) return undefined; // Preserve path structure for rewriting, only warn if very long if (trimmed.length > 1000) { logger.warn(`Path exceeds 1000 characters: ${trimmed.substring(0, 100)}...`); return trimmed.substring(0, 1000); } return trimmed; } function sanitizeForMiddlewareName(str: string): string { // Replace any characters that aren't alphanumeric or dash with dash // and remove consecutive dashes return str.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); }