489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
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<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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<number, IRouteConfig[]> = new Map();
|
|
private logger: ILogger;
|
|
private enableDetailedLogging: boolean;
|
|
|
|
/**
|
|
* Memoization cache for expanded port ranges
|
|
*/
|
|
private portRangeCache: Map<string, number[]> = 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<number, string[]>();
|
|
|
|
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<number, number>();
|
|
|
|
// 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);
|
|
}
|
|
} |