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