1173 lines
42 KiB
TypeScript
1173 lines
42 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
|
|
import { logger } from '../../core/utils/logger.js';
|
|
// Route checking functions have been removed
|
|
import type { IRouteConfig, IRouteAction, IRouteContext } from './models/route-types.js';
|
|
import { ConnectionManager } from './connection-manager.js';
|
|
import { SecurityManager } from './security-manager.js';
|
|
import { TlsManager } from './tls-manager.js';
|
|
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
|
import { TimeoutManager } from './timeout-manager.js';
|
|
import { RouteManager } from './route-manager.js';
|
|
import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.js';
|
|
|
|
/**
|
|
* Handles new connection processing and setup logic with support for route-based configuration
|
|
*/
|
|
export class RouteConnectionHandler {
|
|
private settings: ISmartProxyOptions;
|
|
|
|
// Cache for route contexts to avoid recreation
|
|
private routeContextCache: Map<string, IRouteContext> = new Map();
|
|
|
|
constructor(
|
|
settings: ISmartProxyOptions,
|
|
private connectionManager: ConnectionManager,
|
|
private securityManager: SecurityManager,
|
|
private tlsManager: TlsManager,
|
|
private httpProxyBridge: HttpProxyBridge,
|
|
private timeoutManager: TimeoutManager,
|
|
private routeManager: RouteManager
|
|
) {
|
|
this.settings = settings;
|
|
}
|
|
|
|
/**
|
|
* Create a route context object for port and host mapping functions
|
|
*/
|
|
private createRouteContext(options: {
|
|
connectionId: string;
|
|
port: number;
|
|
domain?: string;
|
|
clientIp: string;
|
|
serverIp: string;
|
|
isTls: boolean;
|
|
tlsVersion?: string;
|
|
routeName?: string;
|
|
routeId?: string;
|
|
path?: string;
|
|
query?: string;
|
|
headers?: Record<string, string>;
|
|
}): IRouteContext {
|
|
return {
|
|
// Connection information
|
|
port: options.port,
|
|
domain: options.domain,
|
|
clientIp: options.clientIp,
|
|
serverIp: options.serverIp,
|
|
path: options.path,
|
|
query: options.query,
|
|
headers: options.headers,
|
|
|
|
// TLS information
|
|
isTls: options.isTls,
|
|
tlsVersion: options.tlsVersion,
|
|
|
|
// Route information
|
|
routeName: options.routeName,
|
|
routeId: options.routeId,
|
|
|
|
// Additional properties
|
|
timestamp: Date.now(),
|
|
connectionId: options.connectionId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle a new incoming connection
|
|
*/
|
|
public handleConnection(socket: plugins.net.Socket): void {
|
|
const remoteIP = socket.remoteAddress || '';
|
|
const localPort = socket.localPort || 0;
|
|
|
|
// Validate IP against rate limits and connection limits
|
|
const ipValidation = this.securityManager.validateIP(remoteIP);
|
|
if (!ipValidation.allowed) {
|
|
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
|
socket.end();
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
// Create a new connection record
|
|
const record = this.connectionManager.createConnection(socket);
|
|
const connectionId = record.id;
|
|
|
|
// Apply socket optimizations
|
|
socket.setNoDelay(this.settings.noDelay);
|
|
|
|
// Apply keep-alive settings if enabled
|
|
if (this.settings.keepAlive) {
|
|
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
|
record.hasKeepAlive = true;
|
|
|
|
// Apply enhanced TCP keep-alive options if enabled
|
|
if (this.settings.enableKeepAliveProbes) {
|
|
try {
|
|
// These are platform-specific and may not be available
|
|
if ('setKeepAliveProbes' in socket) {
|
|
(socket as any).setKeepAliveProbes(10);
|
|
}
|
|
if ('setKeepAliveInterval' in socket) {
|
|
(socket as any).setKeepAliveInterval(1000);
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors - these are optional enhancements
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('warn', `Enhanced TCP keep-alive settings not supported`, { connectionId, error: err, component: 'route-handler' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info',
|
|
`New connection from ${remoteIP} on port ${localPort}. ` +
|
|
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
|
|
`Active connections: ${this.connectionManager.getConnectionCount()}`,
|
|
{
|
|
connectionId,
|
|
remoteIP,
|
|
localPort,
|
|
keepAlive: record.hasKeepAlive ? 'Enabled' : 'Disabled',
|
|
activeConnections: this.connectionManager.getConnectionCount(),
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
} else {
|
|
logger.log('info',
|
|
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}`,
|
|
{
|
|
remoteIP,
|
|
localPort,
|
|
activeConnections: this.connectionManager.getConnectionCount(),
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
}
|
|
|
|
// Start TLS SNI handling
|
|
this.handleTlsConnection(socket, record);
|
|
}
|
|
|
|
/**
|
|
* Handle a connection and wait for TLS handshake for SNI extraction if needed
|
|
*/
|
|
private handleTlsConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
|
const connectionId = record.id;
|
|
const localPort = record.localPort;
|
|
let initialDataReceived = false;
|
|
|
|
// Set an initial timeout for handshake data
|
|
let initialTimeout: NodeJS.Timeout | null = setTimeout(() => {
|
|
if (!initialDataReceived) {
|
|
logger.log('warn', `No initial data received from ${record.remoteIP} after ${this.settings.initialDataTimeout}ms for connection ${connectionId}`, {
|
|
connectionId,
|
|
timeout: this.settings.initialDataTimeout,
|
|
remoteIP: record.remoteIP,
|
|
component: 'route-handler'
|
|
});
|
|
|
|
// Add a grace period
|
|
setTimeout(() => {
|
|
if (!initialDataReceived) {
|
|
logger.log('warn', `Final initial data timeout after grace period for connection ${connectionId}`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'initial_timeout';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'initial_timeout');
|
|
}
|
|
}, 30000);
|
|
}
|
|
}, this.settings.initialDataTimeout!);
|
|
|
|
// Make sure timeout doesn't keep the process alive
|
|
if (initialTimeout.unref) {
|
|
initialTimeout.unref();
|
|
}
|
|
|
|
// Set up error handler
|
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
|
|
|
// First data handler to capture initial TLS handshake
|
|
socket.once('data', (chunk: Buffer) => {
|
|
// Clear the initial timeout since we've received data
|
|
if (initialTimeout) {
|
|
clearTimeout(initialTimeout);
|
|
initialTimeout = null;
|
|
}
|
|
|
|
initialDataReceived = true;
|
|
record.hasReceivedInitialData = true;
|
|
|
|
// Block non-TLS connections on port 443
|
|
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
|
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
|
connectionId,
|
|
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
|
|
component: 'route-handler'
|
|
});
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'non_tls_blocked';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
|
|
return;
|
|
}
|
|
|
|
// Check if this looks like a TLS handshake
|
|
let serverName = '';
|
|
if (this.tlsManager.isTlsHandshake(chunk)) {
|
|
record.isTLS = true;
|
|
|
|
// Check for ClientHello to extract SNI
|
|
if (this.tlsManager.isClientHello(chunk)) {
|
|
// Create connection info for SNI extraction
|
|
const connInfo = {
|
|
sourceIp: record.remoteIP,
|
|
sourcePort: socket.remotePort || 0,
|
|
destIp: socket.localAddress || '',
|
|
destPort: socket.localPort || 0,
|
|
};
|
|
|
|
// Extract SNI
|
|
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
|
|
|
|
// Lock the connection to the negotiated SNI
|
|
record.lockedDomain = serverName;
|
|
|
|
// Check if we should reject connections without SNI
|
|
if (!serverName && this.settings.allowSessionTicket === false) {
|
|
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${connectionId}; sending TLS alert`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
|
this.connectionManager.incrementTerminationStat(
|
|
'incoming',
|
|
'session_ticket_blocked_no_sni'
|
|
);
|
|
}
|
|
const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]);
|
|
try {
|
|
socket.cork();
|
|
socket.write(alert);
|
|
socket.uncork();
|
|
socket.end();
|
|
} catch {
|
|
socket.end();
|
|
}
|
|
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
|
return;
|
|
}
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `TLS connection with SNI`, {
|
|
connectionId,
|
|
serverName: serverName || '(empty)',
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find the appropriate route for this connection
|
|
this.routeConnection(socket, record, serverName, chunk);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Route the connection based on match criteria
|
|
*/
|
|
private routeConnection(
|
|
socket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
serverName: string,
|
|
initialChunk?: Buffer
|
|
): void {
|
|
const connectionId = record.id;
|
|
const localPort = record.localPort;
|
|
const remoteIP = record.remoteIP;
|
|
|
|
// Find matching route
|
|
const routeMatch = this.routeManager.findMatchingRoute({
|
|
port: localPort,
|
|
domain: serverName,
|
|
clientIp: remoteIP,
|
|
path: undefined, // We don't have path info at this point
|
|
tlsVersion: undefined, // We don't extract TLS version yet
|
|
});
|
|
|
|
if (!routeMatch) {
|
|
logger.log('warn', `No route found for ${serverName || 'connection'} on port ${localPort} (connection: ${connectionId})`, {
|
|
connectionId,
|
|
serverName: serverName || 'connection',
|
|
localPort,
|
|
component: 'route-handler'
|
|
});
|
|
|
|
// No matching route, use default/fallback handling
|
|
logger.log('info', `Using default route handling for connection ${connectionId}`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
|
|
// Check default security settings
|
|
const defaultSecuritySettings = this.settings.defaults?.security;
|
|
if (defaultSecuritySettings) {
|
|
if (defaultSecuritySettings.ipAllowList && defaultSecuritySettings.ipAllowList.length > 0) {
|
|
const isAllowed = this.securityManager.isIPAuthorized(
|
|
remoteIP,
|
|
defaultSecuritySettings.ipAllowList,
|
|
defaultSecuritySettings.ipBlockList || []
|
|
);
|
|
|
|
if (!isAllowed) {
|
|
logger.log('warn', `IP ${remoteIP} not in default allowed list for connection ${connectionId}`, {
|
|
connectionId,
|
|
remoteIP,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'ip_blocked');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup direct connection with default settings
|
|
if (this.settings.defaults?.target) {
|
|
// Use defaults from configuration
|
|
const targetHost = this.settings.defaults.target.host;
|
|
const targetPort = this.settings.defaults.target.port;
|
|
|
|
return this.setupDirectConnection(
|
|
socket,
|
|
record,
|
|
serverName,
|
|
initialChunk,
|
|
undefined,
|
|
targetHost,
|
|
targetPort
|
|
);
|
|
} else {
|
|
// No default target available, terminate the connection
|
|
logger.log('warn', `No default target configured for connection ${connectionId}. Closing connection`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'no_default_target');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// A matching route was found
|
|
const route = routeMatch.route;
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Route matched`, {
|
|
connectionId,
|
|
routeName: route.name || 'unnamed',
|
|
serverName: serverName || 'connection',
|
|
localPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
|
|
// Handle the route based on its action type
|
|
switch (route.action.type) {
|
|
case 'forward':
|
|
return this.handleForwardAction(socket, record, route, initialChunk);
|
|
|
|
case 'socket-handler':
|
|
logger.log('info', `Handling socket-handler action for route ${route.name}`, {
|
|
connectionId,
|
|
routeName: route.name,
|
|
component: 'route-handler'
|
|
});
|
|
this.handleSocketHandlerAction(socket, record, route, initialChunk);
|
|
return;
|
|
|
|
default:
|
|
logger.log('error', `Unknown action type '${(route.action as any).type}' for connection ${connectionId}`, {
|
|
connectionId,
|
|
actionType: (route.action as any).type,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'unknown_action');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a forward action for a route
|
|
*/
|
|
private handleForwardAction(
|
|
socket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
route: IRouteConfig,
|
|
initialChunk?: Buffer
|
|
): void {
|
|
const connectionId = record.id;
|
|
const action = route.action as IRouteAction;
|
|
|
|
// Check if this route uses NFTables for forwarding
|
|
if (action.forwardingEngine === 'nftables') {
|
|
// NFTables handles packet forwarding at the kernel level
|
|
// The application should NOT interfere with these connections
|
|
|
|
// Log the connection for monitoring purposes
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `NFTables forwarding (kernel-level)`, {
|
|
connectionId: record.id,
|
|
source: `${record.remoteIP}:${socket.remotePort}`,
|
|
destination: `${socket.localAddress}:${record.localPort}`,
|
|
routeName: route.name || 'unnamed',
|
|
domain: record.lockedDomain || 'n/a',
|
|
component: 'route-handler'
|
|
});
|
|
} else {
|
|
logger.log('info', `NFTables forwarding`, {
|
|
connectionId: record.id,
|
|
remoteIP: record.remoteIP,
|
|
localPort: record.localPort,
|
|
routeName: route.name || 'unnamed',
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
// Additional NFTables-specific logging if configured
|
|
if (action.nftables) {
|
|
const nftConfig = action.nftables;
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `NFTables config`, {
|
|
connectionId: record.id,
|
|
protocol: nftConfig.protocol || 'tcp',
|
|
preserveSourceIP: nftConfig.preserveSourceIP || false,
|
|
priority: nftConfig.priority || 'default',
|
|
maxRate: nftConfig.maxRate || 'unlimited',
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
}
|
|
|
|
// For NFTables routes, we should still track the connection but not interfere
|
|
// Mark the connection as using network proxy so it's cleaned up properly
|
|
record.usingNetworkProxy = true;
|
|
|
|
// We don't close the socket - just let it remain open
|
|
// The kernel-level NFTables rules will handle the actual forwarding
|
|
return;
|
|
}
|
|
|
|
// We should have a target configuration for forwarding
|
|
if (!action.target) {
|
|
logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'missing_target');
|
|
return;
|
|
}
|
|
|
|
// Create the routing context for this connection
|
|
const routeContext = this.createRouteContext({
|
|
connectionId: record.id,
|
|
port: record.localPort,
|
|
domain: record.lockedDomain,
|
|
clientIp: record.remoteIP,
|
|
serverIp: socket.localAddress || '',
|
|
isTls: record.isTLS || false,
|
|
tlsVersion: record.tlsVersion,
|
|
routeName: route.name,
|
|
routeId: route.id,
|
|
});
|
|
|
|
// Cache the context for potential reuse
|
|
this.routeContextCache.set(connectionId, routeContext);
|
|
|
|
// Determine host using function or static value
|
|
let targetHost: string | string[];
|
|
if (typeof action.target.host === 'function') {
|
|
try {
|
|
targetHost = action.target.host(routeContext);
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, {
|
|
connectionId,
|
|
targetHost: Array.isArray(targetHost) ? targetHost.join(', ') : targetHost,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
logger.log('error', `Error in host mapping function for connection ${connectionId}: ${err}`, {
|
|
connectionId,
|
|
error: err,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'host_mapping_error');
|
|
return;
|
|
}
|
|
} else {
|
|
targetHost = action.target.host;
|
|
}
|
|
|
|
// If an array of hosts, select one randomly for load balancing
|
|
const selectedHost = Array.isArray(targetHost)
|
|
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
|
: targetHost;
|
|
|
|
// Determine port using function or static value
|
|
let targetPort: number;
|
|
if (typeof action.target.port === 'function') {
|
|
try {
|
|
targetPort = action.target.port(routeContext);
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, {
|
|
connectionId,
|
|
sourcePort: record.localPort,
|
|
targetPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
// Store the resolved target port in the context for potential future use
|
|
routeContext.targetPort = targetPort;
|
|
} catch (err) {
|
|
logger.log('error', `Error in port mapping function for connection ${connectionId}: ${err}`, {
|
|
connectionId,
|
|
error: err,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'port_mapping_error');
|
|
return;
|
|
}
|
|
} else if (action.target.port === 'preserve') {
|
|
// Use incoming port if port is 'preserve'
|
|
targetPort = record.localPort;
|
|
} else {
|
|
// Use static port from configuration
|
|
targetPort = action.target.port;
|
|
}
|
|
|
|
// Store the resolved host in the context
|
|
routeContext.targetHost = selectedHost;
|
|
|
|
// Determine if this needs TLS handling
|
|
if (action.tls) {
|
|
switch (action.tls.mode) {
|
|
case 'passthrough':
|
|
// For TLS passthrough, just forward directly
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Using TLS passthrough to ${selectedHost}:${targetPort} for connection ${connectionId}`, {
|
|
connectionId,
|
|
targetHost: selectedHost,
|
|
targetPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
return this.setupDirectConnection(
|
|
socket,
|
|
record,
|
|
record.lockedDomain,
|
|
initialChunk,
|
|
undefined,
|
|
selectedHost,
|
|
targetPort
|
|
);
|
|
|
|
case 'terminate':
|
|
case 'terminate-and-reencrypt':
|
|
// For TLS termination, use HttpProxy
|
|
if (this.httpProxyBridge.getHttpProxy()) {
|
|
if (this.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}`, {
|
|
connectionId,
|
|
targetHost: action.target.host,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
// If we have an initial chunk with TLS data, start processing it
|
|
if (initialChunk && record.isTLS) {
|
|
this.httpProxyBridge.forwardToHttpProxy(
|
|
connectionId,
|
|
socket,
|
|
record,
|
|
initialChunk,
|
|
this.settings.httpProxyPort || 8443,
|
|
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// This shouldn't normally happen - we should have TLS data at this point
|
|
logger.log('error', `TLS termination route without TLS data for connection ${connectionId}`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'tls_error');
|
|
return;
|
|
} else {
|
|
logger.log('error', `HttpProxy not available for TLS termination for connection ${connectionId}`, {
|
|
connectionId,
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'no_http_proxy');
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
// No TLS settings - check if this port should use HttpProxy
|
|
const isHttpProxyPort = this.settings.useHttpProxy?.includes(record.localPort);
|
|
|
|
if (isHttpProxyPort && this.httpProxyBridge.getHttpProxy()) {
|
|
// Forward non-TLS connections to HttpProxy if configured
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Using HttpProxy for non-TLS connection ${connectionId} on port ${record.localPort}`, {
|
|
connectionId,
|
|
port: record.localPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
this.httpProxyBridge.forwardToHttpProxy(
|
|
connectionId,
|
|
socket,
|
|
record,
|
|
initialChunk,
|
|
this.settings.httpProxyPort || 8443,
|
|
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
return;
|
|
} else {
|
|
// Basic forwarding
|
|
if (this.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}`, {
|
|
connectionId,
|
|
targetHost: action.target.host,
|
|
targetPort: action.target.port,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
// Get the appropriate host value
|
|
let targetHost: string;
|
|
|
|
if (typeof action.target.host === 'function') {
|
|
// For function-based host, use the same routeContext created earlier
|
|
const hostResult = action.target.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;
|
|
}
|
|
|
|
// 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') {
|
|
targetPort = record.localPort;
|
|
} else {
|
|
targetPort = action.target.port;
|
|
}
|
|
|
|
// Update the connection record and context with resolved values
|
|
record.targetHost = targetHost;
|
|
record.targetPort = targetPort;
|
|
|
|
return this.setupDirectConnection(
|
|
socket,
|
|
record,
|
|
record.lockedDomain,
|
|
initialChunk,
|
|
undefined,
|
|
targetHost,
|
|
targetPort
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a socket-handler action for a route
|
|
*/
|
|
private async handleSocketHandlerAction(
|
|
socket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
route: IRouteConfig,
|
|
initialChunk?: Buffer
|
|
): Promise<void> {
|
|
const connectionId = record.id;
|
|
|
|
if (!route.action.socketHandler) {
|
|
logger.log('error', 'socket-handler action missing socketHandler function', {
|
|
connectionId,
|
|
routeName: route.name,
|
|
component: 'route-handler'
|
|
});
|
|
socket.destroy();
|
|
this.connectionManager.cleanupConnection(record, 'missing_handler');
|
|
return;
|
|
}
|
|
|
|
// Create route context for the handler
|
|
const routeContext = this.createRouteContext({
|
|
connectionId: record.id,
|
|
port: record.localPort,
|
|
domain: record.lockedDomain,
|
|
clientIp: record.remoteIP,
|
|
serverIp: socket.localAddress || '',
|
|
isTls: record.isTLS || false,
|
|
tlsVersion: record.tlsVersion,
|
|
routeName: route.name,
|
|
routeId: route.id,
|
|
});
|
|
|
|
try {
|
|
// Call the handler with socket AND context
|
|
const result = route.action.socketHandler(socket, routeContext);
|
|
|
|
// Handle async handlers properly
|
|
if (result instanceof Promise) {
|
|
result
|
|
.then(() => {
|
|
// Emit initial chunk after async handler completes
|
|
if (initialChunk && initialChunk.length > 0) {
|
|
socket.emit('data', initialChunk);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
logger.log('error', 'Socket handler error', {
|
|
connectionId,
|
|
routeName: route.name,
|
|
error: error.message,
|
|
component: 'route-handler'
|
|
});
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
this.connectionManager.cleanupConnection(record, 'handler_error');
|
|
});
|
|
} else {
|
|
// For sync handlers, emit on next tick
|
|
if (initialChunk && initialChunk.length > 0) {
|
|
process.nextTick(() => {
|
|
socket.emit('data', initialChunk);
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.log('error', 'Socket handler error', {
|
|
connectionId,
|
|
routeName: route.name,
|
|
error: error.message,
|
|
component: 'route-handler'
|
|
});
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
this.connectionManager.cleanupConnection(record, 'handler_error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup improved error handling for the outgoing connection
|
|
*/
|
|
private setupOutgoingErrorHandler(
|
|
connectionId: string,
|
|
targetSocket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
socket: plugins.net.Socket,
|
|
finalTargetHost: string,
|
|
finalTargetPort: number
|
|
): void {
|
|
targetSocket.once('error', (err) => {
|
|
// This handler runs only once during the initial connection phase
|
|
const code = (err as any).code;
|
|
logger.log('error',
|
|
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${err.message} (${code})`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
errorMessage: err.message,
|
|
errorCode: code,
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
|
|
// Resume the incoming socket to prevent it from hanging
|
|
socket.resume();
|
|
|
|
// Log specific error types for easier debugging
|
|
if (code === 'ECONNREFUSED') {
|
|
logger.log('error',
|
|
`Connection ${connectionId}: Target ${finalTargetHost}:${finalTargetPort} refused connection. Check if the target service is running and listening on that port.`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
recommendation: 'Check if the target service is running and listening on that port.',
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
} else if (code === 'ETIMEDOUT') {
|
|
logger.log('error',
|
|
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} timed out. Check network conditions, firewall rules, or if the target is too far away.`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
recommendation: 'Check network conditions, firewall rules, or if the target is too far away.',
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
} else if (code === 'ECONNRESET') {
|
|
logger.log('error',
|
|
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} was reset. The target might have closed the connection abruptly.`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
recommendation: 'The target might have closed the connection abruptly.',
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
} else if (code === 'EHOSTUNREACH') {
|
|
logger.log('error',
|
|
`Connection ${connectionId}: Host ${finalTargetHost} is unreachable. Check DNS settings, network routing, or firewall rules.`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
recommendation: 'Check DNS settings, network routing, or firewall rules.',
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
} else if (code === 'ENOTFOUND') {
|
|
logger.log('error',
|
|
`Connection ${connectionId}: DNS lookup failed for ${finalTargetHost}. Check your DNS settings or if the hostname is correct.`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
recommendation: 'Check your DNS settings or if the hostname is correct.',
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
}
|
|
|
|
// Clear any existing error handler after connection phase
|
|
targetSocket.removeAllListeners('error');
|
|
|
|
// Re-add the normal error handler for established connections
|
|
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
|
|
|
if (record.outgoingTerminationReason === null) {
|
|
record.outgoingTerminationReason = 'connection_failed';
|
|
this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed');
|
|
}
|
|
|
|
// Clean up the connection
|
|
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets up a direct connection to the target
|
|
*/
|
|
private setupDirectConnection(
|
|
socket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
serverName?: string,
|
|
initialChunk?: Buffer,
|
|
overridePort?: number,
|
|
targetHost?: string,
|
|
targetPort?: number
|
|
): void {
|
|
const connectionId = record.id;
|
|
|
|
// Determine target host and port if not provided
|
|
const finalTargetHost =
|
|
targetHost || record.targetHost || this.settings.defaults?.target?.host || 'localhost';
|
|
|
|
// Determine target port
|
|
const finalTargetPort =
|
|
targetPort ||
|
|
record.targetPort ||
|
|
(overridePort !== undefined ? overridePort : this.settings.defaults?.target?.port || 443);
|
|
|
|
// Update record with final target information
|
|
record.targetHost = finalTargetHost;
|
|
record.targetPort = finalTargetPort;
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Setting up direct connection ${connectionId} to ${finalTargetHost}:${finalTargetPort}`, {
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
// Setup connection options
|
|
const connectionOptions: plugins.net.NetConnectOpts = {
|
|
host: finalTargetHost,
|
|
port: finalTargetPort,
|
|
};
|
|
|
|
// Preserve source IP if configured
|
|
if (this.settings.defaults?.preserveSourceIP || this.settings.preserveSourceIP) {
|
|
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
|
|
}
|
|
|
|
// Store initial data if provided
|
|
if (initialChunk) {
|
|
record.bytesReceived += initialChunk.length;
|
|
record.pendingData.push(Buffer.from(initialChunk));
|
|
record.pendingDataSize = initialChunk.length;
|
|
}
|
|
|
|
// Create the target socket
|
|
const targetSocket = plugins.net.connect(connectionOptions);
|
|
record.outgoing = targetSocket;
|
|
record.outgoingStartTime = Date.now();
|
|
|
|
// Apply socket optimizations
|
|
targetSocket.setNoDelay(this.settings.noDelay);
|
|
|
|
// Apply keep-alive settings if enabled
|
|
if (this.settings.keepAlive) {
|
|
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
|
|
|
// Apply enhanced TCP keep-alive options if enabled
|
|
if (this.settings.enableKeepAliveProbes) {
|
|
try {
|
|
if ('setKeepAliveProbes' in targetSocket) {
|
|
(targetSocket as any).setKeepAliveProbes(10);
|
|
}
|
|
if ('setKeepAliveInterval' in targetSocket) {
|
|
(targetSocket as any).setKeepAliveInterval(1000);
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors - these are optional enhancements
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('warn', `Enhanced TCP keep-alive not supported for outgoing socket on connection ${connectionId}: ${err}`, {
|
|
connectionId,
|
|
error: err,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup improved error handling for outgoing connection
|
|
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
|
|
|
// Setup close handlers
|
|
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
|
|
socket.on('close', this.connectionManager.handleClose('incoming', record));
|
|
|
|
// Setup error handlers for incoming socket
|
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
|
|
|
// Handle timeouts with keep-alive awareness
|
|
socket.on('timeout', () => {
|
|
// For keep-alive connections, just log a warning instead of closing
|
|
if (record.hasKeepAlive) {
|
|
logger.log('warn', `Timeout event on incoming keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`, {
|
|
connectionId,
|
|
remoteIP: record.remoteIP,
|
|
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
|
|
status: 'Connection preserved',
|
|
component: 'route-handler'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// For non-keep-alive connections, proceed with normal cleanup
|
|
logger.log('warn', `Timeout on incoming side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`, {
|
|
connectionId,
|
|
remoteIP: record.remoteIP,
|
|
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
|
|
component: 'route-handler'
|
|
});
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'timeout';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
|
|
}
|
|
this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming');
|
|
});
|
|
|
|
targetSocket.on('timeout', () => {
|
|
// For keep-alive connections, just log a warning instead of closing
|
|
if (record.hasKeepAlive) {
|
|
logger.log('warn', `Timeout event on outgoing keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`, {
|
|
connectionId,
|
|
remoteIP: record.remoteIP,
|
|
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
|
|
status: 'Connection preserved',
|
|
component: 'route-handler'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// For non-keep-alive connections, proceed with normal cleanup
|
|
logger.log('warn', `Timeout on outgoing side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`, {
|
|
connectionId,
|
|
remoteIP: record.remoteIP,
|
|
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
|
|
component: 'route-handler'
|
|
});
|
|
if (record.outgoingTerminationReason === null) {
|
|
record.outgoingTerminationReason = 'timeout';
|
|
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
|
|
}
|
|
this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing');
|
|
});
|
|
|
|
// Apply socket timeouts
|
|
this.timeoutManager.applySocketTimeouts(record);
|
|
|
|
// Track outgoing data for bytes counting
|
|
targetSocket.on('data', (chunk: Buffer) => {
|
|
record.bytesSent += chunk.length;
|
|
this.timeoutManager.updateActivity(record);
|
|
});
|
|
|
|
// Wait for the outgoing connection to be ready before setting up piping
|
|
targetSocket.once('connect', () => {
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
// Clear the initial connection error handler
|
|
targetSocket.removeAllListeners('error');
|
|
|
|
// Add the normal error handler for established connections
|
|
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
|
|
|
// Flush any pending data to target
|
|
if (record.pendingData.length > 0) {
|
|
const combinedData = Buffer.concat(record.pendingData);
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
|
);
|
|
}
|
|
|
|
// Write pending data immediately
|
|
targetSocket.write(combinedData, (err) => {
|
|
if (err) {
|
|
logger.log('error', `Error writing pending data to target for connection ${connectionId}: ${err.message}`, {
|
|
connectionId,
|
|
error: err.message,
|
|
component: 'route-handler'
|
|
});
|
|
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
|
|
}
|
|
});
|
|
|
|
// Clear the buffer now that we've processed it
|
|
record.pendingData = [];
|
|
record.pendingDataSize = 0;
|
|
}
|
|
|
|
// Immediately setup bidirectional piping - much simpler than manual data management
|
|
socket.pipe(targetSocket);
|
|
targetSocket.pipe(socket);
|
|
|
|
// Track incoming data for bytes counting - do this after piping is set up
|
|
socket.on('data', (chunk: Buffer) => {
|
|
record.bytesReceived += chunk.length;
|
|
this.timeoutManager.updateActivity(record);
|
|
});
|
|
|
|
// Log successful connection
|
|
logger.log('info',
|
|
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
|
|
`${serverName ? ` (SNI: ${serverName})` : record.lockedDomain ? ` (Domain: ${record.lockedDomain})` : ''}`,
|
|
{
|
|
remoteIP: record.remoteIP,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
sni: serverName || undefined,
|
|
domain: !serverName && record.lockedDomain ? record.lockedDomain : undefined,
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
|
|
// Add TLS renegotiation handler if needed
|
|
if (serverName) {
|
|
// Create connection info object for the existing connection
|
|
const connInfo = {
|
|
sourceIp: record.remoteIP,
|
|
sourcePort: record.incoming.remotePort || 0,
|
|
destIp: record.incoming.localAddress || '',
|
|
destPort: record.incoming.localPort || 0,
|
|
};
|
|
|
|
// Create a renegotiation handler function
|
|
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
|
connectionId,
|
|
serverName,
|
|
connInfo,
|
|
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
|
|
// Store the handler in the connection record so we can remove it during cleanup
|
|
record.renegotiationHandler = renegotiationHandler;
|
|
|
|
// Add the handler to the socket
|
|
socket.on('data', renegotiationHandler);
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
|
|
connectionId,
|
|
serverName,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Set connection timeout
|
|
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
|
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
|
|
connectionId,
|
|
remoteIP: record.remoteIP,
|
|
component: 'route-handler'
|
|
});
|
|
this.connectionManager.initiateCleanupOnce(record, reason);
|
|
});
|
|
|
|
// Mark TLS handshake as complete for TLS connections
|
|
if (record.isTLS) {
|
|
record.tlsHandshakeComplete = true;
|
|
}
|
|
});
|
|
}
|
|
} |