import * as plugins from '../../plugins.js'; import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from './models/route-types.js'; import type { ISmartProxyOptions, IRoutedSmartProxyOptions } from './models/interfaces.js'; import { isRoutedOptions, isLegacyOptions } 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: IRoutedSmartProxyOptions; constructor(options: ISmartProxyOptions) { super(); // We no longer support legacy options, always use provided 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 */ private rebuildPortMap(): void { this.portMap.clear(); for (const route of this.routes) { const ports = this.expandPortRange(route.match.ports); for (const port of ports) { if (!this.portMap.has(port)) { this.portMap.set(port, []); } this.portMap.get(port)!.push(route); } } } /** * Expand a port range specification into an array of individual ports */ private expandPortRange(portRange: TPortRange): number[] { if (typeof portRange === 'number') { return [portRange]; } if (Array.isArray(portRange)) { // Handle array of port objects or numbers return portRange.flatMap(item => { if (typeof item === 'number') { return [item]; } else if (typeof item === 'object' && 'from' in item && 'to' in item) { // Handle port range object const ports: number[] = []; for (let p = item.from; p <= item.to; p++) { ports.push(p); } return ports; } return []; }); } return []; } /** * Get all ports that should be listened on */ public getListeningPorts(): number[] { return Array.from(this.portMap.keys()); } /** * Get all routes for a given port */ public getRoutesForPort(port: number): IRouteConfig[] { return this.portMap.get(port) || []; } /** * 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.blockedIps && security.blockedIps.length > 0) { for (const pattern of security.blockedIps) { if (this.matchIpPattern(pattern, clientIp)) { return false; // IP is blocked } } } // If there are allowed IPs, check them if (security.allowedIps && security.allowedIps.length > 0) { for (const pattern of security.allowedIps) { 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 { // Handle exact match if (pattern === ip) { return true; } // Handle CIDR notation (e.g., 192.168.1.0/24) if (pattern.includes('/')) { return this.matchIpCidr(pattern, ip); } // Handle glob pattern (e.g., 192.168.1.*) if (pattern.includes('*')) { const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(ip); } 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); // Convert IP addresses to numeric values const ipNum = this.ipToNumber(ip); const subnetNum = this.ipToNumber(subnet); // 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 { const parts = ip.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); } /** * Convert a domain config to routes * (For backward compatibility with code that still uses domainConfigs) */ public domainConfigToRoutes(domainConfig: IDomainConfig): IRouteConfig[] { const routes: IRouteConfig[] = []; const { domains, forwarding } = domainConfig; // Determine the action based on forwarding type let action: IRouteAction = { type: 'forward', target: { host: forwarding.target.host, port: forwarding.target.port } }; // Set TLS mode based on forwarding type switch (forwarding.type) { case 'http-only': // No TLS settings needed break; case 'https-passthrough': action.tls = { mode: 'passthrough' }; break; case 'https-terminate-to-http': action.tls = { mode: 'terminate', certificate: forwarding.https?.customCert ? { key: forwarding.https.customCert.key, cert: forwarding.https.customCert.cert } : 'auto' }; break; case 'https-terminate-to-https': action.tls = { mode: 'terminate-and-reencrypt', certificate: forwarding.https?.customCert ? { key: forwarding.https.customCert.key, cert: forwarding.https.customCert.cert } : 'auto' }; break; } // Add security settings if present if (forwarding.security) { action.security = { allowedIps: forwarding.security.allowedIps, blockedIps: forwarding.security.blockedIps, maxConnections: forwarding.security.maxConnections }; } // Add advanced settings if present if (forwarding.advanced) { action.advanced = { timeout: forwarding.advanced.timeout, headers: forwarding.advanced.headers, keepAlive: forwarding.advanced.keepAlive }; } // Determine which port to use based on forwarding type const defaultPort = forwarding.type.startsWith('https') ? 443 : 80; // Add the main route routes.push({ match: { ports: defaultPort, domains }, action, name: `Route for ${domains.join(', ')}` }); // Add HTTP redirect if needed if (forwarding.http?.redirectToHttps) { routes.push({ match: { ports: 80, domains }, action: { type: 'redirect', redirect: { to: 'https://{domain}{path}', status: 301 } }, name: `HTTP Redirect for ${domains.join(', ')}`, priority: 100 // Higher priority for redirects }); } // Add port ranges if specified if (forwarding.advanced?.portRanges) { for (const range of forwarding.advanced.portRanges) { routes.push({ match: { ports: [{ from: range.from, to: range.to }], domains }, action, name: `Port Range ${range.from}-${range.to} for ${domains.join(', ')}` }); } } return routes; } /** * Update routes based on domain configs * (For backward compatibility with code that still uses domainConfigs) */ public updateFromDomainConfigs(domainConfigs: IDomainConfig[]): void { const routes: IRouteConfig[] = []; // Convert each domain config to routes for (const config of domainConfigs) { routes.push(...this.domainConfigToRoutes(config)); } // Merge with existing routes that aren't derived from domain configs const nonDomainRoutes = this.routes.filter(r => !r.name || !r.name.includes('for ')); this.updateRoutes([...nonDomainRoutes, ...routes]); } /** * 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; } }