import * as plugins from '../../plugins.js'; import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; import type { IReverseProxyConfig } from '../../proxies/http-proxy/models/types.js'; import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js'; /** * Interface for router result with additional metadata */ export interface RouterResult { route: IRouteConfig; pathMatch?: string; pathParams?: Record; pathRemainder?: string; } /** * Legacy interface for backward compatibility */ export interface LegacyRouterResult { config: IReverseProxyConfig; pathMatch?: string; pathParams?: Record; pathRemainder?: string; } /** * Logger interface for HttpRouter */ export interface ILogger { debug?: (message: string, data?: any) => void; info: (message: string, data?: any) => void; warn: (message: string, data?: any) => void; error: (message: string, data?: any) => void; } /** * Unified HTTP Router for reverse proxy requests * * Supports both modern IRouteConfig and legacy IReverseProxyConfig formats * * 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) * * Path pattern matching: * - Exact path: "/api/users" * - Wildcard paths: "/api/*" * - Path parameters: "/users/:id/profile" */ export class HttpRouter { // Store routes sorted by priority private routes: IRouteConfig[] = []; // Default route to use when no match is found (optional) private defaultRoute?: IRouteConfig; // Logger interface private logger: ILogger; constructor( routes?: IRouteConfig[], logger?: ILogger ) { this.logger = logger || { error: console.error.bind(console), warn: console.warn.bind(console), info: console.info.bind(console), debug: console.debug?.bind(console) }; if (routes) { this.setRoutes(routes); } } /** * Sets a new set of routes * @param routes Array of route configurations */ public setRoutes(routes: IRouteConfig[]): void { this.routes = [...routes]; // Sort routes by priority (higher priority first) 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 ? [route.match.domains] : []; return domains.includes('*'); }); const uniqueDomains = this.getHostnames(); this.logger.info(`HttpRouter 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(); // Find matching route const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath); if (matchingRoute) { return matchingRoute; } // 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 the best matching route for a given hostname and path */ private findMatchingRoute(hostname: string, path: string): RouterResult | undefined { // Try each route in priority order for (const route of this.routes) { // Skip disabled routes if (route.enabled === false) { continue; } // Check domain match if (route.match.domains) { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Check if any domain pattern matches const domainMatches = domains.some(domain => DomainMatcher.match(domain, hostname) ); if (!domainMatches) { continue; } } // Check path match if specified if (route.match.path) { const pathResult = PathMatcher.match(route.match.path, path); if (pathResult.matches) { return { route, pathMatch: path, pathParams: pathResult.params, pathRemainder: pathResult.pathRemainder }; } } else { // No path specified, so domain match is sufficient return { route }; } } return undefined; } /** * 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 unique 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); // 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; // Filter out routes that match the domain 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; } /** * Remove a specific route by reference * @param route The route to remove * @returns Boolean indicating if the route was found and removed */ public removeRoute(route: IRouteConfig): boolean { const index = this.routes.indexOf(route); if (index !== -1) { this.routes.splice(index, 1); return true; } return false; } // ===== LEGACY COMPATIBILITY METHODS ===== /** * Legacy method that returns IReverseProxyConfig for backward compatibility * @param req The incoming HTTP request * @returns The matching proxy config in legacy format or undefined */ public routeReqLegacy(req: plugins.http.IncomingMessage): IReverseProxyConfig | undefined { const result = this.routeReqWithDetails(req); if (!result) return undefined; return this.convertRouteToLegacy(result.route); } /** * Legacy method for backward compatibility with ProxyRouter * Converts IReverseProxyConfig to IRouteConfig and sets routes * * @param configs Array of legacy proxy configurations */ public setNewProxyConfigs(configs: IReverseProxyConfig[]): void { const routes = configs.map(config => this.convertLegacyConfig(config)); this.setRoutes(routes); } /** * Legacy method for backward compatibility * Gets all proxy configs by converting routes back to legacy format */ public getProxyConfigs(): IReverseProxyConfig[] { return this.routes.map(route => this.convertRouteToLegacy(route)); } /** * Legacy method: Adds a proxy config with optional path pattern * @param config The legacy configuration to add * @param pathPattern Optional path pattern for route matching */ public addProxyConfig( config: IReverseProxyConfig, pathPattern?: string ): void { const route = this.convertLegacyConfig(config, pathPattern); this.addRoute(route); } /** * Legacy method: Remove proxy config by hostname * @param hostname The hostname to remove * @returns Boolean indicating whether any configs were removed */ public removeProxyConfig(hostname: string): boolean { return this.removeRoutesByDomain(hostname); } /** * Convert legacy IReverseProxyConfig to IRouteConfig */ private convertLegacyConfig(config: IReverseProxyConfig, pathPattern?: string): IRouteConfig { return { match: { ports: config.destinationPorts?.[0] || 443, domains: config.hostName, path: pathPattern }, action: { type: 'forward', target: { host: Array.isArray(config.destinationIps) ? config.destinationIps : config.destinationIps, port: config.destinationPorts?.[0] || 443 }, tls: { mode: 'terminate', certificate: { key: config.privateKey, cert: config.publicKey } } }, security: config.authentication ? { basicAuth: { enabled: true, users: [{ username: config.authentication.user, password: config.authentication.pass }], realm: 'Protected' } } : undefined, name: `Legacy - ${config.hostName}`, enabled: true }; } /** * Convert IRouteConfig back to legacy IReverseProxyConfig format */ private convertRouteToLegacy(route: IRouteConfig): IReverseProxyConfig { const action = route.action; const target = action.target || { host: 'localhost', port: 80 }; // Extract certificate if available let privateKey = ''; let publicKey = ''; if (action.tls?.certificate && typeof action.tls.certificate === 'object') { privateKey = action.tls.certificate.key || ''; publicKey = action.tls.certificate.cert || ''; } return { hostName: Array.isArray(route.match.domains) ? route.match.domains[0] : route.match.domains || '*', destinationIps: Array.isArray(target.host) ? target.host : [target.host as string], destinationPorts: [ typeof target.port === 'number' ? target.port : typeof target.port === 'function' ? 443 // Default port for function-based : 443 ], privateKey, publicKey, authentication: route.security?.basicAuth?.enabled && route.security.basicAuth.users.length > 0 ? { type: 'Basic', user: route.security.basicAuth.users[0].username || '', pass: route.security.basicAuth.users[0].password || '' } : undefined, rewriteHostHeader: route.headers?.request?.['Host'] ? true : undefined }; } } // Export backward compatibility aliases export { HttpRouter as ProxyRouter }; export { HttpRouter as RouteRouter };