update
This commit is contained in:
		| @@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js'; | ||||
| import { ContextCreator } from './context-creator.js'; | ||||
| import { HttpRequestHandler } from './http-request-handler.js'; | ||||
| import { Http2RequestHandler } from './http2-request-handler.js'; | ||||
| import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; | ||||
| import { toBaseContext } from '../../core/models/route-context.js'; | ||||
| import { TemplateUtils } from '../../core/utils/template-utils.js'; | ||||
| @@ -99,6 +99,80 @@ export class RequestHandler { | ||||
|     return { ...this.defaultHeaders }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Apply CORS headers to response if configured | ||||
|    * Implements Phase 5.5: Context-aware CORS handling | ||||
| @@ -480,17 +554,31 @@ export class RequestHandler { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // If we found a matching route with function-based targets, use it | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { | ||||
|     // If we found a matching route with forward action, select appropriate target | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) { | ||||
|       this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`); | ||||
|  | ||||
|       // Select the appropriate target from the targets array | ||||
|       const selectedTarget = this.selectTarget(matchingRoute.action.targets, { | ||||
|         port: routeContext.port, | ||||
|         path: routeContext.path, | ||||
|         headers: routeContext.headers, | ||||
|         method: routeContext.method | ||||
|       }); | ||||
|  | ||||
|       if (!selectedTarget) { | ||||
|         this.logger.error(`No matching target found for route ${matchingRoute.name}`); | ||||
|         req.socket.end(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Extract target information, resolving functions if needed | ||||
|       let targetHost: string | string[]; | ||||
|       let targetPort: number; | ||||
|  | ||||
|       try { | ||||
|         // Check function cache for host and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // Generate a function ID for caching (use route name or ID if available) | ||||
|           const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -502,7 +590,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached host value for ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); | ||||
|               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|               targetHost = resolvedHost; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -511,16 +599,16 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); | ||||
|             const resolvedHost = selectedTarget.host(routeContext); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetHost = matchingRoute.action.target.host; | ||||
|           targetHost = selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Check function cache for port and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.port === 'function') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           // Generate a function ID for caching | ||||
|           const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -532,7 +620,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached port value for ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); | ||||
|               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|               targetPort = resolvedPort; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -541,12 +629,12 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); | ||||
|             const resolvedPort = selectedTarget.port(routeContext); | ||||
|             targetPort = resolvedPort; | ||||
|             this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; | ||||
|           targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|         } | ||||
|  | ||||
|         // Select a single host if an array was provided | ||||
| @@ -626,17 +714,32 @@ export class RequestHandler { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // If we found a matching route with function-based targets, use it | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { | ||||
|     // If we found a matching route with forward action, select appropriate target | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) { | ||||
|       this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`); | ||||
|  | ||||
|       // Select the appropriate target from the targets array | ||||
|       const selectedTarget = this.selectTarget(matchingRoute.action.targets, { | ||||
|         port: routeContext.port, | ||||
|         path: routeContext.path, | ||||
|         headers: routeContext.headers, | ||||
|         method: routeContext.method | ||||
|       }); | ||||
|  | ||||
|       if (!selectedTarget) { | ||||
|         this.logger.error(`No matching target found for route ${matchingRoute.name}`); | ||||
|         stream.respond({ ':status': 502 }); | ||||
|         stream.end(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Extract target information, resolving functions if needed | ||||
|       let targetHost: string | string[]; | ||||
|       let targetPort: number; | ||||
|  | ||||
|       try { | ||||
|         // Check function cache for host and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // Generate a function ID for caching (use route name or ID if available) | ||||
|           const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -648,7 +751,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); | ||||
|               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|               targetHost = resolvedHost; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -657,16 +760,16 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); | ||||
|             const resolvedHost = selectedTarget.host(routeContext); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetHost = matchingRoute.action.target.host; | ||||
|           targetHost = selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Check function cache for port and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.port === 'function') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           // Generate a function ID for caching | ||||
|           const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -678,7 +781,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); | ||||
|               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|               targetPort = resolvedPort; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -687,12 +790,12 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); | ||||
|             const resolvedPort = selectedTarget.port(routeContext); | ||||
|             targetPort = resolvedPort; | ||||
|             this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; | ||||
|           targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|         } | ||||
|  | ||||
|         // Select a single host if an array was provided | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js'; | ||||
| import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js'; | ||||
| import { ConnectionPool } from './connection-pool.js'; | ||||
| import { HttpRouter } from '../../routing/router/index.js'; | ||||
| import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext } from '../../core/models/route-context.js'; | ||||
| import { toBaseContext } from '../../core/models/route-context.js'; | ||||
| import { ContextCreator } from './context-creator.js'; | ||||
| @@ -53,6 +53,80 @@ export class WebSocketHandler { | ||||
|     this.securityManager.setRoutes(routes); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize WebSocket server on an existing HTTPS server | ||||
|    */ | ||||
| @@ -146,9 +220,23 @@ export class WebSocketHandler { | ||||
|       let destination: { host: string; port: number }; | ||||
|  | ||||
|       // If we found a route with the modern router, use it | ||||
|       if (route && route.action.type === 'forward' && route.action.target) { | ||||
|       if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) { | ||||
|         this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`); | ||||
|  | ||||
|         // Select the appropriate target from the targets array | ||||
|         const selectedTarget = this.selectTarget(route.action.targets, { | ||||
|           port: routeContext.port, | ||||
|           path: routeContext.path, | ||||
|           headers: routeContext.headers, | ||||
|           method: routeContext.method | ||||
|         }); | ||||
|  | ||||
|         if (!selectedTarget) { | ||||
|           this.logger.error(`No matching target found for route ${route.name}`); | ||||
|           wsIncoming.close(1003, 'No matching target'); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Check if WebSockets are enabled for this route | ||||
|         if (route.action.websocket?.enabled === false) { | ||||
|           this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); | ||||
| @@ -192,20 +280,20 @@ export class WebSocketHandler { | ||||
|  | ||||
|         try { | ||||
|           // Resolve host if it's a function | ||||
|           if (typeof route.action.target.host === 'function') { | ||||
|             const resolvedHost = route.action.target.host(toBaseContext(routeContext)); | ||||
|           if (typeof selectedTarget.host === 'function') { | ||||
|             const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } else { | ||||
|             targetHost = route.action.target.host; | ||||
|             targetHost = selectedTarget.host; | ||||
|           } | ||||
|  | ||||
|           // Resolve port if it's a function | ||||
|           if (typeof route.action.target.port === 'function') { | ||||
|             targetPort = route.action.target.port(toBaseContext(routeContext)); | ||||
|           if (typeof selectedTarget.port === 'function') { | ||||
|             targetPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|             this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); | ||||
|           } else { | ||||
|             targetPort = route.action.target.port === 'preserve' ? routeContext.port : route.action.target.port as number; | ||||
|             targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|           } | ||||
|  | ||||
|           // Select a single host if an array was provided | ||||
|   | ||||
| @@ -46,11 +46,36 @@ export interface IRouteMatch { | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Target configuration for forwarding | ||||
|  * Target-specific match criteria for sub-routing within a route | ||||
|  */ | ||||
| export interface ITargetMatch { | ||||
|   ports?: number[];                             // Match specific ports from the route | ||||
|   path?: string;                                // Match specific paths (supports wildcards like /api/*) | ||||
|   headers?: Record<string, string | RegExp>;    // Match specific HTTP headers | ||||
|   method?: string[];                            // Match specific HTTP methods (GET, POST, etc.) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Target configuration for forwarding with sub-matching and overrides | ||||
|  */ | ||||
| export interface IRouteTarget { | ||||
|   // Optional sub-matching criteria within the route | ||||
|   match?: ITargetMatch; | ||||
|    | ||||
|   // Target destination | ||||
|   host: string | string[] | ((context: IRouteContext) => string | string[]);  // Host or hosts with optional function for dynamic resolution | ||||
|   port: number | 'preserve' | ((context: IRouteContext) => number);  // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port) | ||||
|    | ||||
|   // Optional target-specific overrides (these override route-level settings) | ||||
|   tls?: IRouteTls;                             // Override route-level TLS settings | ||||
|   websocket?: IRouteWebSocket;                 // Override route-level WebSocket settings | ||||
|   loadBalancing?: IRouteLoadBalancing;         // Override route-level load balancing | ||||
|   sendProxyProtocol?: boolean;                 // Override route-level proxy protocol setting | ||||
|   headers?: IRouteHeaders;                     // Override route-level headers | ||||
|   advanced?: IRouteAdvanced;                   // Override route-level advanced settings | ||||
|    | ||||
|   // Priority for matching (higher values are checked first, default: 0) | ||||
|   priority?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -221,19 +246,19 @@ export interface IRouteAction { | ||||
|   // Basic routing | ||||
|   type: TRouteActionType; | ||||
|  | ||||
|   // Target for forwarding | ||||
|   target?: IRouteTarget; | ||||
|   // Targets for forwarding (array supports multiple targets with sub-matching) | ||||
|   targets: IRouteTarget[]; | ||||
|  | ||||
|   // TLS handling | ||||
|   // TLS handling (default for all targets, can be overridden per target) | ||||
|   tls?: IRouteTls; | ||||
|  | ||||
|   // WebSocket support | ||||
|   // WebSocket support (default for all targets, can be overridden per target) | ||||
|   websocket?: IRouteWebSocket; | ||||
|  | ||||
|   // Load balancing options | ||||
|   // Load balancing options (default for all targets, can be overridden per target) | ||||
|   loadBalancing?: IRouteLoadBalancing; | ||||
|  | ||||
|   // Advanced options | ||||
|   // Advanced options (default for all targets, can be overridden per target) | ||||
|   advanced?: IRouteAdvanced; | ||||
|    | ||||
|   // Additional options for backend-specific settings | ||||
| @@ -251,7 +276,7 @@ export interface IRouteAction { | ||||
|   // Socket handler function (when type is 'socket-handler') | ||||
|   socketHandler?: TSocketHandler; | ||||
|    | ||||
|   // PROXY protocol support | ||||
|   // PROXY protocol support (default for all targets, can be overridden per target) | ||||
|   sendProxyProtocol?: boolean; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -123,39 +123,43 @@ export class NFTablesManager { | ||||
|   private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { | ||||
|     const { action } = route; | ||||
|      | ||||
|     // Ensure we have a target | ||||
|     if (!action.target) { | ||||
|       throw new Error('Route must have a target to use NFTables forwarding'); | ||||
|     // Ensure we have targets | ||||
|     if (!action.targets || action.targets.length === 0) { | ||||
|       throw new Error('Route must have targets to use NFTables forwarding'); | ||||
|     } | ||||
|      | ||||
|     // NFTables can only handle a single target, so we use the first target without match criteria | ||||
|     // or the first target if all have match criteria | ||||
|     const defaultTarget = action.targets.find(t => !t.match) || action.targets[0]; | ||||
|      | ||||
|     // Convert port specifications | ||||
|     const fromPorts = this.expandPortRange(route.match.ports); | ||||
|      | ||||
|     // Determine target port | ||||
|     let toPorts: number | PortRange | Array<number | PortRange>; | ||||
|      | ||||
|     if (action.target.port === 'preserve') { | ||||
|     if (defaultTarget.port === 'preserve') { | ||||
|       // 'preserve' means use the same ports as the source | ||||
|       toPorts = fromPorts; | ||||
|     } else if (typeof action.target.port === 'function') { | ||||
|     } else if (typeof defaultTarget.port === 'function') { | ||||
|       // For function-based ports, we can't determine at setup time | ||||
|       // Use the "preserve" approach and let NFTables handle it | ||||
|       toPorts = fromPorts; | ||||
|     } else { | ||||
|       toPorts = action.target.port; | ||||
|       toPorts = defaultTarget.port; | ||||
|     } | ||||
|      | ||||
|     // Determine target host | ||||
|     let toHost: string; | ||||
|     if (typeof action.target.host === 'function') { | ||||
|     if (typeof defaultTarget.host === 'function') { | ||||
|       // Can't determine at setup time, use localhost as a placeholder | ||||
|       // and rely on run-time handling | ||||
|       toHost = 'localhost'; | ||||
|     } else if (Array.isArray(action.target.host)) { | ||||
|     } else if (Array.isArray(defaultTarget.host)) { | ||||
|       // Use first host for now - NFTables will do simple round-robin   | ||||
|       toHost = action.target.host[0]; | ||||
|       toHost = defaultTarget.host[0]; | ||||
|     } else { | ||||
|       toHost = action.target.host; | ||||
|       toHost = defaultTarget.host; | ||||
|     } | ||||
|      | ||||
|     // Create options | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces. | ||||
| import { logger } from '../../core/utils/logger.js'; | ||||
| import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; | ||||
| // Route checking functions have been removed | ||||
| import type { IRouteConfig, IRouteAction } from './models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js'; | ||||
| import type { IRouteContext } from '../../core/models/route-context.js'; | ||||
| import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; | ||||
| import { WrappedSocket } from '../../core/models/wrapped-socket.js'; | ||||
| @@ -657,6 +657,80 @@ export class RouteConnectionHandler { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle a forward action for a route | ||||
|    */ | ||||
| @@ -731,14 +805,37 @@ export class RouteConnectionHandler { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // We should have a target configuration for forwarding | ||||
|     if (!action.target) { | ||||
|       logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, { | ||||
|     // Select the appropriate target from the targets array | ||||
|     if (!action.targets || action.targets.length === 0) { | ||||
|       logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, { | ||||
|         connectionId, | ||||
|         component: 'route-handler' | ||||
|       }); | ||||
|       socket.end(); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'missing_target'); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'missing_targets'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Create context for target selection | ||||
|     const targetSelectionContext = { | ||||
|       port: record.localPort, | ||||
|       path: undefined, // Will be populated from HTTP headers if available | ||||
|       headers: undefined, // Will be populated from HTTP headers if available | ||||
|       method: undefined // Will be populated from HTTP headers if available | ||||
|     }; | ||||
|      | ||||
|     // TODO: Extract path, headers, and method from initialChunk if it's HTTP | ||||
|     // For now, we'll select based on port only | ||||
|      | ||||
|     const selectedTarget = this.selectTarget(action.targets, targetSelectionContext); | ||||
|     if (!selectedTarget) { | ||||
|       logger.log('error', `No matching target found for connection ${connectionId}`, { | ||||
|         connectionId, | ||||
|         port: targetSelectionContext.port, | ||||
|         component: 'route-handler' | ||||
|       }); | ||||
|       socket.end(); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'no_matching_target'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @@ -759,9 +856,9 @@ export class RouteConnectionHandler { | ||||
|  | ||||
|     // Determine host using function or static value | ||||
|     let targetHost: string | string[]; | ||||
|     if (typeof action.target.host === 'function') { | ||||
|     if (typeof selectedTarget.host === 'function') { | ||||
|       try { | ||||
|         targetHost = action.target.host(routeContext); | ||||
|         targetHost = selectedTarget.host(routeContext); | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
| @@ -780,7 +877,7 @@ export class RouteConnectionHandler { | ||||
|         return; | ||||
|       } | ||||
|     } else { | ||||
|       targetHost = action.target.host; | ||||
|       targetHost = selectedTarget.host; | ||||
|     } | ||||
|  | ||||
|     // If an array of hosts, select one randomly for load balancing | ||||
| @@ -790,9 +887,9 @@ export class RouteConnectionHandler { | ||||
|  | ||||
|     // Determine port using function or static value | ||||
|     let targetPort: number; | ||||
|     if (typeof action.target.port === 'function') { | ||||
|     if (typeof selectedTarget.port === 'function') { | ||||
|       try { | ||||
|         targetPort = action.target.port(routeContext); | ||||
|         targetPort = selectedTarget.port(routeContext); | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
| @@ -813,20 +910,27 @@ export class RouteConnectionHandler { | ||||
|         this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); | ||||
|         return; | ||||
|       } | ||||
|     } else if (action.target.port === 'preserve') { | ||||
|     } else if (selectedTarget.port === 'preserve') { | ||||
|       // Use incoming port if port is 'preserve' | ||||
|       targetPort = record.localPort; | ||||
|     } else { | ||||
|       // Use static port from configuration | ||||
|       targetPort = action.target.port; | ||||
|       targetPort = selectedTarget.port; | ||||
|     } | ||||
|  | ||||
|     // Store the resolved host in the context | ||||
|     routeContext.targetHost = selectedHost; | ||||
|  | ||||
|     // Get effective settings (target overrides route-level settings) | ||||
|     const effectiveTls = selectedTarget.tls || effectiveTls; | ||||
|     const effectiveWebsocket = selectedTarget.websocket || action.websocket; | ||||
|     const effectiveSendProxyProtocol = selectedTarget.sendProxyProtocol !== undefined  | ||||
|       ? selectedTarget.sendProxyProtocol  | ||||
|       : action.sendProxyProtocol; | ||||
|  | ||||
|     // Determine if this needs TLS handling | ||||
|     if (action.tls) { | ||||
|       switch (action.tls.mode) { | ||||
|     if (effectiveTls) { | ||||
|       switch (effectiveTls.mode) { | ||||
|         case 'passthrough': | ||||
|           // For TLS passthrough, just forward directly | ||||
|           if (this.smartProxy.settings.enableDetailedLogging) { | ||||
| @@ -853,9 +957,9 @@ export class RouteConnectionHandler { | ||||
|           // For TLS termination, use HttpProxy | ||||
|           if (this.smartProxy.httpProxyBridge.getHttpProxy()) { | ||||
|             if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|               logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host} for connection ${connectionId}`, { | ||||
|               logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host} for connection ${connectionId}`, { | ||||
|                 connectionId, | ||||
|                 targetHost: action.target.host, | ||||
|                 targetHost: selectedTarget.host, | ||||
|                 component: 'route-handler' | ||||
|               }); | ||||
|             } | ||||
| @@ -929,10 +1033,10 @@ export class RouteConnectionHandler { | ||||
|       } else { | ||||
|         // Basic forwarding | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Using basic forwarding to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host}:${action.target.port} for connection ${connectionId}`, { | ||||
|           logger.log('info', `Using basic forwarding to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host}:${selectedTarget.port} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
|             targetHost: action.target.host, | ||||
|             targetPort: action.target.port, | ||||
|             targetHost: selectedTarget.host, | ||||
|             targetPort: selectedTarget.port, | ||||
|             component: 'route-handler' | ||||
|           }); | ||||
|         } | ||||
| @@ -940,27 +1044,27 @@ export class RouteConnectionHandler { | ||||
|         // Get the appropriate host value | ||||
|         let targetHost: string; | ||||
|  | ||||
|         if (typeof action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // For function-based host, use the same routeContext created earlier | ||||
|           const hostResult = action.target.host(routeContext); | ||||
|           const hostResult = selectedTarget.host(routeContext); | ||||
|           targetHost = Array.isArray(hostResult) | ||||
|             ? hostResult[Math.floor(Math.random() * hostResult.length)] | ||||
|             : hostResult; | ||||
|         } else { | ||||
|           // For static host value | ||||
|           targetHost = Array.isArray(action.target.host) | ||||
|             ? action.target.host[Math.floor(Math.random() * action.target.host.length)] | ||||
|             : action.target.host; | ||||
|           targetHost = Array.isArray(selectedTarget.host) | ||||
|             ? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)] | ||||
|             : selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Determine port - either function-based, static, or preserve incoming port | ||||
|         let targetPort: number; | ||||
|         if (typeof action.target.port === 'function') { | ||||
|           targetPort = action.target.port(routeContext); | ||||
|         } else if (action.target.port === 'preserve') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           targetPort = selectedTarget.port(routeContext); | ||||
|         } else if (selectedTarget.port === 'preserve') { | ||||
|           targetPort = record.localPort; | ||||
|         } else { | ||||
|           targetPort = action.target.port; | ||||
|           targetPort = selectedTarget.port; | ||||
|         } | ||||
|  | ||||
|         // Update the connection record and context with resolved values | ||||
|   | ||||
| @@ -66,12 +66,9 @@ export function mergeRouteConfigs( | ||||
|       // Otherwise merge the action properties | ||||
|       mergedRoute.action = { ...mergedRoute.action }; | ||||
|        | ||||
|       // Merge target | ||||
|       if (overrideRoute.action.target) { | ||||
|         mergedRoute.action.target = { | ||||
|           ...mergedRoute.action.target, | ||||
|           ...overrideRoute.action.target | ||||
|         }; | ||||
|       // Merge targets | ||||
|       if (overrideRoute.action.targets) { | ||||
|         mergedRoute.action.targets = overrideRoute.action.targets; | ||||
|       } | ||||
|        | ||||
|       // Merge TLS options | ||||
|   | ||||
| @@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err | ||||
|     errors.push(`Invalid action type: ${action.type}`); | ||||
|   } | ||||
|  | ||||
|   // Validate target for 'forward' action | ||||
|   // Validate targets for 'forward' action | ||||
|   if (action.type === 'forward') { | ||||
|     if (!action.target) { | ||||
|       errors.push('Target is required for forward action'); | ||||
|     if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) { | ||||
|       errors.push('Targets array is required for forward action'); | ||||
|     } else { | ||||
|       // Validate target host | ||||
|       if (!action.target.host) { | ||||
|         errors.push('Target host is required'); | ||||
|       } else if (typeof action.target.host !== 'string' && | ||||
|                 !Array.isArray(action.target.host) && | ||||
|                 typeof action.target.host !== 'function') { | ||||
|         errors.push('Target host must be a string, array of strings, or function'); | ||||
|       } | ||||
|       // Validate each target | ||||
|       action.targets.forEach((target, index) => { | ||||
|         // Validate target host | ||||
|         if (!target.host) { | ||||
|           errors.push(`Target[${index}] host is required`); | ||||
|         } else if (typeof target.host !== 'string' && | ||||
|                   !Array.isArray(target.host) && | ||||
|                   typeof target.host !== 'function') { | ||||
|           errors.push(`Target[${index}] host must be a string, array of strings, or function`); | ||||
|         } | ||||
|  | ||||
|       // Validate target port | ||||
|       if (action.target.port === undefined) { | ||||
|         errors.push('Target port is required'); | ||||
|       } else if (typeof action.target.port !== 'number' && | ||||
|                 typeof action.target.port !== 'function') { | ||||
|         errors.push('Target port must be a number or a function'); | ||||
|       } else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) { | ||||
|         errors.push('Target port must be between 1 and 65535'); | ||||
|       } | ||||
|         // Validate target port | ||||
|         if (target.port === undefined) { | ||||
|           errors.push(`Target[${index}] port is required`); | ||||
|         } else if (typeof target.port !== 'number' && | ||||
|                   typeof target.port !== 'function' && | ||||
|                   target.port !== 'preserve') { | ||||
|           errors.push(`Target[${index}] port must be a number, 'preserve', or a function`); | ||||
|         } else if (typeof target.port === 'number' && !isValidPort(target.port)) { | ||||
|           errors.push(`Target[${index}] port must be between 1 and 65535`); | ||||
|         } | ||||
|          | ||||
|         // Validate match criteria if present | ||||
|         if (target.match) { | ||||
|           if (target.match.ports && !Array.isArray(target.match.ports)) { | ||||
|             errors.push(`Target[${index}] match.ports must be an array`); | ||||
|           } | ||||
|           if (target.match.method && !Array.isArray(target.match.method)) { | ||||
|             errors.push(`Target[${index}] match.method must be an array`); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // Validate TLS options for forward actions | ||||
| @@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType: | ||||
|  | ||||
|   switch (actionType) { | ||||
|     case 'forward': | ||||
|       return !!route.action.target && !!route.action.target.host && !!route.action.target.port; | ||||
|       return !!route.action.targets &&  | ||||
|              Array.isArray(route.action.targets) &&  | ||||
|              route.action.targets.length > 0 && | ||||
|              route.action.targets.every(t => t.host && t.port !== undefined); | ||||
|     case 'socket-handler': | ||||
|       return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; | ||||
|     default: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user