2057 lines
77 KiB
TypeScript
2057 lines
77 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 */
|
|
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;
|
|
|
|
// 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: 86400000 (24h)
|
|
inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h)
|
|
|
|
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
|
|
|
|
// 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
|
|
|
|
// 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
|
|
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
|
|
|
// Enhanced keep-alive settings
|
|
keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections
|
|
keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections
|
|
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
|
|
|
|
// New property for 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[] = [];
|
|
settings: IPortProxySettings;
|
|
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 reasonable defaults for all settings
|
|
this.settings = {
|
|
...settingsArg,
|
|
targetIP: settingsArg.targetIP || 'localhost',
|
|
|
|
// Timeout settings with reasonable defaults
|
|
initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial handshake
|
|
socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout
|
|
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval
|
|
maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default
|
|
inactivityTimeout: ensureSafeTimeout(settingsArg.inactivityTimeout || 14400000), // 4 hours 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 (reduced for responsiveness)
|
|
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes
|
|
|
|
// Feature flags
|
|
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
|
|
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined
|
|
? settingsArg.enableKeepAliveProbes : true, // Enable by default
|
|
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
|
|
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
|
|
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Disable randomization by default
|
|
|
|
// Rate limiting defaults
|
|
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
|
|
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute
|
|
|
|
// Enhanced keep-alive settings
|
|
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', // Extended by default
|
|
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, // 6x normal inactivity timeout
|
|
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 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');
|
|
});
|
|
|
|
// Update activity on data transfer
|
|
socket.on('data', (chunk: Buffer) => {
|
|
this.updateActivity(record);
|
|
|
|
// Check for potential TLS renegotiation or reconnection packets
|
|
if (chunk.length > 0 && chunk[0] === 22) { // ContentType.handshake
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(`[${connectionId}] Detected potential TLS handshake data while connected to NetworkProxy`);
|
|
}
|
|
|
|
// Let the NetworkProxy handle the TLS renegotiation
|
|
// Just update the activity timestamp to prevent timeouts
|
|
record.lastActivity = Date.now();
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
|
|
// 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 {
|
|
// No pending data, so just 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;
|
|
|
|
// Add the renegotiation listener for SNI validation
|
|
if (serverName) {
|
|
socket.on('data', (renegChunk: Buffer) => {
|
|
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
|
try {
|
|
// Try to extract SNI from potential renegotiation
|
|
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
|
|
if (newSNI && newSNI !== record.lockedDomain) {
|
|
console.log(
|
|
`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${record.lockedDomain}. Terminating connection.`
|
|
);
|
|
this.initiateCleanupOnce(record, 'sni_mismatch');
|
|
} else if (newSNI && this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.log(
|
|
`[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 very extended timeout
|
|
else if (record.hasKeepAlive && record.isTLS) {
|
|
// For TLS keep-alive connections, use a very extended timeout
|
|
// This helps prevent certificate errors after sleep/wake cycles
|
|
const tlsKeepAliveTimeout = 14 * 24 * 60 * 60 * 1000; // 14 days for TLS keep-alive
|
|
const safeTimeout = ensureSafeTimeout(tlsKeepAliveTimeout);
|
|
|
|
record.cleanupTimer = setTimeout(() => {
|
|
console.log(
|
|
`[${connectionId}] TLS keep-alive connection from ${record.remoteIP} exceeded extended lifetime (${plugins.prettyMs(
|
|
tlsKeepAliveTimeout
|
|
)}), forcing cleanup.`
|
|
);
|
|
this.initiateCleanupOnce(record, 'tls_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}] TLS keep-alive connection with enhanced 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)}. ` +
|
|
`Preserving keep-alive connection.`
|
|
);
|
|
}
|
|
|
|
// For keep-alive connections after sleep, we should refresh the TLS state if needed
|
|
if (record.isTLS && record.tlsHandshakeComplete) {
|
|
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) {
|
|
// Send a zero-byte packet to test the connection
|
|
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 can't refresh, don't terminate - client will re-establish if needed
|
|
// Just log the issue but preserve the connection
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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
|
|
if (record.isTLS && record.hasKeepAlive) {
|
|
// For TLS keep-alive connections, add additional grace period
|
|
// This helps with browsers reconnecting after sleep
|
|
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.');
|
|
}
|
|
} |