import * as plugins from '../../plugins.js'; import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; import type { ILogger } from '../../proxies/http-proxy/models/types.js'; /** * Optional path pattern configuration that can be added to proxy configs */ export interface PathPatternConfig { pathPattern?: string; } /** * Interface for router result with additional metadata */ export interface RouterResult { route: IRouteConfig; pathMatch?: string; pathParams?: Record; pathRemainder?: string; } /** * Router for HTTP reverse proxy requests based on route configurations * * Supports the following domain matching patterns: * - Exact matches: "example.com" * - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com) * - TLD wildcards: "example.*" (matches example.com, example.org, etc.) * - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain) * - Default fallback: "*" (matches any unmatched domain) * * Also supports path pattern matching for each domain: * - Exact path: "/api/users" * - Wildcard paths: "/api/*" * - Path parameters: "/users/:id/profile" */ export class RouteRouter { // Store original routes for reference private routes: IRouteConfig[] = []; // Default route to use when no match is found (optional) private defaultRoute?: IRouteConfig; // Store path patterns separately since they're not in the original interface private pathPatterns: Map = new Map(); // Logger interface private logger: ILogger; constructor( routes?: IRouteConfig[], logger?: ILogger ) { this.logger = logger || { error: console.error, warn: console.warn, info: console.info, debug: console.debug }; if (routes) { this.setRoutes(routes); } } /** * Sets a new set of routes to be routed to * @param routes Array of route configurations */ public setRoutes(routes: IRouteConfig[]): void { this.routes = [...routes]; // Sort routes by priority this.routes.sort((a, b) => { const priorityA = a.priority ?? 0; const priorityB = b.priority ?? 0; return priorityB - priorityA; }); // Find default route if any (route with "*" as domain) this.defaultRoute = this.routes.find(route => { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return domains.includes('*'); }); // Extract path patterns from route match.path for (const route of this.routes) { if (route.match.path) { this.pathPatterns.set(route, route.match.path); } } const uniqueDomains = this.getHostnames(); this.logger.info(`Router initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`); } /** * Routes a request based on hostname and path * @param req The incoming HTTP request * @returns The matching route or undefined if no match found */ public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined { const result = this.routeReqWithDetails(req); return result ? result.route : undefined; } /** * Routes a request with detailed matching information * @param req The incoming HTTP request * @returns Detailed routing result including matched route and path information */ public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined { // Extract and validate host header const originalHost = req.headers.host; if (!originalHost) { this.logger.error('No host header found in request'); return this.defaultRoute ? { route: this.defaultRoute } : undefined; } // Parse URL for path matching const parsedUrl = plugins.url.parse(req.url || '/'); const urlPath = parsedUrl.pathname || '/'; // Extract hostname without port const hostWithoutPort = originalHost.split(':')[0].toLowerCase(); // First try exact hostname match const exactRoute = this.findRouteForHost(hostWithoutPort, urlPath); if (exactRoute) { return exactRoute; } // Try various wildcard patterns if (hostWithoutPort.includes('.')) { const domainParts = hostWithoutPort.split('.'); // Try wildcard subdomain (*.example.com) if (domainParts.length > 2) { const wildcardDomain = `*.${domainParts.slice(1).join('.')}`; const wildcardRoute = this.findRouteForHost(wildcardDomain, urlPath); if (wildcardRoute) { return wildcardRoute; } } // Try TLD wildcard (example.*) const baseDomain = domainParts.slice(0, -1).join('.'); const tldWildcardDomain = `${baseDomain}.*`; const tldWildcardRoute = this.findRouteForHost(tldWildcardDomain, urlPath); if (tldWildcardRoute) { return tldWildcardRoute; } // Try complex wildcard patterns const wildcardPatterns = this.findWildcardMatches(hostWithoutPort); for (const pattern of wildcardPatterns) { const wildcardRoute = this.findRouteForHost(pattern, urlPath); if (wildcardRoute) { return wildcardRoute; } } } // Fall back to default route if available if (this.defaultRoute) { this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`); return { route: this.defaultRoute }; } this.logger.error(`No route found for host: ${hostWithoutPort}`); return undefined; } /** * Find potential wildcard patterns that could match a given hostname * Handles complex patterns like "*.lossless*" or other partial matches * @param hostname The hostname to find wildcard matches for * @returns Array of potential wildcard patterns that could match */ private findWildcardMatches(hostname: string): string[] { const patterns: string[] = []; // Find all routes with wildcard domains for (const route of this.routes) { if (!route.match.domains) continue; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Filter to only wildcard domains const wildcardDomains = domains.filter(domain => domain.includes('*')); // Convert each wildcard domain to a regex pattern and check if it matches for (const domain of wildcardDomains) { // Skip the default wildcard '*' if (domain === '*') continue; // Skip already checked patterns (*.domain.com and domain.*) if (domain.startsWith('*.') && domain.indexOf('*', 2) === -1) continue; if (domain.endsWith('.*') && domain.indexOf('*') === domain.length - 1) continue; // Convert wildcard pattern to regex const regexPattern = domain .replace(/\./g, '\\.') // Escape dots .replace(/\*/g, '.*'); // Convert * to .* for regex // Create regex object with case insensitive flag const regex = new RegExp(`^${regexPattern}$`, 'i'); // If hostname matches this complex pattern, add it to the list if (regex.test(hostname)) { patterns.push(domain); } } } return patterns; } /** * Find a route for a specific host and path */ private findRouteForHost(hostname: string, path: string): RouterResult | undefined { // Find all routes for this hostname const matchingRoutes = this.routes.filter(route => { if (!route.match.domains) return false; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return domains.some(domain => domain.toLowerCase() === hostname.toLowerCase()); }); if (matchingRoutes.length === 0) { return undefined; } // First try routes with path patterns const routesWithPaths = matchingRoutes.filter(route => this.pathPatterns.has(route)); // Already sorted by priority during setRoutes // Check each route with path pattern for (const route of routesWithPaths) { const pathPattern = this.pathPatterns.get(route); if (pathPattern) { const pathMatch = this.matchPath(path, pathPattern); if (pathMatch) { return { route, pathMatch: pathMatch.matched, pathParams: pathMatch.params, pathRemainder: pathMatch.remainder }; } } } // If no path pattern matched, use the first route without a path pattern const routeWithoutPath = matchingRoutes.find(route => !this.pathPatterns.has(route)); if (routeWithoutPath) { return { route: routeWithoutPath }; } return undefined; } /** * Matches a URL path against a pattern * Supports: * - Exact matches: /users/profile * - Wildcards: /api/* (matches any path starting with /api/) * - Path parameters: /users/:id (captures id as a parameter) * * @param path The URL path to match * @param pattern The pattern to match against * @returns Match result with params and remainder, or null if no match */ private matchPath(path: string, pattern: string): { matched: string; params: Record; remainder: string; } | null { // Handle exact match if (path === pattern) { return { matched: pattern, params: {}, remainder: '' }; } // Handle wildcard match if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -2); if (path === prefix || path.startsWith(`${prefix}/`)) { return { matched: prefix, params: {}, remainder: path.slice(prefix.length) }; } return null; } // Handle path parameters const patternParts = pattern.split('/').filter(p => p); const pathParts = path.split('/').filter(p => p); // Too few path parts to match if (pathParts.length < patternParts.length) { return null; } const params: Record = {}; // Compare each part for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i]; const pathPart = pathParts[i]; // Handle parameter if (patternPart.startsWith(':')) { const paramName = patternPart.slice(1); params[paramName] = pathPart; continue; } // Handle wildcard at the end if (patternPart === '*' && i === patternParts.length - 1) { break; } // Handle exact match for this part if (patternPart !== pathPart) { return null; } } // Calculate the remainder - the unmatched path parts const remainderParts = pathParts.slice(patternParts.length); const remainder = remainderParts.length ? '/' + remainderParts.join('/') : ''; // Calculate the matched path const matchedParts = patternParts.map((part, i) => { return part.startsWith(':') ? pathParts[i] : part; }); const matched = '/' + matchedParts.join('/'); return { matched, params, remainder }; } /** * Gets all currently active route configurations * @returns Array of all active routes */ public getRoutes(): IRouteConfig[] { return [...this.routes]; } /** * Gets all hostnames that this router is configured to handle * @returns Array of hostnames */ public getHostnames(): string[] { const hostnames = new Set(); for (const route of this.routes) { if (!route.match.domains) continue; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; for (const domain of domains) { if (domain !== '*') { hostnames.add(domain.toLowerCase()); } } } return Array.from(hostnames); } /** * Adds a single new route configuration * @param route The route configuration to add */ public addRoute(route: IRouteConfig): void { this.routes.push(route); // Store path pattern if present if (route.match.path) { this.pathPatterns.set(route, route.match.path); } // Re-sort routes by priority this.routes.sort((a, b) => { const priorityA = a.priority ?? 0; const priorityB = b.priority ?? 0; return priorityB - priorityA; }); } /** * Removes routes by domain pattern * @param domain The domain pattern to remove routes for * @returns Boolean indicating whether any routes were removed */ public removeRoutesByDomain(domain: string): boolean { const initialCount = this.routes.length; // Find routes to remove const routesToRemove = this.routes.filter(route => { if (!route.match.domains) return false; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return domains.includes(domain); }); // Remove them from the patterns map for (const route of routesToRemove) { this.pathPatterns.delete(route); } // Filter them out of the routes array this.routes = this.routes.filter(route => { if (!route.match.domains) return true; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return !domains.includes(domain); }); return this.routes.length !== initialCount; } /** * Legacy method for compatibility with ProxyRouter * Converts IReverseProxyConfig to IRouteConfig and calls setRoutes * * @param configs Array of legacy proxy configurations */ public setNewProxyConfigs(configs: any[]): void { // Convert legacy configs to routes and add them const routes: IRouteConfig[] = configs.map(config => { // Create a basic route configuration from the legacy config return { match: { ports: config.destinationPorts[0], // Just use the first port domains: config.hostName }, action: { type: 'forward', target: { host: config.destinationIps, port: config.destinationPorts[0] }, tls: { mode: 'terminate', certificate: { key: config.privateKey, cert: config.publicKey } } }, name: `Legacy Config - ${config.hostName}`, enabled: true }; }); this.setRoutes(routes); } }