import * as plugins from '../../plugins.js'; import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from './models/route-types.js'; import type { ISmartProxyOptions } from './models/interfaces.js'; /** * Result of route matching */ export interface IRouteMatchResult { route: IRouteConfig; // Additional match parameters (path, query, etc.) params?: Record; } /** * The RouteManager handles all routing decisions based on connections and attributes */ export class RouteManager extends plugins.EventEmitter { private routes: IRouteConfig[] = []; private portMap: Map = new Map(); private options: ISmartProxyOptions; constructor(options: ISmartProxyOptions) { super(); // Store options this.options = options; // Initialize routes from either source this.updateRoutes(this.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(); } /** * 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) { console.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; console.log(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`); // Log port details if detailed logging is enabled const enableDetailedLogging = this.options.enableDetailedLogging; if (enableDetailedLogging) { for (const [port, routes] of this.portMap.entries()) { console.log(`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) { console.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; } /** * Memoization cache for expanded port ranges */ private portRangeCache: Map = new Map(); /** * 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) || []; } /** * Get all routes */ public getAllRoutes(): IRouteConfig[] { return [...this.routes]; } /** * Test if a pattern matches a domain using glob matching */ private matchDomain(pattern: string, domain: string): boolean { // Convert glob pattern to regex const regexPattern = pattern .replace(/\./g, '\\.') // Escape dots .replace(/\*/g, '.*'); // Convert * to .* const regex = new RegExp(`^${regexPattern}$`, 'i'); return regex.test(domain); } /** * Match a domain against all patterns in a route */ private matchRouteDomain(route: IRouteConfig, domain: string): boolean { if (!route.match.domains) { // If no domains specified, match all domains return true; } const patterns = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return patterns.some(pattern => this.matchDomain(pattern, domain)); } /** * Check if a client IP is allowed by a route's security settings */ private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean { const security = route.action.security; if (!security) { return true; // No security settings means allowed } // Check blocked IPs first if (security.ipBlockList && security.ipBlockList.length > 0) { for (const pattern of security.ipBlockList) { if (this.matchIpPattern(pattern, clientIp)) { return false; // IP is blocked } } } // If there are allowed IPs, check them if (security.ipAllowList && security.ipAllowList.length > 0) { for (const pattern of security.ipAllowList) { if (this.matchIpPattern(pattern, clientIp)) { return true; // IP is allowed } } return false; // IP not in allowed list } // No allowed IPs specified, so IP is allowed return true; } /** * Match an IP against a pattern */ private matchIpPattern(pattern: string, ip: string): boolean { // Normalize IPv6-mapped IPv4 addresses const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern; // Handle exact match with normalized addresses if (pattern === ip || normalizedPattern === normalizedIp || pattern === normalizedIp || normalizedPattern === ip) { return true; } // Handle CIDR notation (e.g., 192.168.1.0/24) if (pattern.includes('/')) { return this.matchIpCidr(pattern, normalizedIp) || (normalizedPattern !== pattern && this.matchIpCidr(normalizedPattern, normalizedIp)); } // Handle glob pattern (e.g., 192.168.1.*) if (pattern.includes('*')) { const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); const regex = new RegExp(`^${regexPattern}$`); if (regex.test(ip) || regex.test(normalizedIp)) { return true; } // If pattern was normalized, also test with normalized pattern if (normalizedPattern !== pattern) { const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`); return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp); } } return false; } /** * Match an IP against a CIDR pattern */ private matchIpCidr(cidr: string, ip: string): boolean { try { // In a real implementation, you'd use a proper IP library // This is a simplified implementation const [subnet, bits] = cidr.split('/'); const mask = parseInt(bits, 10); // Normalize IPv6-mapped IPv4 addresses const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet; // Convert IP addresses to numeric values const ipNum = this.ipToNumber(normalizedIp); const subnetNum = this.ipToNumber(normalizedSubnet); // Calculate subnet mask const maskNum = ~(2 ** (32 - mask) - 1); // Check if IP is in subnet return (ipNum & maskNum) === (subnetNum & maskNum); } catch (e) { console.error(`Error matching IP ${ip} against CIDR ${cidr}:`, e); return false; } } /** * Convert an IP address to a numeric value */ private ipToNumber(ip: string): number { // Normalize IPv6-mapped IPv4 addresses const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; const parts = normalizedIp.split('.').map(part => parseInt(part, 10)); return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; } /** * Find the matching route for a connection */ public findMatchingRoute(options: { port: number; domain?: string; clientIp: string; path?: string; tlsVersion?: string; }): IRouteMatchResult | null { const { port, domain, clientIp, path, tlsVersion } = options; // Get all routes for this port const routesForPort = this.getRoutesForPort(port); // Find the first matching route based on priority order for (const route of routesForPort) { // Check domain match if specified if (domain && !this.matchRouteDomain(route, domain)) { continue; } // Check path match if specified in both route and request if (path && route.match.path) { if (!this.matchPath(route.match.path, path)) { continue; } } // Check client IP match if (route.match.clientIp && !route.match.clientIp.some(pattern => this.matchIpPattern(pattern, clientIp))) { continue; } // Check TLS version match if (tlsVersion && route.match.tlsVersion && !route.match.tlsVersion.includes(tlsVersion)) { continue; } // Check security settings if (!this.isClientIpAllowed(route, clientIp)) { continue; } // All checks passed, this route matches return { route }; } return null; } /** * Match a path against a pattern */ private matchPath(pattern: string, path: string): boolean { // Convert the glob pattern to a regex const regexPattern = pattern .replace(/\./g, '\\.') // Escape dots .replace(/\*/g, '.*') // Convert * to .* .replace(/\//g, '\\/'); // Escape slashes const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); } /** * Domain-based configuration methods have been removed * as part of the migration to pure route-based configuration */ /** * 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 if (this.isRouteMoreSpecific(higherPriorityRoute.match, route.match)) { 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 */ private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean { // Check if match1 has more specific criteria let match1Points = 0; let match2Points = 0; // Path is the most specific if (match1.path) match1Points += 3; if (match2.path) match2Points += 3; // Domain is next most specific if (match1.domains) match1Points += 2; if (match2.domains) match2Points += 2; // Client IP and TLS version are least specific if (match1.clientIp) match1Points += 1; if (match2.clientIp) match2Points += 1; if (match1.tlsVersion) match1Points += 1; if (match2.tlsVersion) match2Points += 1; return match1Points > match2Points; } }