fix(portproxy): Improve connection timeouts and detailed logging for PortProxy

This commit is contained in:
Philipp Kunz 2025-03-05 18:40:42 +00:00
parent 4ccc1db8a2
commit d4251b2cf9
3 changed files with 346 additions and 319 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## 2025-03-05 - 3.25.4 - fix(portproxy)
Improve connection timeouts and detailed logging for PortProxy
- Refactored timeout management for connections to include enhanced defaults and prevent thundering herd.
- Improved support for TLS handshake detection with logging capabilities in PortProxy.
- Removed protocol-specific handling which is now managed generically.
- Introduced enhanced logging for SNI extraction and connection management.
## 2025-03-05 - 3.25.3 - fix(core)
Update dependencies and configuration improvements.

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.25.3',
version: '3.25.4',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
}

View File

@ -7,14 +7,10 @@ export interface IDomainConfig {
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
// Protocol-specific timeout overrides
httpTimeout?: number; // HTTP connection timeout override (ms)
wsTimeout?: number; // WebSocket connection timeout override (ms)
// Allow domain-specific timeout override
connectionTimeout?: number; // Connection timeout override (ms)
}
/** Connection protocol types for timeout management */
export type ProtocolType = 'http' | 'websocket' | 'https' | 'tls' | 'unknown';
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
@ -26,40 +22,37 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// Updated timeout settings with better defaults
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 15000 (15s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 300000 (5m)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 30000 (30s)
// Timeout settings
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
inactivityTimeout?: number; // Inactivity timeout (ms), default: 3600000 (1h)
// Protocol-specific timeouts
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
httpConnectionTimeout?: number; // HTTP specific timeout (ms), default: 1800000 (30m)
wsConnectionTimeout?: number; // WebSocket specific timeout (ms), default: 14400000 (4h)
httpKeepAliveTimeout?: number; // HTTP keep-alive header timeout (ms), default: 1200000 (20m)
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
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
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
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
// Enable enhanced features
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
enableProtocolDetection?: boolean; // Enable HTTP/WebSocket protocol detection
enableDetailedLogging?: boolean; // Enable detailed connection logging
// Enhanced features
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
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
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
}
/**
* Enhanced connection record with protocol-specific handling
* Enhanced connection record
*/
interface IConnectionRecord {
id: string; // Unique connection identifier
@ -76,78 +69,161 @@ interface IConnectionRecord {
pendingDataSize: number; // Track total size of pending data
// Enhanced tracking fields
protocolType: ProtocolType; // Connection protocol type
isPooledConnection: boolean; // Whether this is likely a browser pooled connection
lastHttpRequest?: number; // Timestamp of last HTTP request (for keep-alive tracking)
httpKeepAliveTimeout?: number; // HTTP keep-alive timeout from headers
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)
httpRequests: number; // Count of HTTP requests on this connection
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
}
/**
* 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): string | undefined {
let offset = 0;
if (buffer.length < 5) return undefined;
const recordType = buffer.readUInt8(0);
if (recordType !== 22) return undefined; // 22 = handshake
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) return undefined;
offset = 5;
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) return undefined; // 1 = ClientHello
offset += 4; // Skip handshake header (type + length)
offset += 2 + 32; // Skip client version and random
const sessionIDLength = buffer.readUInt8(offset);
offset += 1 + sessionIDLength; // Skip session ID
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength; // Skip cipher suites
const compressionMethodsLength = buffer.readUInt8(offset);
offset += 1 + compressionMethodsLength; // Skip compression methods
if (offset + 2 > buffer.length) return undefined;
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
offset += 4;
if (extensionType === 0x0000) { // SNI extension
if (offset + 2 > buffer.length) return undefined;
const sniListLength = buffer.readUInt16BE(offset);
offset += 2;
const sniListEnd = offset + sniListLength;
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset++);
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (nameType === 0) { // host_name
if (offset + nameLen > buffer.length) return undefined;
return buffer.toString('utf8', offset, offset + nameLen);
}
offset += nameLen;
}
break;
} else {
offset += extensionLength;
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;
}
return undefined;
}
// Helper: Check if a port falls within any of the given port ranges
@ -157,7 +233,10 @@ const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }
// 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];
@ -167,7 +246,10 @@ const isAllowed = (ip: string, patterns: string[]): boolean => {
}
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))
@ -176,6 +258,7 @@ const isAllowed = (ip: string, patterns: string[]): boolean => {
// 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);
};
@ -185,34 +268,17 @@ const generateConnectionId = (): string => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
// Protocol detection helpers
const isHttpRequest = (buffer: Buffer): boolean => {
if (buffer.length < 4) return false;
const start = buffer.toString('ascii', 0, 4).toUpperCase();
return (
start.startsWith('GET ') ||
start.startsWith('POST') ||
start.startsWith('PUT ') ||
start.startsWith('HEAD') ||
start.startsWith('DELE') ||
start.startsWith('PATC') ||
start.startsWith('OPTI')
);
};
const isWebSocketUpgrade = (buffer: Buffer): boolean => {
if (buffer.length < 20) return false;
const data = buffer.toString('ascii', 0, Math.min(buffer.length, 200));
return (
data.includes('Upgrade: websocket') ||
data.includes('Upgrade: WebSocket')
);
};
// Helper: Check if a buffer contains a TLS handshake
const isTlsHandshake = (buffer: Buffer): boolean => {
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
};
// Helper: Generate a slightly randomized timeout to prevent thundering herd
const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => {
const variation = baseTimeout * (variationPercent / 100);
return baseTimeout + Math.floor(Math.random() * variation * 2) - variation;
};
export class PortProxy {
private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings;
@ -242,16 +308,12 @@ export class PortProxy {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
// Timeout settings with browser-friendly defaults
initialDataTimeout: settingsArg.initialDataTimeout || 30000, // 30 seconds
socketTimeout: settingsArg.socketTimeout || 300000, // 5 minutes
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 30000, // 30 seconds
// Protocol-specific timeouts
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default
httpConnectionTimeout: settingsArg.httpConnectionTimeout || 1800000, // 30 minutes
wsConnectionTimeout: settingsArg.wsConnectionTimeout || 14400000, // 4 hours
httpKeepAliveTimeout: settingsArg.httpKeepAliveTimeout || 1200000, // 20 minutes
// Timeout settings with our enhanced defaults
initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial data
socketTimeout: settingsArg.socketTimeout || 3600000, // 1 hour socket timeout
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default lifetime
inactivityTimeout: settingsArg.inactivityTimeout || 3600000, // 1 hour inactivity timeout
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
@ -259,13 +321,14 @@ export class PortProxy {
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds
maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes
// Feature flags
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes || false,
enableProtocolDetection: settingsArg.enableProtocolDetection !== undefined ? settingsArg.enableProtocolDetection : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || true,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
@ -332,115 +395,22 @@ export class PortProxy {
}
/**
* Get protocol-specific timeout based on connection type
* Get connection timeout based on domain config or default settings
*/
private getProtocolTimeout(record: IConnectionRecord, domainConfig?: IDomainConfig): number {
// If the protocol has a domain-specific timeout, use that
if (domainConfig) {
if (record.protocolType === 'http' && domainConfig.httpTimeout) {
return domainConfig.httpTimeout;
}
if (record.protocolType === 'websocket' && domainConfig.wsTimeout) {
return domainConfig.wsTimeout;
}
}
// Use HTTP keep-alive timeout from headers if available
if (record.httpKeepAliveTimeout) {
return record.httpKeepAliveTimeout;
private getConnectionTimeout(record: IConnectionRecord): number {
// If the connection has a domain-specific timeout, use that
if (record.domainConfig?.connectionTimeout) {
return record.domainConfig.connectionTimeout;
}
// Otherwise use default protocol-specific timeout
switch (record.protocolType) {
case 'http':
return this.settings.httpConnectionTimeout!;
case 'websocket':
return this.settings.wsConnectionTimeout!;
case 'https':
case 'tls':
return this.settings.httpConnectionTimeout!; // Use HTTP timeout for HTTPS by default
default:
return this.settings.maxConnectionLifetime!;
}
}
/**
* Detect protocol and update connection record
*/
private detectProtocol(data: Buffer, record: IConnectionRecord): void {
if (!this.settings.enableProtocolDetection || record.protocolType !== 'unknown') {
return;
}
try {
// Detect TLS/HTTPS
if (isTlsHandshake(data)) {
record.protocolType = 'tls';
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: TLS`);
}
return;
}
// Detect HTTP including WebSocket upgrades
if (isHttpRequest(data)) {
record.httpRequests++;
record.lastHttpRequest = Date.now();
// Check for WebSocket upgrade
if (isWebSocketUpgrade(data)) {
record.protocolType = 'websocket';
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: WebSocket Upgrade`);
}
} else {
record.protocolType = 'http';
// Parse HTTP keep-alive headers
this.parseHttpHeaders(data, record);
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: HTTP${record.isPooledConnection ? ' (KeepAlive)' : ''}`);
}
}
}
} catch (err) {
console.log(`[${record.id}] Error detecting protocol: ${err}`);
}
}
/**
* Parse HTTP headers for keep-alive and other connection info
*/
private parseHttpHeaders(data: Buffer, record: IConnectionRecord): void {
try {
const headerStr = data.toString('utf8', 0, Math.min(data.length, 1024));
// Check for HTTP keep-alive
const connectionHeader = headerStr.match(/\r\nConnection:\s*([^\r\n]+)/i);
if (connectionHeader && connectionHeader[1].toLowerCase().includes('keep-alive')) {
record.isPooledConnection = true;
// Check for Keep-Alive timeout value
const keepAliveHeader = headerStr.match(/\r\nKeep-Alive:\s*([^\r\n]+)/i);
if (keepAliveHeader) {
const timeoutMatch = keepAliveHeader[1].match(/timeout=(\d+)/i);
if (timeoutMatch && timeoutMatch[1]) {
const timeoutSec = parseInt(timeoutMatch[1], 10);
if (!isNaN(timeoutSec) && timeoutSec > 0) {
// Convert seconds to milliseconds and add some buffer
record.httpKeepAliveTimeout = (timeoutSec * 1000) + 5000;
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] HTTP Keep-Alive timeout set to ${timeoutSec} seconds`);
}
}
}
}
}
} catch (err) {
console.log(`[${record.id}] Error parsing HTTP headers: ${err}`);
// Use default timeout, potentially randomized
const baseTimeout = this.settings.maxConnectionLifetime!;
if (this.settings.enableRandomizedTimeouts) {
return randomizeTimeout(baseTimeout);
}
return baseTimeout;
}
/**
@ -465,7 +435,6 @@ export class PortProxy {
const duration = Date.now() - record.incomingStartTime;
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
const httpRequests = record.httpRequests;
try {
if (!record.incoming.destroyed) {
@ -538,7 +507,7 @@ export class PortProxy {
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}, ` +
`HTTP Requests: ${httpRequests}, Protocol: ${record.protocolType}, Pooled: ${record.isPooledConnection}`);
`TLS: ${record.isTLS ? 'Yes' : 'No'}`);
} else {
console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
@ -608,6 +577,21 @@ export class PortProxy {
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
}
// Apply enhanced TCP options if available
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
}
}
// Create a unique connection ID and record
const connectionId = generateConnectionId();
const connectionRecord: IConnectionRecord = {
@ -621,13 +605,13 @@ export class PortProxy {
pendingDataSize: 0,
// Initialize enhanced tracking fields
protocolType: 'unknown',
isPooledConnection: false,
bytesReceived: 0,
bytesSent: 0,
remoteIP: remoteIP,
localPort: localPort,
httpRequests: 0
isTLS: false,
tlsHandshakeComplete: false,
hasReceivedInitialData: false
};
// Track connection by IP
@ -685,9 +669,15 @@ export class PortProxy {
socket.end();
cleanupOnce();
}
}, this.settings.initialDataTimeout);
}, 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', (err: Error) => {
@ -699,39 +689,14 @@ export class PortProxy {
connectionRecord.bytesReceived += chunk.length;
this.updateActivity(connectionRecord);
// Detect protocol on first data chunk
if (connectionRecord.protocolType === 'unknown') {
this.detectProtocol(chunk, connectionRecord);
// Check for TLS handshake if this is the first chunk
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
// Update timeout based on protocol
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
// Set new timeout based on protocol
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
initiateCleanupOnce(`${connectionRecord.protocolType}_timeout`);
}, protocolTimeout);
}
} else if (connectionRecord.protocolType === 'http' && isHttpRequest(chunk)) {
// Additional HTTP request on the same connection
connectionRecord.httpRequests++;
connectionRecord.lastHttpRequest = Date.now();
// Parse HTTP headers again for keep-alive changes
this.parseHttpHeaders(chunk, connectionRecord);
// Update timeout based on new HTTP headers
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
// Set new timeout based on updated HTTP info
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] HTTP connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
initiateCleanupOnce('http_timeout');
}, protocolTimeout);
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);
}
}
});
@ -797,9 +762,17 @@ export class PortProxy {
initialTimeout = null;
}
// Detect protocol if initial chunk is available
if (initialChunk && this.settings.enableProtocolDetection) {
this.detectProtocol(initialChunk, connectionRecord);
// Mark that we've received initial data
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake
if (initialChunk && isTlsHandshake(initialChunk)) {
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.
@ -809,6 +782,9 @@ export class PortProxy {
config.domains.some(d => plugins.minimatch(serverName, d))
) : undefined);
// Save domain config in connection record
connectionRecord.domainConfig = domainConfig;
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
@ -847,9 +823,13 @@ export class PortProxy {
// Track bytes received
connectionRecord.bytesReceived += chunk.length;
// Detect protocol even during connection setup
if (this.settings.enableProtocolDetection && connectionRecord.protocolType === 'unknown') {
this.detectProtocol(chunk, connectionRecord);
// Check for TLS handshake
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) {
connectionRecord.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
@ -888,6 +868,20 @@ export class PortProxy {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
}
// Apply enhanced TCP options if available
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
}
}
// Setup specific error handler for connection phase
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
@ -928,7 +922,7 @@ export class PortProxy {
// Handle timeouts
socket.on('timeout', () => {
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
if (incomingTerminationReason === null) {
incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
@ -937,7 +931,7 @@ export class PortProxy {
});
targetSocket.on('timeout', () => {
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
if (outgoingTerminationReason === null) {
outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
@ -946,8 +940,8 @@ export class PortProxy {
});
// Set appropriate timeouts using the configured value
socket.setTimeout(this.settings.socketTimeout || 300000);
targetSocket.setTimeout(this.settings.socketTimeout || 300000);
socket.setTimeout(this.settings.socketTimeout || 3600000);
targetSocket.setTimeout(this.settings.socketTimeout || 3600000);
// Track outgoing data for bytes counting
targetSocket.on('data', (chunk: Buffer) => {
@ -984,7 +978,7 @@ export class PortProxy {
console.log(
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
` Protocol: ${connectionRecord.protocolType}`
` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}`
);
} else {
console.log(
@ -1003,7 +997,7 @@ export class PortProxy {
console.log(
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
` Protocol: ${connectionRecord.protocolType}`
` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}`
);
} else {
console.log(
@ -1023,7 +1017,7 @@ export class PortProxy {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk);
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
console.log(`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
initiateCleanupOnce('sni_mismatch');
@ -1037,17 +1031,31 @@ export class PortProxy {
});
}
// Set protocol-specific timeout based on detected protocol
// Set connection timeout
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
// Set timeout based on protocol
const protocolTimeout = this.getProtocolTimeout(connectionRecord, domainConfig);
// Set timeout based on domain config or default
const connectionTimeout = this.getConnectionTimeout(connectionRecord);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection exceeded max lifetime (${plugins.prettyMs(protocolTimeout)}), forcing cleanup.`);
initiateCleanupOnce(`${connectionRecord.protocolType}_max_lifetime`);
}, protocolTimeout);
console.log(`[${connectionId}] Connection from ${remoteIP} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`);
initiateCleanupOnce('connection_timeout');
}, connectionTimeout);
// Make sure timeout doesn't keep the process alive
if (connectionRecord.cleanupTimer.unref) {
connectionRecord.cleanupTimer.unref();
}
// Mark TLS handshake as complete for TLS connections
if (connectionRecord.isTLS) {
connectionRecord.tlsHandshakeComplete = true;
if (this.settings.enableTlsDebugLogging) {
console.log(`[${connectionId}] TLS handshake complete for connection from ${remoteIP}`);
}
}
});
};
@ -1055,7 +1063,7 @@ export class PortProxy {
// 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 && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
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;
@ -1111,7 +1119,20 @@ export class PortProxy {
}
initialDataReceived = true;
const serverName = extractSNI(chunk) || '';
// 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;
@ -1123,9 +1144,12 @@ export class PortProxy {
});
} else {
initialDataReceived = true;
if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
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('');
}
};
@ -1167,11 +1191,10 @@ export class PortProxy {
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let httpConnections = 0;
let wsConnections = 0;
let tlsConnections = 0;
let unknownConnections = 0;
let pooledConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
@ -1180,17 +1203,16 @@ export class PortProxy {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Track connection stats by protocol
switch (record.protocolType) {
case 'http': httpConnections++; break;
case 'websocket': wsConnections++; break;
case 'tls':
case 'https': tlsConnections++; break;
default: unknownConnections++; break;
}
if (record.isPooledConnection) {
pooledConnections++;
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
} else {
pendingTlsHandshakes++;
}
} else {
nonTlsConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
@ -1208,23 +1230,20 @@ export class PortProxy {
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
if (!this.settings.disableInactivityCheck) {
// Inactivity check - use protocol-specific values
let inactivityThreshold = Math.floor(Math.random() * (1800000 - 1200000 + 1)) + 1200000; // random between 20 and 30 minutes
// Set protocol-specific inactivity thresholds
if (record.protocolType === 'http' && record.isPooledConnection) {
inactivityThreshold = this.settings.httpKeepAliveTimeout || 1200000; // 20 minutes for pooled HTTP
} else if (record.protocolType === 'websocket') {
inactivityThreshold = this.settings.wsConnectionTimeout || 14400000; // 4 hours for WebSocket
} else if (record.protocolType === 'http') {
inactivityThreshold = this.settings.httpConnectionTimeout || 1800000; // 30 minutes for HTTP
}
// Inactivity check with configurable timeout
const inactivityThreshold = this.settings.inactivityTimeout!;
const inactivityTime = now - record.lastActivity;
if (inactivityTime > inactivityThreshold && !record.connectionClosed) {
console.log(`[${id}] Inactivity check: No activity on ${record.protocolType} connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
console.log(`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
this.cleanupConnection(record, 'inactivity');
}
}
@ -1233,11 +1252,11 @@ export class PortProxy {
// Log detailed stats periodically
console.log(
`Active connections: ${this.connectionRecords.size}. ` +
`Types: HTTP=${httpConnections}, WS=${wsConnections}, TLS=${tlsConnections}, Unknown=${unknownConnections}, Pooled=${pooledConnections}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), Non-TLS=${nonTlsConnections}. ` +
`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 || 30000);
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {