smartproxy/ts/classes.portproxy.ts
Philipp Kunz 7850a80452 fix(PortProxy): Fix TypeScript errors by using correct variable names
Fixed TypeScript errors caused by using 'connectionRecord' instead of 'record' in TLS renegotiation handlers.
The variable name mistake occurred when moving and restructuring the TLS handshake detection code.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-11 02:47:57 +00:00

2300 lines
86 KiB
TypeScript

import * as plugins from './plugins.js';
import { NetworkProxy } from './classes.networkproxy.js';
/** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
blockedIPs?: string[]; // Glob patterns for blocked IPs
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
// Allow domain-specific timeout override
connectionTimeout?: number; // Connection timeout override (ms)
// New properties for NetworkProxy integration
useNetworkProxy?: boolean; // When true, forwards TLS connections to NetworkProxy
networkProxyIndex?: number; // Optional index to specify which NetworkProxy to use (defaults to 0)
}
/**
* Port proxy settings including global allowed port ranges
*
* NOTE: In version 3.31.0+, timeout settings have been simplified and hardcoded with sensible defaults
* to ensure TLS certificate safety in all deployment scenarios, especially chained proxies.
*/
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// Simplified timeout settings
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
// Ranged port settings
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
// Socket optimization settings
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
keepAlive?: boolean; // Enable TCP keepalive (default: true)
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
// Logging settings
enableDetailedLogging?: boolean; // Enable detailed connection logging
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
// NetworkProxy integration
networkProxies?: NetworkProxy[]; // Array of NetworkProxy instances to use for TLS termination
}
/**
* Enhanced connection record
*/
interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup
pendingDataSize: number; // Track total size of pending data
// Enhanced tracking fields
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
hasReceivedInitialData: boolean; // Whether initial data has been received
domainConfig?: IDomainConfig; // Associated domain config for this connection
// Keep-alive tracking
hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection
inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued
incomingTerminationReason?: string | null; // Reason for incoming termination
outgoingTerminationReason?: string | null; // Reason for outgoing termination
// New field for NetworkProxy tracking
usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy
networkProxyIndex?: number; // Which NetworkProxy instance is being used
// Sleep detection fields
possibleSystemSleep?: boolean; // Flag to indicate a possible system sleep was detected
lastSleepDetection?: number; // Timestamp of the last sleep detection
}
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* Enhanced for robustness and detailed logging.
* @param buffer - Buffer containing the TLS ClientHello.
* @param enableLogging - Whether to enable detailed logging.
* @returns The server name if found, otherwise undefined.
*/
function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
try {
// Check if buffer is too small for TLS
if (buffer.length < 5) {
if (enableLogging) console.log('Buffer too small for TLS header');
return undefined;
}
// Check record type (has to be handshake - 22)
const recordType = buffer.readUInt8(0);
if (recordType !== 22) {
if (enableLogging) console.log(`Not a TLS handshake. Record type: ${recordType}`);
return undefined;
}
// Check TLS version (has to be 3.1 or higher)
const majorVersion = buffer.readUInt8(1);
const minorVersion = buffer.readUInt8(2);
if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion}`);
// Check record length
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) {
if (enableLogging)
console.log(
`Buffer too small for TLS record. Expected: ${5 + recordLength}, Got: ${buffer.length}`
);
return undefined;
}
let offset = 5;
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) {
if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`);
return undefined;
}
offset += 4; // Skip handshake header (type + length)
// Client version
const clientMajorVersion = buffer.readUInt8(offset);
const clientMinorVersion = buffer.readUInt8(offset + 1);
if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`);
offset += 2 + 32; // Skip client version and random
// Session ID
const sessionIDLength = buffer.readUInt8(offset);
if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`);
offset += 1 + sessionIDLength; // Skip session ID
// Cipher suites
if (offset + 2 > buffer.length) {
if (enableLogging) console.log('Buffer too small for cipher suites length');
return undefined;
}
const cipherSuitesLength = buffer.readUInt16BE(offset);
if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`);
offset += 2 + cipherSuitesLength; // Skip cipher suites
// Compression methods
if (offset + 1 > buffer.length) {
if (enableLogging) console.log('Buffer too small for compression methods length');
return undefined;
}
const compressionMethodsLength = buffer.readUInt8(offset);
if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`);
offset += 1 + compressionMethodsLength; // Skip compression methods
// Extensions
if (offset + 2 > buffer.length) {
if (enableLogging) console.log('Buffer too small for extensions length');
return undefined;
}
const extensionsLength = buffer.readUInt16BE(offset);
if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`);
offset += 2;
const extensionsEnd = offset + extensionsLength;
if (extensionsEnd > buffer.length) {
if (enableLogging)
console.log(
`Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${buffer.length}`
);
return undefined;
}
// Parse extensions
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
if (enableLogging)
console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`);
offset += 4;
if (extensionType === 0x0000) {
// SNI extension
if (offset + 2 > buffer.length) {
if (enableLogging) console.log('Buffer too small for SNI list length');
return undefined;
}
const sniListLength = buffer.readUInt16BE(offset);
if (enableLogging) console.log(`SNI List Length: ${sniListLength}`);
offset += 2;
const sniListEnd = offset + sniListLength;
if (sniListEnd > buffer.length) {
if (enableLogging)
console.log(
`Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${buffer.length}`
);
return undefined;
}
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset++);
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`);
if (nameType === 0) {
// host_name
if (offset + nameLen > buffer.length) {
if (enableLogging)
console.log(
`Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${
buffer.length
}`
);
return undefined;
}
const serverName = buffer.toString('utf8', offset, offset + nameLen);
if (enableLogging) console.log(`Extracted SNI: ${serverName}`);
return serverName;
}
offset += nameLen;
}
break;
} else {
offset += extensionLength;
}
}
if (enableLogging) console.log('No SNI extension found');
return undefined;
} catch (err) {
console.log(`Error extracting SNI: ${err}`);
return undefined;
}
}
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some((range) => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns
const isAllowed = (ip: string, patterns: string[]): boolean => {
if (!ip || !patterns || patterns.length === 0) return false;
const normalizeIP = (ip: string): string[] => {
if (!ip) return [];
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
if (normalizedIPVariants.length === 0) return false;
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some((ipVariant) =>
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
);
};
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
if (!ip) return false;
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
return isAllowed(ip, allowed);
};
// Helper: Generate a unique connection ID
const generateConnectionId = (): string => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
// Helper: Check if a buffer contains a TLS handshake
const isTlsHandshake = (buffer: Buffer): boolean => {
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
};
// Helper: Ensure timeout values don't exceed Node.js max safe integer
const ensureSafeTimeout = (timeout: number): number => {
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
};
// Helper: Generate a slightly randomized timeout to prevent thundering herd
const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => {
const safeBaseTimeout = ensureSafeTimeout(baseTimeout);
const variation = safeBaseTimeout * (variationPercent / 100);
return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation);
};
export class PortProxy {
private netServers: plugins.net.Server[] = [];
// Define the internal settings interface to include all fields, including those removed from the public interface
settings: IPortProxySettings & {
// Internal fields removed from public interface in 3.31.0+
initialDataTimeout: number;
socketTimeout: number;
inactivityCheckInterval: number;
maxConnectionLifetime: number;
inactivityTimeout: number;
disableInactivityCheck: boolean;
enableKeepAliveProbes: boolean;
keepAliveTreatment: 'standard' | 'extended' | 'immortal';
keepAliveInactivityMultiplier: number;
extendedKeepAliveLifetime: number;
};
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Map to track round robin indices for each domain config
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
// Enhanced stats tracking
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = {
incoming: {},
outgoing: {},
};
// Connection tracking by IP for rate limiting
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
// New property to store NetworkProxy instances
private networkProxies: NetworkProxy[] = [];
constructor(settingsArg: IPortProxySettings) {
// Set hardcoded sensible defaults for all settings
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
// Hardcoded timeout settings optimized for TLS safety in all deployment scenarios
initialDataTimeout: 60000, // 60 seconds for initial handshake
socketTimeout: 1800000, // 30 minutes - short enough for regular certificate refresh
inactivityCheckInterval: 60000, // 60 seconds interval for regular cleanup
maxConnectionLifetime: 3600000, // 1 hour maximum lifetime for all connections
inactivityTimeout: 1800000, // 30 minutes inactivity timeout
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
// Socket optimization settings
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes
// Feature flags - simplified with sensible defaults
disableInactivityCheck: false, // Always enable inactivity checks for TLS safety
enableKeepAliveProbes: true, // Always enable keep-alive probes for connection health
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute
// Keep-alive settings with sensible defaults that ensure certificate safety
keepAliveTreatment: 'standard', // Always use standard treatment for certificate safety
keepAliveInactivityMultiplier: 2, // 2x normal inactivity timeout for minimal extension
extendedKeepAliveLifetime: 3 * 60 * 60 * 1000, // 3 hours maximum (previously was 7 days!)
};
// Store NetworkProxy instances if provided
this.networkProxies = settingsArg.networkProxies || [];
}
/**
* Forwards a TLS connection to a NetworkProxy for handling
* @param connectionId - Unique connection identifier
* @param socket - The incoming client socket
* @param record - The connection record
* @param domainConfig - The domain configuration
* @param initialData - Initial data chunk (TLS ClientHello)
* @param serverName - SNI hostname (if available)
*/
private forwardToNetworkProxy(
connectionId: string,
socket: plugins.net.Socket,
record: IConnectionRecord,
domainConfig: IDomainConfig,
initialData: Buffer,
serverName?: string
): void {
// Determine which NetworkProxy to use
const proxyIndex =
domainConfig.networkProxyIndex !== undefined ? domainConfig.networkProxyIndex : 0;
// Validate the NetworkProxy index
if (proxyIndex < 0 || proxyIndex >= this.networkProxies.length) {
console.log(
`[${connectionId}] Invalid NetworkProxy index: ${proxyIndex}. Using fallback direct connection.`
);
// Fall back to direct connection
return this.setupDirectConnection(
connectionId,
socket,
record,
domainConfig,
serverName,
initialData
);
}
const networkProxy = this.networkProxies[proxyIndex];
const proxyPort = networkProxy.getListeningPort();
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Forwarding TLS connection to NetworkProxy[${proxyIndex}] at ${proxyHost}:${proxyPort}`
);
}
// Create a connection to the NetworkProxy
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort,
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
record.networkProxyIndex = proxyIndex;
// Set up error handlers
proxySocket.on('error', (err) => {
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
this.cleanupConnection(record, 'network_proxy_connect_error');
});
// Handle connection to NetworkProxy
proxySocket.on('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// First send the initial data that contains the TLS ClientHello
proxySocket.write(initialData);
// Now set up bidirectional piping between client and NetworkProxy
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Setup cleanup handlers
proxySocket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] NetworkProxy connection closed`);
}
this.cleanupConnection(record, 'network_proxy_closed');
});
socket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Client connection closed after forwarding to NetworkProxy`
);
}
this.cleanupConnection(record, 'client_closed');
});
// Special handler for TLS handshake detection with NetworkProxy
socket.on('data', (chunk: Buffer) => {
// Check for TLS handshake packets (ContentType.handshake)
if (chunk.length > 0 && chunk[0] === 22) {
console.log(`[${connectionId}] Detected potential TLS handshake with NetworkProxy, updating activity`);
this.updateActivity(record);
}
});
// Update activity on data transfer from the proxy socket
proxySocket.on('data', () => this.updateActivity(record));
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] TLS connection successfully forwarded to NetworkProxy[${proxyIndex}]`
);
}
});
}
/**
* Sets up a direct connection to the target (original behavior)
* This is used when NetworkProxy isn't configured or as a fallback
*/
private setupDirectConnection(
connectionId: string,
socket: plugins.net.Socket,
record: IConnectionRecord,
domainConfig: IDomainConfig | undefined,
serverName?: string,
initialChunk?: Buffer,
overridePort?: number
): void {
// Existing connection setup logic
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
// Pause the incoming socket to prevent buffer overflows
socket.pause();
// Temporary handler to collect data during connection setup
const tempDataHandler = (chunk: Buffer) => {
// Track bytes received
record.bytesReceived += chunk.length;
// Check for TLS handshake
if (!record.isTLS && isTlsHandshake(chunk)) {
record.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes`
);
}
}
// Check if adding this chunk would exceed the buffer limit
const newSize = record.pendingDataSize + chunk.length;
if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) {
console.log(
`[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`
);
socket.end(); // Gracefully close the socket
return this.initiateCleanupOnce(record, 'buffer_limit_exceeded');
}
// Buffer the chunk and update the size counter
record.pendingData.push(Buffer.from(chunk));
record.pendingDataSize = newSize;
this.updateActivity(record);
};
// Add the temp handler to capture all incoming data during connection setup
socket.on('data', tempDataHandler);
// Add initial chunk to pending data if present
if (initialChunk) {
record.bytesReceived += initialChunk.length;
record.pendingData.push(Buffer.from(initialChunk));
record.pendingDataSize = initialChunk.length;
}
// Create the target socket but don't set up piping immediately
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 to the outgoing connection as well
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) {
console.log(
`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`
);
}
}
}
}
// Setup specific error handler for connection phase
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
const code = (err as any).code;
console.log(
`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`
);
// Resume the incoming socket to prevent it from hanging
socket.resume();
if (code === 'ECONNREFUSED') {
console.log(
`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`
);
} else if (code === 'ETIMEDOUT') {
console.log(
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`
);
} else if (code === 'ECONNRESET') {
console.log(
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`
);
} else if (code === 'EHOSTUNREACH') {
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
}
// Clear any existing error handler after connection phase
targetSocket.removeAllListeners('error');
// Re-add the normal error handler for established connections
targetSocket.on('error', this.handleError('outgoing', record));
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'connection_failed';
this.incrementTerminationStat('outgoing', 'connection_failed');
}
// Clean up the connection
this.initiateCleanupOnce(record, `connection_failed_${code}`);
});
// Setup close handler
targetSocket.on('close', this.handleClose('outgoing', record));
socket.on('close', this.handleClose('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) {
console.log(
`[${connectionId}] Timeout event on incoming keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
// Don't close the connection - just log
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on incoming side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
this.initiateCleanupOnce(record, 'timeout_incoming');
});
targetSocket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(
`[${connectionId}] Timeout event on outgoing keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
// Don't close the connection - just log
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on outgoing side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
}
this.initiateCleanupOnce(record, 'timeout_outgoing');
});
// Set appropriate timeouts, or disable for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
// Disable timeouts completely for immortal connections
socket.setTimeout(0);
targetSocket.setTimeout(0);
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Disabled socket timeouts for immortal keep-alive connection`
);
}
} else {
// Set normal timeouts for other connections
socket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000));
targetSocket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000));
}
// Track outgoing data for bytes counting
targetSocket.on('data', (chunk: Buffer) => {
record.bytesSent += chunk.length;
this.updateActivity(record);
});
// Wait for the outgoing connection to be ready before setting up piping
targetSocket.once('connect', () => {
// Clear the initial connection error handler
targetSocket.removeAllListeners('error');
// Add the normal error handler for established connections
targetSocket.on('error', this.handleError('outgoing', record));
// Remove temporary data handler
socket.removeListener('data', tempDataHandler);
// Flush all pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.initiateCleanupOnce(record, 'write_error');
}
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// This listener handles TLS renegotiation detection
socket.on('data', (renegChunk) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
// IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, allowing it through.`);
return;
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
}
if (!allowed) {
const newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
}
}
});
}
// Now set up piping for future data and resume the socket
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}`
);
}
});
} else {
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// This listener handles TLS renegotiation detection
socket.on('data', (renegChunk) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
// IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, allowing it through.`);
return;
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
}
if (!allowed) {
const newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
}
}
});
}
// Now set up piping
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}`
);
}
}
// Clear the buffer now that we've processed it
record.pendingData = [];
record.pendingDataSize = 0;
// Renegotiation detection is now handled before piping is established
// This ensures the data listener receives all packets properly
// Set connection timeout with simpler logic
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
}
// For immortal keep-alive connections, skip setting a timeout completely
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime`
);
}
// No cleanup timer for immortal connections
}
// For TLS keep-alive connections, use a more generous timeout now that
// we've fixed the renegotiation handling issue that was causing certificate problems
else if (record.hasKeepAlive && record.isTLS) {
// Use a longer timeout for TLS connections now that renegotiation handling is fixed
// This reduces unnecessary reconnections while still ensuring certificate freshness
const tlsKeepAliveTimeout = 4 * 60 * 60 * 1000; // 4 hours for TLS keep-alive - increased from 30 minutes
const safeTimeout = ensureSafeTimeout(tlsKeepAliveTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] TLS keep-alive connection from ${
record.remoteIP
} exceeded max lifetime (${plugins.prettyMs(
tlsKeepAliveTimeout
)}), forcing cleanup to refresh certificate context.`
);
this.initiateCleanupOnce(record, 'tls_certificate_refresh');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] TLS keep-alive connection with aggressive certificate refresh protection, lifetime: ${plugins.prettyMs(
tlsKeepAliveTimeout
)}`
);
}
}
// For extended keep-alive connections, use extended timeout
else if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const extendedTimeout = this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000; // 7 days
const safeTimeout = ensureSafeTimeout(extendedTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Keep-alive connection from ${
record.remoteIP
} exceeded extended lifetime (${plugins.prettyMs(extendedTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'extended_lifetime');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs(
extendedTimeout
)}`
);
}
}
// For standard connections, use normal timeout
else {
// Use domain-specific timeout if available, otherwise use default
const connectionTimeout =
record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!;
const safeTimeout = ensureSafeTimeout(connectionTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Connection from ${
record.remoteIP
} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'connection_timeout');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
}
// Mark TLS handshake as complete for TLS connections
if (record.isTLS) {
record.tlsHandshakeComplete = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}`
);
}
}
});
}
/**
* Get connections count by IP
*/
private getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
*/
private checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
}
/**
* Track connection by IP
*/
private trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
private removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Track connection termination statistic
*/
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Update connection activity timestamp with sleep detection
*/
private updateActivity(record: IConnectionRecord): void {
// Get the current time
const now = Date.now();
// Check if there was a large time gap that suggests system sleep
if (record.lastActivity > 0) {
const timeDiff = now - record.lastActivity;
// If time difference is very large (> 30 minutes) and this is a keep-alive connection,
// this might indicate system sleep rather than just inactivity
if (timeDiff > 30 * 60 * 1000 && record.hasKeepAlive) {
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] Detected possible system sleep for ${plugins.prettyMs(timeDiff)}. ` +
`Handling keep-alive connection after long inactivity.`
);
}
// For TLS keep-alive connections after sleep/long inactivity, force close
// to make browser establish a new connection with fresh certificate context
if (record.isTLS && record.tlsHandshakeComplete) {
// More generous timeout now that we've fixed the renegotiation handling
if (timeDiff > 2 * 60 * 60 * 1000) {
// If inactive for more than 2 hours (increased from 20 minutes)
console.log(
`[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` +
`Closing to force new connection with fresh certificate.`
);
return this.initiateCleanupOnce(record, 'certificate_refresh_needed');
} else if (timeDiff > 30 * 60 * 1000) {
// For shorter but still significant inactivity (30+ minutes), refresh TLS state
console.log(
`[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` +
`Refreshing TLS state.`
);
this.refreshTlsStateAfterSleep(record);
// Add an additional check in 15 minutes if no activity
const refreshCheckId = record.id;
const refreshCheck = setTimeout(() => {
const currentRecord = this.connectionRecords.get(refreshCheckId);
if (currentRecord && Date.now() - currentRecord.lastActivity > 15 * 60 * 1000) {
console.log(
`[${refreshCheckId}] No activity detected after TLS refresh. ` +
`Closing connection to ensure certificate freshness.`
);
this.initiateCleanupOnce(currentRecord, 'tls_refresh_verification_failed');
}
}, 15 * 60 * 1000);
// Make sure timeout doesn't keep the process alive
if (refreshCheck.unref) {
refreshCheck.unref();
}
} else {
// For shorter inactivity periods, try to refresh the TLS state normally
this.refreshTlsStateAfterSleep(record);
}
}
// Mark that we detected sleep
record.possibleSystemSleep = true;
record.lastSleepDetection = now;
}
}
// Update the activity timestamp
record.lastActivity = now;
// Clear any inactivity warning
if (record.inactivityWarningIssued) {
record.inactivityWarningIssued = false;
}
}
/**
* Refresh TLS state after sleep detection
*/
private refreshTlsStateAfterSleep(record: IConnectionRecord): void {
// Skip if we're using a NetworkProxy as it handles its own TLS state
if (record.usingNetworkProxy) {
return;
}
try {
// For outgoing connections that might need to be refreshed
if (record.outgoing && !record.outgoing.destroyed) {
// Check how long this connection has been established
const connectionAge = Date.now() - record.incomingStartTime;
const hourInMs = 60 * 60 * 1000;
// For TLS browser connections, use a more generous timeout now that
// we've fixed the renegotiation handling issues
if (record.isTLS && record.hasKeepAlive && connectionAge > 8 * hourInMs) { // 8 hours instead of 45 minutes
console.log(
`[${record.id}] Long-lived TLS connection (${plugins.prettyMs(connectionAge)}). ` +
`Closing to ensure proper certificate handling on browser reconnect in proxy chain.`
);
return this.initiateCleanupOnce(record, 'certificate_context_refresh');
}
// For newer connections, try to send a refresh packet
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Sent refresh packet after sleep detection`);
}
}
} catch (err) {
console.log(`[${record.id}] Error refreshing TLS state: ${err}`);
// If we hit an error, it's likely the connection is already broken
// Force cleanup to ensure browser reconnects cleanly
return this.initiateCleanupOnce(record, 'tls_refresh_error');
}
}
/**
* Cleans up a connection record.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* @param record - The connection record to clean up
* @param reason - Optional reason for cleanup (for logging)
*/
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
// Track connection termination
this.removeConnectionByIP(record.remoteIP, record.id);
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// Detailed logging data
const duration = Date.now() - record.incomingStartTime;
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
try {
if (!record.incoming.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.incoming.end();
const incomingTimeout = setTimeout(() => {
try {
if (record && !record.incoming.destroyed) {
record.incoming.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying incoming socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (incomingTimeout.unref) {
incomingTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing incoming socket: ${err}`);
try {
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying incoming socket: ${destroyErr}`);
}
}
try {
if (record.outgoing && !record.outgoing.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.outgoing.end();
const outgoingTimeout = setTimeout(() => {
try {
if (record && record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying outgoing socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (outgoingTimeout.unref) {
outgoingTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing outgoing socket: ${err}`);
try {
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying outgoing socket: ${destroyErr}`);
}
}
// Clear pendingData to avoid memory leaks
record.pendingData = [];
record.pendingDataSize = 0;
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
// Log connection details
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
` Duration: ${plugins.prettyMs(
duration
)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}` +
`${record.usingNetworkProxy ? `, NetworkProxy: ${record.networkProxyIndex}` : ''}`
);
} else {
console.log(
`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`
);
}
}
}
/**
* Get target IP with round-robin support
*/
private getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP!;
}
/**
* Initiates cleanup once for a connection
*/
private initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`);
}
if (
record.incomingTerminationReason === null ||
record.incomingTerminationReason === undefined
) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
this.cleanupConnection(record, reason);
}
/**
* Creates a generic error handler for incoming or outgoing sockets
*/
private handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return (err: Error) => {
const code = (err as any).code;
let reason = 'error';
const now = Date.now();
const connectionDuration = now - record.incomingStartTime;
const lastActivityAge = now - record.lastActivity;
if (code === 'ECONNRESET') {
reason = 'econnreset';
console.log(
`[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${
err.message
}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(
lastActivityAge
)} ago`
);
} else if (code === 'ETIMEDOUT') {
reason = 'etimedout';
console.log(
`[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${
err.message
}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(
lastActivityAge
)} ago`
);
} else {
console.log(
`[${record.id}] Error on ${side} side from ${record.remoteIP}: ${
err.message
}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(
lastActivityAge
)} ago`
);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
this.initiateCleanupOnce(record, reason);
};
}
/**
* Creates a generic close handler for incoming or outgoing sockets
*/
private handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
record.outgoingClosedTime = Date.now();
}
this.initiateCleanupOnce(record, 'closed_' + side);
};
}
/**
* Main method to start the proxy
*/
public async start() {
// Don't start if already shutting down
if (this.isShuttingDown) {
console.log("Cannot start PortProxy while it's shutting down");
return;
}
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0; // The port on which this connection was accepted.
// Check rate limits
if (
this.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(remoteIP) >= this.settings.maxConnectionsPerIP
) {
console.log(
`Connection rejected from ${remoteIP}: Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`
);
socket.end();
socket.destroy();
return;
}
if (this.settings.connectionRateLimitPerMinute && !this.checkConnectionRate(remoteIP)) {
console.log(
`Connection rejected from ${remoteIP}: Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`
);
socket.end();
socket.destroy();
return;
}
// Apply socket optimizations
socket.setNoDelay(this.settings.noDelay);
// Create a unique connection ID and record
const connectionId = generateConnectionId();
const connectionRecord: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
connectionClosed: false,
pendingData: [],
pendingDataSize: 0,
// Initialize enhanced tracking fields
bytesReceived: 0,
bytesSent: 0,
remoteIP: remoteIP,
localPort: localPort,
isTLS: false,
tlsHandshakeComplete: false,
hasReceivedInitialData: false,
hasKeepAlive: false, // Will set to true if keep-alive is applied
incomingTerminationReason: null,
outgoingTerminationReason: null,
// Initialize NetworkProxy tracking fields
usingNetworkProxy: false,
// Initialize sleep detection fields
possibleSystemSleep: false,
};
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
connectionRecord.hasKeepAlive = true; // Mark connection as having keep-alive
// 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); // More aggressive probing
}
if ('setKeepAliveInterval' in socket) {
(socket as any).setKeepAliveInterval(1000); // 1 second interval between probes
}
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`
);
}
}
}
}
// Track connection by IP
this.trackConnectionByIP(remoteIP, connectionId);
this.connectionRecords.set(connectionId, connectionRecord);
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` +
`Keep-Alive: ${connectionRecord.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
`Active connections: ${this.connectionRecords.size}`
);
} else {
console.log(
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`
);
}
let initialDataReceived = false;
// Define helpers for rejecting connections
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(`[${connectionId}] ${logMessage}`);
socket.end();
if (connectionRecord.incomingTerminationReason === null) {
connectionRecord.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
this.cleanupConnection(connectionRecord, reason);
};
// Set an initial timeout for SNI data if needed
let initialTimeout: NodeJS.Timeout | null = null;
if (this.settings.sniEnabled) {
initialTimeout = setTimeout(() => {
if (!initialDataReceived) {
console.log(
`[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`
);
if (connectionRecord.incomingTerminationReason === null) {
connectionRecord.incomingTerminationReason = 'initial_timeout';
this.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
this.cleanupConnection(connectionRecord, 'initial_timeout');
}
}, this.settings.initialDataTimeout!);
// Make sure timeout doesn't keep the process alive
if (initialTimeout.unref) {
initialTimeout.unref();
}
} else {
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
}
socket.on('error', this.handleError('incoming', connectionRecord));
// Track data for bytes counting
socket.on('data', (chunk: Buffer) => {
connectionRecord.bytesReceived += chunk.length;
this.updateActivity(connectionRecord);
// Check for TLS handshake if this is the first chunk
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes`
);
// Try to extract SNI and log detailed debug info
extractSNI(chunk, true);
}
}
});
/**
* Sets up the connection to the target host or NetworkProxy.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
* @param overridePort - If provided, use this port for the outgoing connection.
*/
const setupConnection = (
serverName: string,
initialChunk?: Buffer,
forcedDomain?: IDomainConfig,
overridePort?: number
) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
// Mark that we've received initial data
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake
const isTlsHandshakeDetected = initialChunk && isTlsHandshake(initialChunk);
if (isTlsHandshakeDetected) {
connectionRecord.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes`
);
}
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
: serverName
? this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
)
: undefined;
// Save domain config in connection record
connectionRecord.domainConfig = domainConfig;
// Always set the lockedDomain, even for non-SNI connections
if (serverName) {
connectionRecord.lockedDomain = serverName;
console.log(`[${connectionId}] Locked connection to domain: ${serverName}`);
}
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs: string[] = [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
// Skip IP validation if allowedIPs is empty
if (
domainConfig.allowedIPs.length > 0 &&
!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(
', '
)}`
);
}
// Check if we should forward this to a NetworkProxy
if (
isTlsHandshakeDetected &&
domainConfig.useNetworkProxy === true &&
initialChunk &&
this.networkProxies.length > 0
) {
return this.forwardToNetworkProxy(
connectionId,
socket,
connectionRecord,
domainConfig,
initialChunk,
serverName
);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (
!isGlobIPAllowed(
remoteIP,
this.settings.defaultAllowedIPs,
this.settings.defaultBlockedIPs || []
)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed by default allowed list`
);
}
}
// If we didn't forward to NetworkProxy, proceed with direct connection
return this.setupDirectConnection(
connectionId,
socket,
connectionRecord,
domainConfig,
serverName,
initialChunk,
overridePort
);
};
// --- PORT RANGE-BASED HANDLING ---
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (
this.settings.globalPortRanges &&
isPortInRanges(localPort, this.settings.globalPortRanges)
) {
if (this.settings.forwardAllGlobalRanges) {
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!isAllowed(remoteIP, this.settings.defaultAllowedIPs)
) {
console.log(
`[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`
);
}
setupConnection(
'',
undefined,
{
domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [],
blockedIPs: this.settings.defaultBlockedIPs || [],
targetIPs: [this.settings.targetIP!],
portRanges: [],
},
localPort
);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domainConfigs.find(
(domain) =>
domain.portRanges &&
domain.portRanges.length > 0 &&
isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
const effectiveAllowedIPs: string[] = [
...forcedDomain.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs: string[] = [
...(forcedDomain.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
console.log(
`[${connectionId}] Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(
', '
)} on port ${localPort}.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(
', '
)}.`
);
}
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
// Try to extract SNI
let serverName = '';
if (isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`
);
}
serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || '';
}
// Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = serverName;
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Received connection from ${remoteIP} with SNI: ${
serverName || '(empty)'
}`
);
}
setupConnection(serverName, chunk);
});
} else {
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!isAllowed(remoteIP, this.settings.defaultAllowedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`
);
}
setupConnection('');
}
};
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set<number>();
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// Listen on every port defined by the global ranges.
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isn't already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
}
// Create a server for each port.
for (const port of listeningPorts) {
const server = plugins.net.createServer(connectionHandler).on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
console.log(
`PortProxy -> OK: Now listening on port ${port}${
this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''
}${this.networkProxies.length > 0 ? ' (NetworkProxy integration enabled)' : ''}`
);
});
this.netServers.push(server);
}
// Log active connection count, longest running durations, and run parity checks periodically
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown) return;
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let tlsConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
} else {
pendingTlsHandshakes++;
}
} else {
nonTlsConnections++;
}
if (record.hasKeepAlive) {
keepAliveConnections++;
}
if (record.usingNetworkProxy) {
networkProxyConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// Parity check: if outgoing socket closed and incoming remains active
if (
record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
now - record.outgoingClosedTime > 120000
) {
const remoteIP = record.remoteIP;
console.log(
`[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(
now - record.outgoingClosedTime
)} after outgoing closed.`
);
this.cleanupConnection(record, 'parity_check');
}
// Check for stalled connections waiting for initial data
if (
!record.hasReceivedInitialData &&
now - record.incomingStartTime > this.settings.initialDataTimeout! / 2
) {
console.log(
`[${id}] Warning: Connection from ${
record.remoteIP
} has not received initial data after ${plugins.prettyMs(
now - record.incomingStartTime
)}`
);
}
// Skip inactivity check if disabled or for immortal keep-alive connections
if (
!this.settings.disableInactivityCheck &&
!(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
const inactivityTime = now - record.lastActivity;
// Special handling for TLS keep-alive connections
if (
record.hasKeepAlive &&
record.isTLS &&
inactivityTime > this.settings.inactivityTimeout! / 2
) {
// For TLS keep-alive connections that are getting stale, try to refresh before closing
if (!record.inactivityWarningIssued) {
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Attempting to preserve connection.`
);
// Set warning flag but give a much longer grace period for TLS connections
record.inactivityWarningIssued = true;
// For TLS connections, extend the last activity time considerably
// This gives browsers more time to re-establish the connection properly
record.lastActivity = now - this.settings.inactivityTimeout! / 3;
// Try to stimulate the connection with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
// For TLS connections, send a proper TLS heartbeat-like packet
// This is just a small empty buffer that won't affect the TLS session
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent TLS keep-alive probe packet`);
}
} catch (err) {
console.log(`[${id}] Error sending TLS probe packet: ${err}`);
}
}
// Don't proceed to the normal inactivity check logic
continue;
}
}
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
// For keep-alive connections, issue a warning first
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
console.log(
`[${id}] Warning: Keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Will close in 10 minutes if no activity.`
);
// Set warning flag and add grace period
record.inactivityWarningIssued = true;
record.lastActivity = now - (effectiveTimeout - 600000);
// Try to stimulate activity with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent probe packet to test keep-alive connection`);
}
} catch (err) {
console.log(`[${id}] Error sending probe packet: ${err}`);
}
}
} else {
// MODIFIED: For TLS connections, be more lenient before closing
// For TLS browser connections, we need to handle certificate context properly
if (record.isTLS && record.hasKeepAlive) {
// For very long inactivity, it's better to close the connection
// so the browser establishes a new one with a fresh certificate context
if (inactivityTime > 6 * 60 * 60 * 1000) {
// 6 hours
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Closing to ensure proper certificate handling on browser reconnect.`
);
this.cleanupConnection(record, 'tls_certificate_refresh');
} else {
// For shorter inactivity periods, add grace period
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Adding extra grace period.`
);
// Give additional time for browsers to reconnect properly
record.lastActivity = now - effectiveTimeout / 2;
}
} else {
// For non-keep-alive or after warning, close the connection
console.log(
`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` +
`for ${plugins.prettyMs(inactivityTime)}.` +
(record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '')
);
this.cleanupConnection(record, 'inactivity');
}
}
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
// If activity detected after warning, clear the warning
if (this.settings.enableDetailedLogging) {
console.log(
`[${id}] Connection activity detected after inactivity warning, resetting warning`
);
}
record.inactivityWarningIssued = false;
}
}
}
// Log detailed stats periodically
console.log(
`Active connections: ${this.connectionRecords.size}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(
maxOutgoing
)}. ` +
`Termination stats: ${JSON.stringify({
IN: this.terminationStats.incoming,
OUT: this.terminationStats.outgoing,
})}`
);
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
}
/**
* Add or replace NetworkProxy instances
*/
public setNetworkProxies(networkProxies: NetworkProxy[]): void {
this.networkProxies = networkProxies;
console.log(`Updated NetworkProxy instances: ${this.networkProxies.length} proxies configured`);
}
/**
* Get a list of configured NetworkProxy instances
*/
public getNetworkProxies(): NetworkProxy[] {
return this.networkProxies;
}
/**
* Gracefully shut down the proxy
*/
public async stop() {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
(server) =>
new Promise<void>((resolve) => {
if (!server.listening) {
resolve();
return;
}
server.close((err) => {
if (err) {
console.log(`Error closing server: ${err.message}`);
}
resolve();
});
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Wait for servers to close
await Promise.all(closeServerPromises);
console.log('All servers closed. Cleaning up active connections...');
// Force destroy all active connections immediately
const connectionIds = [...this.connectionRecords.keys()];
console.log(`Cleaning up ${connectionIds.length} active connections...`);
// First pass: End all connections gracefully
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Clear any timers
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// End sockets gracefully
if (record.incoming && !record.incoming.destroyed) {
record.incoming.end();
}
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.end();
}
} catch (err) {
console.log(`Error during graceful connection end for ${id}: ${err}`);
}
}
}
// Short delay to allow graceful ends to process
await new Promise((resolve) => setTimeout(resolve, 100));
// Second pass: Force destroy everything
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Remove all listeners to prevent memory leaks
if (record.incoming) {
record.incoming.removeAllListeners();
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
}
if (record.outgoing) {
record.outgoing.removeAllListeners();
if (!record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
} catch (err) {
console.log(`Error during forced connection destruction for ${id}: ${err}`);
}
}
}
// Clear all tracking maps
this.connectionRecords.clear();
this.domainTargetIndices.clear();
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
this.netServers = [];
// Reset termination stats
this.terminationStats = {
incoming: {},
outgoing: {},
};
console.log('PortProxy shutdown complete.');
}
}