The previous fix only addressed ForwardingHandler classes but missed the critical setupDirectConnection() method in route-connection-handler.ts where SmartProxy actually handles connections. This caused active connections to rise indefinitely on ECONNREFUSED errors. Changes: - Import createSocketWithErrorHandler in route-connection-handler.ts - Replace net.connect() with createSocketWithErrorHandler() in setupDirectConnection() - Properly clean up connection records when server connection fails - Add connectionFailed flag to prevent setup of failed connections This ensures connection records are cleaned up immediately when backend connections fail, preventing memory leaks.
1390 lines
50 KiB
TypeScript
1390 lines
50 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 { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.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' });
|
|
cleanupSocket(socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
|
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'
|
|
}
|
|
);
|
|
}
|
|
|
|
// Handle the connection - wait for initial data to determine if it's TLS
|
|
this.handleInitialData(socket, record);
|
|
}
|
|
|
|
/**
|
|
* Handle initial data from a connection to determine routing
|
|
*/
|
|
private handleInitialData(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
|
const connectionId = record.id;
|
|
const localPort = record.localPort;
|
|
let initialDataReceived = false;
|
|
|
|
// Check if any routes on this port require TLS handling
|
|
const allRoutes = this.routeManager.getAllRoutes();
|
|
const needsTlsHandling = allRoutes.some(route => {
|
|
// Check if route matches this port
|
|
const matchesPort = this.routeManager.getRoutesForPort(localPort).includes(route);
|
|
|
|
return matchesPort &&
|
|
route.action.type === 'forward' &&
|
|
route.action.tls &&
|
|
(route.action.tls.mode === 'terminate' ||
|
|
route.action.tls.mode === 'passthrough');
|
|
});
|
|
|
|
// If no routes require TLS handling and it's not port 443, route immediately
|
|
if (!needsTlsHandling && localPort !== 443) {
|
|
// Set up error handler
|
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
|
|
|
// Route immediately for non-TLS connections
|
|
this.routeConnection(socket, record, '', undefined);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, wait for initial data to check if it's TLS
|
|
// 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;
|
|
|
|
// Check if this is an HTTP proxy port
|
|
const isHttpProxyPort = this.settings.useHttpProxy?.includes(localPort);
|
|
|
|
// For HTTP proxy ports without TLS, skip domain check since domain info comes from HTTP headers
|
|
const skipDomainCheck = isHttpProxyPort && !record.isTLS;
|
|
|
|
// 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
|
|
skipDomainCheck: skipDomainCheck,
|
|
});
|
|
|
|
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'
|
|
});
|
|
}
|
|
|
|
// Apply route-specific security checks
|
|
if (route.security) {
|
|
// Check IP allow/block lists
|
|
if (route.security.ipAllowList || route.security.ipBlockList) {
|
|
const isIPAllowed = this.securityManager.isIPAuthorized(
|
|
remoteIP,
|
|
route.security.ipAllowList || [],
|
|
route.security.ipBlockList || []
|
|
);
|
|
|
|
if (!isIPAllowed) {
|
|
logger.log('warn', `IP ${remoteIP} blocked by route security for route ${route.name || 'unnamed'} (connection: ${connectionId})`, {
|
|
connectionId,
|
|
remoteIP,
|
|
routeName: route.name || 'unnamed',
|
|
component: 'route-handler'
|
|
});
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'route_ip_blocked');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check max connections per route
|
|
if (route.security.maxConnections !== undefined) {
|
|
// TODO: Implement per-route connection tracking
|
|
// For now, log that this feature is not yet implemented
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('warn', `Route ${route.name} has maxConnections=${route.security.maxConnections} configured but per-route connection limits are not yet implemented`, {
|
|
connectionId,
|
|
routeName: route.name,
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check authentication requirements
|
|
if (route.security.authentication || route.security.basicAuth || route.security.jwtAuth) {
|
|
// Authentication checks would typically happen at the HTTP layer
|
|
// For non-HTTP connections or passthrough, we can't enforce authentication
|
|
if (route.action.type === 'forward' && route.action.tls?.mode !== 'terminate') {
|
|
logger.log('warn', `Route ${route.name} has authentication configured but it cannot be enforced for non-terminated connections`, {
|
|
connectionId,
|
|
routeName: route.name,
|
|
tlsMode: route.action.tls?.mode || 'none',
|
|
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);
|
|
|
|
// Debug logging
|
|
if (this.settings.enableDetailedLogging) {
|
|
logger.log('debug', `Checking HttpProxy forwarding: port=${record.localPort}, useHttpProxy=${JSON.stringify(this.settings.useHttpProxy)}, isHttpProxyPort=${isHttpProxyPort}, hasHttpProxy=${!!this.httpProxyBridge.getHttpProxy()}`, {
|
|
connectionId,
|
|
localPort: record.localPort,
|
|
useHttpProxy: this.settings.useHttpProxy,
|
|
isHttpProxyPort,
|
|
hasHttpProxy: !!this.httpProxyBridge.getHttpProxy(),
|
|
component: 'route-handler'
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Track event listeners added by the handler so we can clean them up
|
|
const originalOn = socket.on.bind(socket);
|
|
const originalOnce = socket.once.bind(socket);
|
|
const trackedListeners: Array<{event: string; listener: (...args: any[]) => void}> = [];
|
|
|
|
// Override socket.on to track listeners
|
|
socket.on = function(event: string, listener: (...args: any[]) => void) {
|
|
trackedListeners.push({event, listener});
|
|
return originalOn(event, listener);
|
|
} as any;
|
|
|
|
// Override socket.once to track listeners
|
|
socket.once = function(event: string, listener: (...args: any[]) => void) {
|
|
trackedListeners.push({event, listener});
|
|
return originalOnce(event, listener);
|
|
} as any;
|
|
|
|
// Set up automatic cleanup when socket closes
|
|
const cleanupHandler = () => {
|
|
// Remove all tracked listeners
|
|
for (const {event, listener} of trackedListeners) {
|
|
socket.removeListener(event, listener);
|
|
}
|
|
// Restore original methods
|
|
socket.on = originalOn;
|
|
socket.once = originalOnce;
|
|
};
|
|
|
|
// Listen for socket close to trigger cleanup
|
|
originalOnce('close', cleanupHandler);
|
|
originalOnce('error', cleanupHandler);
|
|
|
|
// 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'
|
|
});
|
|
// Remove all event listeners before destroying to prevent memory leaks
|
|
socket.removeAllListeners();
|
|
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'
|
|
});
|
|
// Remove all event listeners before destroying to prevent memory leaks
|
|
socket.removeAllListeners();
|
|
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 with immediate error handling
|
|
let targetSocket: plugins.net.Socket;
|
|
|
|
// Flag to track if initial connection failed
|
|
let connectionFailed = false;
|
|
|
|
targetSocket = createSocketWithErrorHandler({
|
|
port: finalTargetPort,
|
|
host: finalTargetHost,
|
|
onError: (error) => {
|
|
// Mark connection as failed
|
|
connectionFailed = true;
|
|
|
|
// Connection failed - clean up immediately
|
|
logger.log('error',
|
|
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${error.message} (${(error as any).code})`,
|
|
{
|
|
connectionId,
|
|
targetHost: finalTargetHost,
|
|
targetPort: finalTargetPort,
|
|
errorMessage: error.message,
|
|
errorCode: (error as any).code,
|
|
component: 'route-handler'
|
|
}
|
|
);
|
|
|
|
// Resume the incoming socket to prevent it from hanging
|
|
socket.resume();
|
|
|
|
// Clean up the incoming socket
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
|
|
// Clean up the connection record
|
|
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
|
}
|
|
});
|
|
|
|
// Only proceed with setup if connection didn't fail immediately
|
|
if (!connectionFailed) {
|
|
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);
|
|
|
|
// Note: Close handlers are managed by independent socket handlers above
|
|
// We don't register handleClose here to avoid bilateral cleanup
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Set up independent socket handlers for half-open connection support
|
|
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
|
socket,
|
|
targetSocket,
|
|
(reason) => {
|
|
this.connectionManager.initiateCleanupOnce(record, reason);
|
|
}
|
|
);
|
|
|
|
// Setup socket handlers with custom timeout handling
|
|
setupSocketHandlers(socket, cleanupClient, (sock) => {
|
|
// Don't close on timeout for keep-alive connections
|
|
if (record.hasKeepAlive) {
|
|
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
|
}
|
|
}, 'client');
|
|
|
|
setupSocketHandlers(targetSocket, cleanupServer, (sock) => {
|
|
// Don't close on timeout for keep-alive connections
|
|
if (record.hasKeepAlive) {
|
|
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
|
}
|
|
}, 'server');
|
|
|
|
// Forward data from client to target with backpressure handling
|
|
socket.on('data', (chunk: Buffer) => {
|
|
record.bytesReceived += chunk.length;
|
|
this.timeoutManager.updateActivity(record);
|
|
|
|
if (targetSocket.writable) {
|
|
const flushed = targetSocket.write(chunk);
|
|
|
|
// Handle backpressure
|
|
if (!flushed) {
|
|
socket.pause();
|
|
targetSocket.once('drain', () => {
|
|
socket.resume();
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Forward data from target to client with backpressure handling
|
|
targetSocket.on('data', (chunk: Buffer) => {
|
|
record.bytesSent += chunk.length;
|
|
this.timeoutManager.updateActivity(record);
|
|
|
|
if (socket.writable) {
|
|
const flushed = socket.write(chunk);
|
|
|
|
// Handle backpressure
|
|
if (!flushed) {
|
|
targetSocket.pause();
|
|
socket.once('drain', () => {
|
|
targetSocket.resume();
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
} // End of if (!connectionFailed)
|
|
}
|
|
} |