import * as plugins from '../../plugins.js'; import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../proxies/smart-proxy/models/route-types.js'; import { matchDomain, matchRouteDomain, matchPath, matchIpPattern, matchIpCidr, ipToNumber, isIpAuthorized, calculateRouteSpecificity } from './route-utils.js'; /** * Result of route matching */ export interface IRouteMatchResult { route: IRouteConfig; // Additional match parameters (path, query, etc.) params?: Record; } /** * Logger interface for RouteManager */ export interface ILogger { info: (message: string, ...args: any[]) => void; warn: (message: string, ...args: any[]) => void; error: (message: string, ...args: any[]) => void; debug?: (message: string, ...args: any[]) => void; } /** * Shared RouteManager used by both SmartProxy and NetworkProxy * * This provides a unified implementation for route management, * route matching, and port handling. */ export class SharedRouteManager extends plugins.EventEmitter { private routes: IRouteConfig[] = []; private portMap: Map = new Map(); private logger: ILogger; private enableDetailedLogging: boolean; /** * Memoization cache for expanded port ranges */ private portRangeCache: Map = new Map(); constructor(options: { logger?: ILogger; enableDetailedLogging?: boolean; routes?: IRouteConfig[]; }) { super(); // Set up logger (use console if not provided) this.logger = options.logger || { info: console.log, warn: console.warn, error: console.error, debug: options.enableDetailedLogging ? console.log : undefined }; this.enableDetailedLogging = options.enableDetailedLogging || false; // Initialize routes if provided if (options.routes) { this.updateRoutes(options.routes); } } /** * Update routes with new configuration */ public updateRoutes(routes: IRouteConfig[] = []): void { // Sort routes by priority (higher first) this.routes = [...(routes || [])].sort((a, b) => { const priorityA = a.priority ?? 0; const priorityB = b.priority ?? 0; return priorityB - priorityA; }); // Rebuild port mapping for fast lookups this.rebuildPortMap(); this.logger.info(`Updated RouteManager with ${this.routes.length} routes`); } /** * Get all routes */ public getRoutes(): IRouteConfig[] { return [...this.routes]; } /** * Rebuild the port mapping for fast lookups * Also logs information about the ports being listened on */ private rebuildPortMap(): void { this.portMap.clear(); this.portRangeCache.clear(); // Clear cache when rebuilding // Track ports for logging const portToRoutesMap = new Map(); for (const route of this.routes) { const ports = this.expandPortRange(route.match.ports); // Skip if no ports were found if (ports.length === 0) { this.logger.warn(`Route ${route.name || 'unnamed'} has no valid ports to listen on`); continue; } for (const port of ports) { // Add to portMap for routing if (!this.portMap.has(port)) { this.portMap.set(port, []); } this.portMap.get(port)!.push(route); // Add to tracking for logging if (!portToRoutesMap.has(port)) { portToRoutesMap.set(port, []); } portToRoutesMap.get(port)!.push(route.name || 'unnamed'); } } // Log summary of ports and routes const totalPorts = this.portMap.size; const totalRoutes = this.routes.length; this.logger.info(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`); // Log port details if detailed logging is enabled if (this.enableDetailedLogging) { for (const [port, routes] of this.portMap.entries()) { this.logger.info(`Port ${port}: ${routes.length} routes (${portToRoutesMap.get(port)!.join(', ')})`); } } } /** * Expand a port range specification into an array of individual ports * Uses caching to improve performance for frequently used port ranges * * @public - Made public to allow external code to interpret port ranges */ public expandPortRange(portRange: TPortRange): number[] { // For simple number, return immediately if (typeof portRange === 'number') { return [portRange]; } // Create a cache key for this port range const cacheKey = JSON.stringify(portRange); // Check if we have a cached result if (this.portRangeCache.has(cacheKey)) { return this.portRangeCache.get(cacheKey)!; } // Process the port range let result: number[] = []; if (Array.isArray(portRange)) { // Handle array of port objects or numbers result = portRange.flatMap(item => { if (typeof item === 'number') { return [item]; } else if (typeof item === 'object' && 'from' in item && 'to' in item) { // Handle port range object - check valid range if (item.from > item.to) { this.logger.warn(`Invalid port range: from (${item.from}) > to (${item.to})`); return []; } // Handle port range object const ports: number[] = []; for (let p = item.from; p <= item.to; p++) { ports.push(p); } return ports; } return []; }); } // Cache the result this.portRangeCache.set(cacheKey, result); return result; } /** * Get all ports that should be listened on * This method automatically infers all required ports from route configurations */ public getListeningPorts(): number[] { // Return the unique set of ports from all routes return Array.from(this.portMap.keys()); } /** * Get all routes for a given port */ public getRoutesForPort(port: number): IRouteConfig[] { return this.portMap.get(port) || []; } /** * Find the matching route for a connection */ public findMatchingRoute(context: IRouteContext): IRouteMatchResult | null { // Get routes for this port if using port-based filtering const routesToCheck = context.port ? (this.portMap.get(context.port) || []) : this.routes; // Find the first matching route based on priority order for (const route of routesToCheck) { if (this.matchesRoute(route, context)) { return { route }; } } return null; } /** * Check if a route matches the given context */ private matchesRoute(route: IRouteConfig, context: IRouteContext): boolean { // Skip disabled routes if (route.enabled === false) { return false; } // Check port match if provided in context if (context.port !== undefined) { const ports = this.expandPortRange(route.match.ports); if (!ports.includes(context.port)) { return false; } } // Check domain match if specified if (route.match.domains && context.domain) { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; if (!domains.some(domainPattern => this.matchDomain(domainPattern, context.domain!))) { return false; } } // Check path match if specified if (route.match.path && context.path) { if (!this.matchPath(route.match.path, context.path)) { return false; } } // Check client IP match if specified if (route.match.clientIp && context.clientIp) { if (!route.match.clientIp.some(ip => this.matchIpPattern(ip, context.clientIp))) { return false; } } // Check TLS version match if specified if (route.match.tlsVersion && context.tlsVersion) { if (!route.match.tlsVersion.includes(context.tlsVersion)) { return false; } } // Check header match if specified if (route.match.headers && context.headers) { for (const [headerName, expectedValue] of Object.entries(route.match.headers)) { const actualValue = context.headers[headerName.toLowerCase()]; // If header doesn't exist, no match if (actualValue === undefined) { return false; } // Match against string or regex if (typeof expectedValue === 'string') { if (actualValue !== expectedValue) { return false; } } else if (expectedValue instanceof RegExp) { if (!expectedValue.test(actualValue)) { return false; } } } } // All criteria matched return true; } /** * Match a domain pattern against a domain * @deprecated Use the matchDomain function from route-utils.js instead */ public matchDomain(pattern: string, domain: string): boolean { return matchDomain(pattern, domain); } /** * Match a path pattern against a path * @deprecated Use the matchPath function from route-utils.js instead */ public matchPath(pattern: string, path: string): boolean { return matchPath(pattern, path); } /** * Match an IP pattern against a pattern * @deprecated Use the matchIpPattern function from route-utils.js instead */ public matchIpPattern(pattern: string, ip: string): boolean { return matchIpPattern(pattern, ip); } /** * Match an IP against a CIDR pattern * @deprecated Use the matchIpCidr function from route-utils.js instead */ public matchIpCidr(cidr: string, ip: string): boolean { return matchIpCidr(cidr, ip); } /** * Convert an IP address to a numeric value * @deprecated Use the ipToNumber function from route-utils.js instead */ private ipToNumber(ip: string): number { return ipToNumber(ip); } /** * Validate the route configuration and return any warnings */ public validateConfiguration(): string[] { const warnings: string[] = []; const duplicatePorts = new Map(); // Check for routes with the same exact match criteria for (let i = 0; i < this.routes.length; i++) { for (let j = i + 1; j < this.routes.length; j++) { const route1 = this.routes[i]; const route2 = this.routes[j]; // Check if route match criteria are the same if (this.areMatchesSimilar(route1.match, route2.match)) { warnings.push( `Routes "${route1.name || i}" and "${route2.name || j}" have similar match criteria. ` + `The route with higher priority (${Math.max(route1.priority || 0, route2.priority || 0)}) will be used.` ); } } } // Check for routes that may never be matched due to priority for (let i = 0; i < this.routes.length; i++) { const route = this.routes[i]; const higherPriorityRoutes = this.routes.filter(r => (r.priority || 0) > (route.priority || 0)); for (const higherRoute of higherPriorityRoutes) { if (this.isRouteShadowed(route, higherRoute)) { warnings.push( `Route "${route.name || i}" may never be matched because it is shadowed by ` + `higher priority route "${higherRoute.name || 'unnamed'}"` ); break; } } } return warnings; } /** * Check if two route matches are similar (potential conflict) */ private areMatchesSimilar(match1: IRouteMatch, match2: IRouteMatch): boolean { // Check port overlap const ports1 = new Set(this.expandPortRange(match1.ports)); const ports2 = new Set(this.expandPortRange(match2.ports)); let havePortOverlap = false; for (const port of ports1) { if (ports2.has(port)) { havePortOverlap = true; break; } } if (!havePortOverlap) { return false; } // Check domain overlap if (match1.domains && match2.domains) { const domains1 = Array.isArray(match1.domains) ? match1.domains : [match1.domains]; const domains2 = Array.isArray(match2.domains) ? match2.domains : [match2.domains]; // Check if any domain pattern from match1 could match any from match2 let haveDomainOverlap = false; for (const domain1 of domains1) { for (const domain2 of domains2) { if (domain1 === domain2 || (domain1.includes('*') || domain2.includes('*'))) { haveDomainOverlap = true; break; } } if (haveDomainOverlap) break; } if (!haveDomainOverlap) { return false; } } else if (match1.domains || match2.domains) { // One has domains, the other doesn't - they could overlap // The one with domains is more specific, so it's not exactly a conflict return false; } // Check path overlap if (match1.path && match2.path) { // This is a simplified check - in a real implementation, // you'd need to check if the path patterns could match the same paths return match1.path === match2.path || match1.path.includes('*') || match2.path.includes('*'); } else if (match1.path || match2.path) { // One has a path, the other doesn't return false; } // If we get here, the matches have significant overlap return true; } /** * Check if a route is completely shadowed by a higher priority route */ private isRouteShadowed(route: IRouteConfig, higherPriorityRoute: IRouteConfig): boolean { // If they don't have similar match criteria, no shadowing occurs if (!this.areMatchesSimilar(route.match, higherPriorityRoute.match)) { return false; } // If higher priority route has more specific criteria, no shadowing const routeSpecificity = calculateRouteSpecificity(route.match); const higherRouteSpecificity = calculateRouteSpecificity(higherPriorityRoute.match); if (higherRouteSpecificity > routeSpecificity) { return false; } // If higher priority route is equally or less specific but has higher priority, // it shadows the lower priority route return true; } /** * Check if route1 is more specific than route2 * @deprecated Use the calculateRouteSpecificity function from route-utils.js instead */ private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean { return calculateRouteSpecificity(match1) > calculateRouteSpecificity(match2); } }