smartproxy/ts/classes.portproxy.ts

3545 lines
137 KiB
TypeScript

import * as plugins from './plugins.js';
import { NetworkProxy } from './classes.networkproxy.js';
/** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
blockedIPs?: string[]; // Glob patterns for blocked IPs
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
// Allow domain-specific timeout override
connectionTimeout?: number; // Connection timeout override (ms)
// New properties for NetworkProxy integration
useNetworkProxy?: boolean; // When true, forwards TLS connections to NetworkProxy
networkProxyIndex?: number; // Optional index to specify which NetworkProxy to use (defaults to 0)
}
/**
* Port proxy settings including global allowed port ranges
*
* NOTE: In version 3.31.0+, timeout settings have been simplified and hardcoded with sensible defaults
* to ensure TLS certificate safety in all deployment scenarios, especially chained proxies.
*/
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// Simplified timeout settings
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
// Ranged port settings
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
// Socket optimization settings
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
keepAlive?: boolean; // Enable TCP keepalive (default: true)
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
// Logging settings
enableDetailedLogging?: boolean; // Enable detailed connection logging
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
// NetworkProxy integration
networkProxies?: NetworkProxy[]; // Array of NetworkProxy instances to use for TLS termination
// New settings for chained proxy configurations and TLS handling
isChainedProxy?: boolean; // Whether this proxy is part of a proxy chain (detected automatically if unspecified)
chainPosition?: 'first' | 'middle' | 'last'; // Position in the proxy chain (affects TLS handling)
aggressiveTlsRefresh?: boolean; // Use more aggressive TLS refresh timeouts (default: true for chained)
// TLS session cache configuration
tlsSessionCache?: {
enabled?: boolean; // Whether to use the TLS session cache (default: true)
maxEntries?: number; // Maximum cache entries (default: 10000)
expiryTime?: number; // Session expiry time in ms (default: 24h)
cleanupInterval?: number; // Cache cleanup interval in ms (default: 10min)
};
}
/**
* 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
incomingKeepAliveEnabled?: boolean; // Whether keep-alive is enabled on incoming socket
outgoingKeepAliveEnabled?: boolean; // Whether keep-alive is enabled on outgoing socket
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
}
/**
* Structure to track TLS session information for proper resumption handling
*/
interface ITlsSessionInfo {
domain: string; // The SNI domain associated with this session
sessionId?: Buffer; // The TLS session ID (if available)
ticketId?: string; // Session ticket identifier for newer TLS versions
ticketTimestamp: number; // When this session was recorded
lastAccessed?: number; // When this session was last accessed
accessCount?: number; // How many times this session has been used
}
/**
* Configuration for TLS session cache
*/
interface ITlsSessionCacheConfig {
maxEntries: number; // Maximum number of entries to keep in the cache
expiryTime: number; // Time in ms before sessions expire (default: 24 hours)
cleanupInterval: number; // Interval in ms to run cleanup (default: 10 minutes)
enabled: boolean; // Whether session caching is enabled
}
// Default configuration for session cache with relaxed timeouts
const DEFAULT_SESSION_CACHE_CONFIG: ITlsSessionCacheConfig = {
maxEntries: 20000, // Default max 20,000 entries (doubled)
expiryTime: 7 * 24 * 60 * 60 * 1000, // 7 days default (increased from 24 hours)
cleanupInterval: 30 * 60 * 1000, // Clean up every 30 minutes (relaxed from 10 minutes)
enabled: true // Enabled by default
};
// Enhanced TLS session cache with size limits and better performance
class TlsSessionCache {
private cache = new Map<string, ITlsSessionInfo>();
private config: ITlsSessionCacheConfig;
private cleanupTimer: NodeJS.Timeout | null = null;
private lastCleanupTime: number = 0;
private cacheStats = {
hits: 0,
misses: 0,
expirations: 0,
evictions: 0,
total: 0
};
constructor(config?: Partial<ITlsSessionCacheConfig>) {
this.config = { ...DEFAULT_SESSION_CACHE_CONFIG, ...config };
this.startCleanupTimer();
}
/**
* Get a session from the cache
*/
public get(key: string): ITlsSessionInfo | undefined {
// Skip if cache is disabled
if (!this.config.enabled) return undefined;
const entry = this.cache.get(key);
if (entry) {
// Update access information
entry.lastAccessed = Date.now();
entry.accessCount = (entry.accessCount || 0) + 1;
this.cache.set(key, entry);
this.cacheStats.hits++;
return entry;
}
this.cacheStats.misses++;
return undefined;
}
/**
* Check if the cache has a key
*/
public has(key: string): boolean {
// Skip if cache is disabled
if (!this.config.enabled) return false;
const exists = this.cache.has(key);
if (exists) {
const entry = this.cache.get(key)!;
// Check if entry has expired
if (Date.now() - entry.ticketTimestamp > this.config.expiryTime) {
this.cache.delete(key);
this.cacheStats.expirations++;
return false;
}
// Update last accessed time
entry.lastAccessed = Date.now();
this.cache.set(key, entry);
}
return exists;
}
/**
* Set a session in the cache
*/
public set(key: string, value: ITlsSessionInfo): void {
// Skip if cache is disabled
if (!this.config.enabled) return;
// Ensure timestamps are set
const entry = {
...value,
lastAccessed: Date.now(),
accessCount: 0
};
// Check if we need to evict entries
if (!this.cache.has(key) && this.cache.size >= this.config.maxEntries) {
this.evictOldest();
}
this.cache.set(key, entry);
this.cacheStats.total = this.cache.size;
// Run cleanup if it's been a while
const timeSinceCleanup = Date.now() - this.lastCleanupTime;
if (timeSinceCleanup > this.config.cleanupInterval * 2) {
this.cleanup();
}
}
/**
* Delete a session from the cache
*/
public delete(key: string): boolean {
return this.cache.delete(key);
}
/**
* Clear the entire cache
*/
public clear(): void {
this.cache.clear();
this.cacheStats.total = 0;
}
/**
* Get cache statistics
*/
public getStats(): any {
return {
...this.cacheStats,
size: this.cache.size,
enabled: this.config.enabled,
maxEntries: this.config.maxEntries,
expiryTimeHours: this.config.expiryTime / (60 * 60 * 1000)
};
}
/**
* Update cache configuration
*/
public updateConfig(config: Partial<ITlsSessionCacheConfig>): void {
this.config = { ...this.config, ...config };
// Restart the cleanup timer with new interval
this.startCleanupTimer();
// Run immediate cleanup if max entries was reduced
if (config.maxEntries && this.cache.size > config.maxEntries) {
while (this.cache.size > config.maxEntries) {
this.evictOldest();
}
}
}
/**
* Start the cleanup timer
*/
private startCleanupTimer(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
if (!this.config.enabled) return;
this.cleanupTimer = setInterval(() => {
this.cleanup();
}, this.config.cleanupInterval);
// Make sure the interval doesn't keep the process alive
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref();
}
}
/**
* Clean up expired entries
*/
private cleanup(): void {
this.lastCleanupTime = Date.now();
const now = Date.now();
let expiredCount = 0;
for (const [key, info] of this.cache.entries()) {
if (now - info.ticketTimestamp > this.config.expiryTime) {
this.cache.delete(key);
expiredCount++;
}
}
if (expiredCount > 0) {
this.cacheStats.expirations += expiredCount;
this.cacheStats.total = this.cache.size;
console.log(`TLS Session Cache: Cleaned up ${expiredCount} expired entries. ${this.cache.size} entries remaining.`);
}
}
/**
* Evict the oldest entries when cache is full
*/
private evictOldest(): void {
if (this.cache.size === 0) return;
let oldestKey: string | null = null;
let oldestTime = Date.now();
// Strategy: Find least recently accessed entry
for (const [key, info] of this.cache.entries()) {
const lastAccess = info.lastAccessed || info.ticketTimestamp;
if (lastAccess < oldestTime) {
oldestTime = lastAccess;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
this.cacheStats.evictions++;
}
}
/**
* Stop cleanup timer (used during shutdown)
*/
public stop(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
}
// Create the global session cache
const tlsSessionCache = new TlsSessionCache();
// Legacy function for backward compatibility
function stopSessionCleanupTimer() {
tlsSessionCache.stop();
}
/**
* Return type for the extractSNIInfo function
*/
interface ISNIExtractResult {
serverName?: string; // The extracted SNI hostname
sessionId?: Buffer; // The TLS session ID if present
sessionIdKey?: string; // The hex string representation of session ID
sessionTicketId?: string; // Session ticket identifier for TLS 1.3+ resumption
hasSessionTicket?: boolean; // Whether a session ticket extension was found
isResumption: boolean; // Whether this appears to be a session resumption
resumedDomain?: string; // The domain associated with the session if resuming
partialExtract?: boolean; // Whether this was only a partial extraction (more data needed)
recordsExamined?: number; // Number of TLS records examined in the buffer
multipleRecords?: boolean; // Whether multiple TLS records were found in the buffer
}
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* Enhanced for robustness and detailed logging.
* Also extracts and tracks TLS Session IDs for session resumption handling.
*
* Improved to handle:
* - Multiple TLS records in a single buffer
* - Fragmented TLS handshakes across multiple records
* - Partial TLS records that may continue in future chunks
*
* @param buffer - Buffer containing the TLS ClientHello.
* @param enableLogging - Whether to enable detailed logging.
* @returns An object containing SNI and session information, or undefined if parsing fails.
*/
function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | 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 {
isResumption: false,
partialExtract: true // Indicating we need more data
};
}
// Check first 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;
}
// Track multiple records and total records examined
let recordsExamined = 0;
let multipleRecords = false;
let currentPosition = 0;
let result: ISNIExtractResult | undefined;
// Process potentially multiple TLS records in the buffer
while (currentPosition + 5 <= buffer.length) {
recordsExamined++;
// Read record header
const currentRecordType = buffer.readUInt8(currentPosition);
// Only process handshake records (type 22)
if (currentRecordType !== 22) {
if (enableLogging) console.log(`Skipping non-handshake record at position ${currentPosition}, type: ${currentRecordType}`);
// Move to next potential record
if (currentPosition + 5 <= buffer.length) {
// Need at least 5 bytes to determine next record length
const nextRecordLength = buffer.readUInt16BE(currentPosition + 3);
currentPosition += 5 + nextRecordLength;
multipleRecords = true;
continue;
} else {
// Not enough data to determine next record
break;
}
}
// Check TLS version
const majorVersion = buffer.readUInt8(currentPosition + 1);
const minorVersion = buffer.readUInt8(currentPosition + 2);
if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion} at position ${currentPosition}`);
// Get record length
const recordLength = buffer.readUInt16BE(currentPosition + 3);
// Check if we have the complete record
if (currentPosition + 5 + recordLength > buffer.length) {
if (enableLogging) {
console.log(`Incomplete TLS record at position ${currentPosition}. Expected: ${currentPosition + 5 + recordLength}, Got: ${buffer.length}`);
}
// Return partial info and signal that more data is needed
return {
isResumption: false,
partialExtract: true,
recordsExamined,
multipleRecords
};
}
// Process this record - extract handshake information
const recordResult = extractSNIFromRecord(
buffer.slice(currentPosition, currentPosition + 5 + recordLength),
enableLogging
);
// If we found SNI or session info in this record, store it
if (recordResult && (recordResult.serverName || recordResult.isResumption)) {
result = recordResult;
result.recordsExamined = recordsExamined;
result.multipleRecords = multipleRecords;
// Once we've found SNI or session resumption info, we can stop processing
// But we'll still set the multipleRecords flag to indicate more records exist
if (currentPosition + 5 + recordLength < buffer.length) {
result.multipleRecords = true;
}
break;
}
// Move to the next record
currentPosition += 5 + recordLength;
// Set the flag if we've processed multiple records
if (currentPosition < buffer.length) {
multipleRecords = true;
}
}
// If we processed records but didn't find SNI or session info
if (recordsExamined > 0 && !result) {
return {
isResumption: false,
recordsExamined,
multipleRecords
};
}
return result;
} catch (err) {
console.log(`Error extracting SNI: ${err}`);
return undefined;
}
}
/**
* Extracts SNI information from a single TLS record
* This helper function processes a single complete TLS record
*/
function extractSNIFromRecord(recordBuffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined {
try {
// Skip the 5-byte TLS record header
let offset = 5;
// Verify this is a handshake message
const handshakeType = recordBuffer.readUInt8(offset);
if (handshakeType !== 1) { // 1 = ClientHello
if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`);
return undefined;
}
// Skip the 4-byte handshake header (type + 3 bytes length)
offset += 4;
// Check if we have at least 38 more bytes for protocol version and random
if (offset + 38 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for handshake header');
return undefined;
}
// Client version
const clientMajorVersion = recordBuffer.readUInt8(offset);
const clientMinorVersion = recordBuffer.readUInt8(offset + 1);
if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`);
// Skip version and random (2 + 32 bytes)
offset += 2 + 32;
// Session ID
if (offset + 1 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for session ID length');
return undefined;
}
// Extract Session ID for session resumption tracking
const sessionIDLength = recordBuffer.readUInt8(offset);
if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`);
// If there's a session ID, extract it
let sessionId: Buffer | undefined;
let sessionIdKey: string | undefined;
let isResumption = false;
let resumedDomain: string | undefined;
if (sessionIDLength > 0) {
if (offset + 1 + sessionIDLength > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for session ID data');
return undefined;
}
sessionId = Buffer.from(recordBuffer.slice(offset + 1, offset + 1 + sessionIDLength));
// Convert sessionId to a string key for our cache
sessionIdKey = sessionId.toString('hex');
if (enableLogging) {
console.log(`Session ID: ${sessionIdKey}`);
}
// Check if this is a session resumption attempt
if (tlsSessionCache.has(sessionIdKey)) {
const cachedInfo = tlsSessionCache.get(sessionIdKey)!;
resumedDomain = cachedInfo.domain;
isResumption = true;
if (enableLogging) {
console.log(`TLS Session Resumption detected for domain: ${resumedDomain}`);
}
}
}
offset += 1 + sessionIDLength; // Skip session ID length and data
// Cipher suites
if (offset + 2 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for cipher suites length');
return undefined;
}
const cipherSuitesLength = recordBuffer.readUInt16BE(offset);
if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`);
if (offset + 2 + cipherSuitesLength > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for cipher suites data');
return undefined;
}
offset += 2 + cipherSuitesLength; // Skip cipher suites length and data
// Compression methods
if (offset + 1 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for compression methods length');
return undefined;
}
const compressionMethodsLength = recordBuffer.readUInt8(offset);
if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`);
if (offset + 1 + compressionMethodsLength > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for compression methods data');
return undefined;
}
offset += 1 + compressionMethodsLength; // Skip compression methods length and data
// Check if we have extensions data
if (offset + 2 > recordBuffer.length) {
if (enableLogging) console.log('No extensions data found - end of ClientHello');
// Even without SNI, we might be dealing with a session resumption
if (isResumption && resumedDomain) {
return {
serverName: resumedDomain, // Use the domain from previous session
sessionId,
sessionIdKey,
hasSessionTicket: false,
isResumption: true,
resumedDomain
};
}
return {
isResumption,
sessionId,
sessionIdKey
};
}
// Extensions
const extensionsLength = recordBuffer.readUInt16BE(offset);
if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`);
offset += 2;
const extensionsEnd = offset + extensionsLength;
if (extensionsEnd > recordBuffer.length) {
if (enableLogging) {
console.log(`Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${recordBuffer.length}`);
}
// Even without complete extensions, we might be dealing with a session resumption
if (isResumption && resumedDomain) {
return {
serverName: resumedDomain, // Use the domain from previous session
sessionId,
sessionIdKey,
hasSessionTicket: false,
isResumption: true,
resumedDomain
};
}
return {
isResumption,
sessionId,
sessionIdKey,
partialExtract: true // Indicating we have incomplete extensions data
};
}
// Variables to track session tickets
let hasSessionTicket = false;
let sessionTicketId: string | undefined;
// Parse extensions
while (offset + 4 <= extensionsEnd) {
const extensionType = recordBuffer.readUInt16BE(offset);
const extensionLength = recordBuffer.readUInt16BE(offset + 2);
if (enableLogging) {
console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`);
}
if (offset + 4 + extensionLength > recordBuffer.length) {
if (enableLogging) {
console.log(`Extension data incomplete. Expected: ${offset + 4 + extensionLength}, Got: ${recordBuffer.length}`);
}
return {
isResumption,
sessionId,
sessionIdKey,
hasSessionTicket,
partialExtract: true
};
}
offset += 4;
// Check for Session Ticket extension (type 0x0023)
if (extensionType === 0x0023 && extensionLength > 0) {
hasSessionTicket = true;
// Extract a hash of the ticket for tracking
if (extensionLength > 16) { // Ensure we have enough bytes to create a meaningful ID
const ticketBytes = recordBuffer.slice(offset, offset + Math.min(16, extensionLength));
sessionTicketId = ticketBytes.toString('hex');
if (enableLogging) {
console.log(`Session Ticket found, ID: ${sessionTicketId}`);
// Check if this is a known session ticket
if (tlsSessionCache.has(`ticket:${sessionTicketId}`)) {
const cachedInfo = tlsSessionCache.get(`ticket:${sessionTicketId}`);
console.log(`TLS Session Ticket Resumption detected for domain: ${cachedInfo?.domain}`);
// Set isResumption and resumedDomain if not already set
if (!isResumption && !resumedDomain) {
isResumption = true;
resumedDomain = cachedInfo?.domain;
}
}
}
}
}
// Server Name Indication extension (type 0x0000)
if (extensionType === 0x0000) {
if (offset + 2 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for SNI list length');
return {
isResumption,
sessionId,
sessionIdKey,
hasSessionTicket,
partialExtract: true
};
}
const sniListLength = recordBuffer.readUInt16BE(offset);
if (enableLogging) console.log(`SNI List Length: ${sniListLength}`);
offset += 2;
const sniListEnd = offset + sniListLength;
if (sniListEnd > recordBuffer.length) {
if (enableLogging) {
console.log(`Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${recordBuffer.length}`);
}
return {
isResumption,
sessionId,
sessionIdKey,
hasSessionTicket,
partialExtract: true
};
}
while (offset + 3 < sniListEnd) {
const nameType = recordBuffer.readUInt8(offset++);
if (offset + 2 > recordBuffer.length) {
if (enableLogging) console.log('Buffer too small for SNI name length');
return {
isResumption,
sessionId,
sessionIdKey,
hasSessionTicket,
partialExtract: true
};
}
const nameLen = recordBuffer.readUInt16BE(offset);
offset += 2;
if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`);
// Only process hostname entries (type 0)
if (nameType === 0) {
if (offset + nameLen > recordBuffer.length) {
if (enableLogging) {
console.log(`Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${recordBuffer.length}`);
}
return {
isResumption,
sessionId,
sessionIdKey,
hasSessionTicket,
partialExtract: true
};
}
const serverName = recordBuffer.toString('utf8', offset, offset + nameLen);
if (enableLogging) console.log(`Extracted SNI: ${serverName}`);
// Store the session ID to domain mapping for future resumptions
if (sessionIdKey && sessionId && serverName) {
tlsSessionCache.set(sessionIdKey, {
domain: serverName,
sessionId: sessionId,
ticketTimestamp: Date.now()
});
if (enableLogging) {
console.log(`Stored session ${sessionIdKey} for domain ${serverName}`);
}
}
// Also store session ticket information if present
if (sessionTicketId && serverName) {
tlsSessionCache.set(`ticket:${sessionTicketId}`, {
domain: serverName,
ticketId: sessionTicketId,
ticketTimestamp: Date.now()
});
if (enableLogging) {
console.log(`Stored session ticket ${sessionTicketId} for domain ${serverName}`);
}
}
// Return the complete extraction result
return {
serverName,
sessionId,
sessionIdKey,
sessionTicketId,
isResumption,
resumedDomain,
hasSessionTicket
};
}
// Skip this name entry
offset += nameLen;
}
// Finished processing the SNI extension without finding a hostname
break;
} else {
// Skip other extensions
offset += extensionLength;
}
}
// We finished processing all extensions without finding SNI
if (enableLogging) console.log('No SNI extension found');
// Even without SNI, we might be dealing with a session resumption
if (isResumption && resumedDomain) {
return {
serverName: resumedDomain, // Use the domain from previous session
sessionId,
sessionIdKey,
sessionTicketId,
hasSessionTicket,
isResumption: true,
resumedDomain
};
}
// Return a basic result with just the session info
return {
isResumption,
sessionId,
sessionIdKey,
sessionTicketId,
hasSessionTicket,
resumedDomain
};
} catch (err) {
console.log(`Error in extractSNIFromRecord: ${err}`);
return undefined;
}
}
/**
* Legacy wrapper for extractSNIInfo to maintain backward compatibility
* @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 {
const result = extractSNIInfo(buffer, enableLogging);
return result?.serverName;
}
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some((range) => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns
const isAllowed = (ip: string, patterns: string[]): boolean => {
if (!ip || !patterns || patterns.length === 0) return false;
const normalizeIP = (ip: string): string[] => {
if (!ip) return [];
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
if (normalizedIPVariants.length === 0) return false;
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some((ipVariant) =>
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
);
};
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
if (!ip) return false;
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
return isAllowed(ip, allowed);
};
// Helper: Generate a unique connection ID
const generateConnectionId = (): string => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
// Helper: Check if a buffer contains a TLS handshake
const isTlsHandshake = (buffer: Buffer): boolean => {
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
};
// Helper: Ensure timeout values don't exceed Node.js max safe integer
const ensureSafeTimeout = (timeout: number): number => {
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
};
// Helper: Generate a slightly randomized timeout to prevent thundering herd
const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => {
const safeBaseTimeout = ensureSafeTimeout(baseTimeout);
const variation = safeBaseTimeout * (variationPercent / 100);
return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation);
};
export class PortProxy {
private netServers: plugins.net.Server[] = [];
// Define the internal settings interface to include all fields, including those removed from the public interface
settings: IPortProxySettings & {
// Internal fields removed from public interface in 3.31.0+
initialDataTimeout: number;
socketTimeout: number;
inactivityCheckInterval: number;
maxConnectionLifetime: number;
inactivityTimeout: number;
disableInactivityCheck: boolean;
enableKeepAliveProbes: boolean;
keepAliveTreatment: 'standard' | 'extended' | 'immortal';
keepAliveInactivityMultiplier: number;
extendedKeepAliveLifetime: number;
};
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Map to track round robin indices for each domain config
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
// Enhanced stats tracking
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = {
incoming: {},
outgoing: {},
};
// Connection tracking by IP for rate limiting
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
// New property to store NetworkProxy instances
private networkProxies: NetworkProxy[] = [];
constructor(settingsArg: IPortProxySettings) {
// Auto-detect if this is a chained proxy based on targetIP
const targetIP = settingsArg.targetIP || 'localhost';
const isChainedProxy = settingsArg.isChainedProxy !== undefined
? settingsArg.isChainedProxy
: (targetIP === 'localhost' || targetIP === '127.0.0.1');
// Use more aggressive timeouts for chained proxies
const aggressiveTlsRefresh = settingsArg.aggressiveTlsRefresh !== undefined
? settingsArg.aggressiveTlsRefresh
: isChainedProxy;
// Configure TLS session cache if specified
if (settingsArg.tlsSessionCache) {
tlsSessionCache.updateConfig({
enabled: settingsArg.tlsSessionCache.enabled,
maxEntries: settingsArg.tlsSessionCache.maxEntries,
expiryTime: settingsArg.tlsSessionCache.expiryTime,
cleanupInterval: settingsArg.tlsSessionCache.cleanupInterval
});
console.log(`Configured TLS session cache with custom settings. Current stats: ${JSON.stringify(tlsSessionCache.getStats())}`);
}
// Determine appropriate timeouts based on proxy chain position
// Much more relaxed socket timeouts
let socketTimeout = 6 * 60 * 60 * 1000; // 6 hours default for standalone
if (isChainedProxy) {
// Still adjust based on chain position, but with more relaxed values
const chainPosition = settingsArg.chainPosition || 'middle';
// Adjust timeouts based on position in chain, but significantly relaxed
switch (chainPosition) {
case 'first':
// First proxy handling browser connections
socketTimeout = 6 * 60 * 60 * 1000; // 6 hours
break;
case 'middle':
// Middle proxies
socketTimeout = 5 * 60 * 60 * 1000; // 5 hours
break;
case 'last':
// Last proxy connects to backend
socketTimeout = 6 * 60 * 60 * 1000; // 6 hours
break;
}
console.log(`Configured as ${chainPosition} proxy in chain. Using relaxed timeouts for better stability.`);
}
// Set sensible defaults with significantly relaxed timeouts
this.settings = {
...settingsArg,
targetIP: targetIP,
// Record the chained proxy status for use in other methods
isChainedProxy: isChainedProxy,
chainPosition: settingsArg.chainPosition || (isChainedProxy ? 'middle' : 'last'),
aggressiveTlsRefresh: aggressiveTlsRefresh,
// Much more relaxed timeout settings
initialDataTimeout: 120000, // 2 minutes for initial handshake (doubled)
socketTimeout: socketTimeout, // 5-6 hours based on chain position
inactivityCheckInterval: 5 * 60 * 1000, // 5 minutes between checks (relaxed)
maxConnectionLifetime: 12 * 60 * 60 * 1000, // 12 hours lifetime
inactivityTimeout: 4 * 60 * 60 * 1000, // 4 hours inactivity timeout
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 60000, // 60 seconds
// Socket optimization settings
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds (increased)
maxPendingDataSize: settingsArg.maxPendingDataSize || 20 * 1024 * 1024, // 20MB to handle large TLS handshakes
// Feature flags - simplified with sensible defaults
disableInactivityCheck: false, // Still enable inactivity checks
enableKeepAliveProbes: true, // Still enable keep-alive probes
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 200, // 200 connections per IP (doubled)
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 500, // 500 per minute (increased)
// Keep-alive settings with much more relaxed defaults
keepAliveTreatment: 'extended', // Use extended keep-alive treatment
keepAliveInactivityMultiplier: 3, // 3x normal inactivity timeout for longer extension
// Much longer keep-alive lifetimes
extendedKeepAliveLifetime: isChainedProxy
? 24 * 60 * 60 * 1000 // 24 hours for chained proxies
: 48 * 60 * 60 * 1000, // 48 hours for standalone proxies
};
// Store NetworkProxy instances if provided
this.networkProxies = settingsArg.networkProxies || [];
// Log proxy configuration details
console.log(`PortProxy initialized with ${isChainedProxy ? 'chained proxy' : 'standalone'} configuration.`);
if (isChainedProxy) {
console.log(`TLS certificate refresh: ${aggressiveTlsRefresh ? 'Aggressive' : 'Standard'}`);
console.log(`Connection lifetime: ${plugins.prettyMs(this.settings.maxConnectionLifetime)}`);
console.log(`Inactivity timeout: ${plugins.prettyMs(this.settings.inactivityTimeout)}`);
}
}
/**
* 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 with optimized settings for reliability
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort,
noDelay: true, // Disable Nagle's algorithm for NetworkProxy connections
keepAlive: this.settings.keepAlive, // Use the same keepAlive setting as regular connections
keepAliveInitialDelay: Math.max(this.settings.keepAliveInitialDelay - 5000, 5000) // Slightly faster
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
record.networkProxyIndex = proxyIndex;
// Mark keep-alive as enabled on outgoing if requested
if (this.settings.keepAlive) {
record.outgoingKeepAliveEnabled = true;
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
try {
if ('setKeepAliveProbes' in proxySocket) {
(proxySocket as any).setKeepAliveProbes(10);
}
if ('setKeepAliveInterval' in proxySocket) {
(proxySocket as any).setKeepAliveInterval(800);
}
console.log(`[${connectionId}] Enhanced TCP keep-alive configured for NetworkProxy connection`);
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Enhanced keep-alive not supported for NetworkProxy: ${err}`);
}
}
}
}
// Set up error handlers
proxySocket.on('error', (err) => {
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
this.cleanupConnection(record, 'network_proxy_connect_error');
});
// Handle connection to NetworkProxy
proxySocket.on('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// First send the initial data that contains the TLS ClientHello
proxySocket.write(initialData);
// Now set up bidirectional piping between client and NetworkProxy
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Setup cleanup handlers
proxySocket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] NetworkProxy connection closed`);
}
this.cleanupConnection(record, 'network_proxy_closed');
});
socket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Client connection closed after forwarding to NetworkProxy`
);
}
this.cleanupConnection(record, 'client_closed');
});
// Special handler for TLS handshake detection with NetworkProxy
socket.on('data', (chunk: Buffer) => {
// Check for TLS handshake packets (ContentType.handshake)
if (chunk.length > 0 && chunk[0] === 22) {
console.log(`[${connectionId}] Detected potential TLS handshake with NetworkProxy, updating activity`);
this.updateActivity(record);
}
});
// Update activity on data transfer from the proxy socket
proxySocket.on('data', () => this.updateActivity(record));
// Special handling for application-level keep-alives on NetworkProxy connections
if (this.settings.keepAlive && record.isTLS) {
// Set up a timer to periodically send application-level keep-alives
const keepAliveTimer = setInterval(() => {
if (proxySocket && !proxySocket.destroyed && record && !record.connectionClosed) {
try {
// Send 0-byte packet as application-level keep-alive
proxySocket.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Sent application-level keep-alive to NetworkProxy connection`);
}
} catch (err) {
// If we can't write, the connection is probably already dead
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Error sending application-level keep-alive to NetworkProxy: ${err}`);
}
// Stop the timer if we hit an error
clearInterval(keepAliveTimer);
}
} else {
// Clean up timer if connection is gone
clearInterval(keepAliveTimer);
}
}, 60000); // Send keep-alive every minute
// Make sure interval doesn't prevent process exit
if (keepAliveTimer.unref) {
keepAliveTimer.unref();
}
console.log(`[${connectionId}] Application-level keep-alive configured for NetworkProxy connection`);
}
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 {
// Enhanced logging for initial connection troubleshooting
if (serverName) {
console.log(`[${connectionId}] Setting up direct connection for domain: ${serverName}`);
} else {
console.log(`[${connectionId}] Setting up direct connection without SNI`);
}
// Log domain config details to help diagnose routing issues
if (domainConfig) {
console.log(`[${connectionId}] Using domain config: ${domainConfig.domains.join(', ')}`);
} else {
console.log(`[${connectionId}] No specific domain config found, using default settings`);
}
// Ensure we maximize connection chances by setting appropriate timeouts
socket.setTimeout(30000); // 30 second initial connect timeout
// 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,
// Add connection timeout to ensure we don't hang indefinitely
timeout: 15000 // 15 second connection timeout
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
console.log(`[${connectionId}] Connecting to backend: ${targetHost}:${connectionOptions.port}`);
// 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 - this is critical for SNI forwarding
if (initialChunk) {
// Make explicit copy of the buffer to ensure it doesn't get modified
const initialDataCopy = Buffer.from(initialChunk);
record.bytesReceived += initialDataCopy.length;
record.pendingData.push(initialDataCopy);
record.pendingDataSize = initialDataCopy.length;
// Log TLS handshake for debug purposes
if (isTlsHandshake(initialChunk)) {
record.isTLS = true;
console.log(`[${connectionId}] Buffered TLS handshake data: ${initialDataCopy.length} bytes, SNI: ${serverName || 'none'}`);
}
} else if (record.isTLS) {
// This shouldn't happen, but log a warning if we have a TLS connection with no initial data
console.log(`[${connectionId}] WARNING: TLS connection without initial handshake data`);
}
// 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) {
// Use a slightly shorter initial delay for outgoing to ensure it stays active
const outgoingInitialDelay = Math.max(this.settings.keepAliveInitialDelay - 5000, 5000);
targetSocket.setKeepAlive(true, outgoingInitialDelay);
record.outgoingKeepAliveEnabled = true;
console.log(`[${connectionId}] Keep-alive enabled on outgoing connection with initial delay: ${outgoingInitialDelay}ms`);
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
try {
if ('setKeepAliveProbes' in targetSocket) {
(targetSocket as any).setKeepAliveProbes(10); // Same probes as incoming
}
if ('setKeepAliveInterval' in targetSocket) {
// Use a shorter interval on outgoing for more reliable detection
(targetSocket as any).setKeepAliveInterval(800); // Slightly faster than incoming
}
console.log(`[${connectionId}] Enhanced TCP keep-alive probes configured on outgoing connection`);
} 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}`
);
}
}
}
// Special handling for TLS keep-alive - we want to be more aggressive
// with keeping the outgoing connection alive in TLS mode
if (record.isTLS) {
// Set a timer to periodically send empty data to keep connection alive
// This is in addition to TCP keep-alive, works at application layer
const keepAliveTimer = setInterval(() => {
if (targetSocket && !targetSocket.destroyed && record && !record.connectionClosed) {
try {
// Send 0-byte packet as application-level keep-alive
targetSocket.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Sent application-level keep-alive to outgoing TLS connection`);
}
} catch (err) {
// If we can't write, the connection is probably already dead
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Error sending application-level keep-alive: ${err}`);
}
// Stop the timer if we hit an error
clearInterval(keepAliveTimer);
}
} else {
// Clean up timer if connection is gone
clearInterval(keepAliveTimer);
}
}, 60000); // Send keep-alive every minute
// Make sure interval doesn't prevent process exit
if (keepAliveTimer.unref) {
keepAliveTimer.unref();
}
console.log(`[${connectionId}] Application-level keep-alive configured for TLS outgoing connection`);
}
}
// Setup specific error handler for connection phase with enhanced retries
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();
// Add detailed logging for connection problems
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`);
}
// Log additional diagnostics
console.log(`[${connectionId}] Connection details - SNI: ${serverName || 'none'}, HasChunk: ${!!initialChunk}, ChunkSize: ${initialChunk ? initialChunk.length : 0}`);
// For TLS connections, provide even more detailed diagnostics
if (record.isTLS) {
console.log(`[${connectionId}] TLS connection failure details - TLS detected: ${record.isTLS}, Server: ${targetHost}:${connectionOptions.port}, Domain config: ${domainConfig ? 'Present' : 'Missing'}`);
}
// For connection refusal or timeouts, try a more aggressive error response
// This helps browsers quickly realize there's an issue rather than waiting
if (code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EHOSTUNREACH') {
try {
// Send a RST packet rather than a graceful close
// This signals to browsers to try a new connection immediately
socket.destroy(new Error(`Backend connection failed: ${code}`));
console.log(`[${connectionId}] Forced connection termination to trigger immediate browser retry`);
return; // Skip normal cleanup
} catch (destroyErr) {
console.log(`[${connectionId}] Error during forced connection termination: ${destroyErr}`);
}
}
// 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);
// Add critical debugging for SNI forwarding issues
if (record.isTLS && this.settings.enableTlsDebugLogging) {
console.log(`[${connectionId}] Forwarding TLS handshake data: ${combinedData.length} bytes, SNI: ${serverName || 'none'}`);
// Additional check to verify we're forwarding the ClientHello properly
if (combinedData[0] === 22) { // TLS handshake
console.log(`[${connectionId}] Initial data is a TLS handshake record`);
}
}
// Write the combined data to the target
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.initiateCleanupOnce(record, 'write_error');
}
if (record.isTLS) {
// Log successful forwarding of initial TLS data
console.log(`[${connectionId}] Successfully forwarded initial TLS data to backend`);
}
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// Create a flag to prevent double-processing of the same handshake packet
let processingRenegotiation = false;
// This listener handles TLS renegotiation detection on the incoming socket
socket.on('data', (renegChunk) => {
// Only check for content type 22 (handshake) and not already processing
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22 && !processingRenegotiation) {
processingRenegotiation = true;
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Enhanced logging for renegotiation
console.log(`[${connectionId}] TLS handshake/renegotiation packet detected (${renegChunk.length} bytes)`);
// Extract all TLS information including session resumption data
const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging);
// Log details about the handshake packet
if (this.settings.enableTlsDebugLogging) {
console.log(`[${connectionId}] Handshake SNI extraction results:`, {
isResumption: sniInfo?.isResumption || false,
serverName: sniInfo?.serverName || 'none',
resumedDomain: sniInfo?.resumedDomain || 'none',
recordsExamined: sniInfo?.recordsExamined || 0,
multipleRecords: sniInfo?.multipleRecords || false,
partialExtract: sniInfo?.partialExtract || false
});
}
let newSNI = sniInfo?.serverName;
// Handle session resumption - if we recognize the session ID, we know what domain it belongs to
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`);
newSNI = sniInfo.resumedDomain;
}
// IMPORTANT: If we can't extract an SNI from renegotiation, but we detected a TLS handshake,
// we still need to make sure it's properly forwarded to maintain the TLS state
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, forwarding transparently.`);
// Set a temporary timeout to reset the processing flag
setTimeout(() => {
processingRenegotiation = false;
}, 500);
return; // Let the piping handle the forwarding
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
}
if (!allowed) {
const newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
return;
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
} finally {
// Reset the processing flag after a small delay to prevent double-processing
// of packets that may be part of the same handshake
setTimeout(() => {
processingRenegotiation = false;
}, 500);
}
}
});
// Set up a listener on the outgoing socket to detect issues with renegotiation
// This helps catch cases where the outgoing connection has closed but the incoming is still active
targetSocket.on('error', (err) => {
// If we get an error during what might be a renegotiation, log it specially
if (processingRenegotiation) {
console.log(`[${connectionId}] ERROR: Outgoing socket error during TLS renegotiation: ${err.message}`);
// Force immediate cleanup to prevent hanging connections
this.initiateCleanupOnce(record, 'renegotiation_error');
}
// The normal error handler will be called for other errors
});
}
// Now set up piping for future data and resume the socket
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}`
);
}
});
} else {
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// Create a flag to prevent double-processing of the same handshake packet
let processingRenegotiation = false;
// This listener handles TLS renegotiation detection on the incoming socket
socket.on('data', (renegChunk) => {
// Only check for content type 22 (handshake) and not already processing
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22 && !processingRenegotiation) {
processingRenegotiation = true;
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Enhanced logging for renegotiation
console.log(`[${connectionId}] TLS handshake/renegotiation packet detected (${renegChunk.length} bytes)`);
// Extract all TLS information including session resumption data
const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging);
// Log details about the handshake packet
if (this.settings.enableTlsDebugLogging) {
console.log(`[${connectionId}] Handshake SNI extraction results:`, {
isResumption: sniInfo?.isResumption || false,
serverName: sniInfo?.serverName || 'none',
resumedDomain: sniInfo?.resumedDomain || 'none',
recordsExamined: sniInfo?.recordsExamined || 0,
multipleRecords: sniInfo?.multipleRecords || false,
partialExtract: sniInfo?.partialExtract || false
});
}
let newSNI = sniInfo?.serverName;
// Handle session resumption - if we recognize the session ID, we know what domain it belongs to
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`);
newSNI = sniInfo.resumedDomain;
}
// IMPORTANT: If we can't extract an SNI from renegotiation, but we detected a TLS handshake,
// we still need to make sure it's properly forwarded to maintain the TLS state
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, forwarding transparently.`);
// Set a temporary timeout to reset the processing flag
setTimeout(() => {
processingRenegotiation = false;
}, 500);
return; // Let the piping handle the forwarding
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
// First check if the new SNI is allowed under the existing domain config
// This is the preferred approach as it maintains the existing connection context
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
if (allowed) {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} allowed by existing domain config`);
}
}
// If not allowed by existing config, try to find an alternative domain config
if (!allowed) {
// First try exact match
let newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
// If no exact match, try flexible matching with domain parts (for wildcard domains)
if (!newDomainConfig) {
console.log(`[${connectionId}] No exact domain config match for rehandshake SNI: ${newSNI}, trying flexible matching`);
const domainParts = newSNI.split('.');
// Try matching with parent domains or wildcard patterns
if (domainParts.length > 2) {
const parentDomain = domainParts.slice(1).join('.');
const wildcardDomain = '*.' + parentDomain;
console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`);
newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) =>
d === parentDomain ||
d === wildcardDomain ||
plugins.minimatch(parentDomain, d)
)
);
}
}
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
return;
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
} finally {
// Reset the processing flag after a small delay to prevent double-processing
// of packets that may be part of the same handshake
setTimeout(() => {
processingRenegotiation = false;
}, 500);
}
}
});
// Set up a listener on the outgoing socket to detect issues with renegotiation
// This helps catch cases where the outgoing connection has closed but the incoming is still active
targetSocket.on('error', (err) => {
// If we get an error during what might be a renegotiation, log it specially
if (processingRenegotiation) {
console.log(`[${connectionId}] ERROR: Outgoing socket error during TLS renegotiation: ${err.message}`);
// Force immediate cleanup to prevent hanging connections
this.initiateCleanupOnce(record, 'renegotiation_error');
}
// The normal error handler will be called for other errors
});
// Also monitor targetSocket for connection issues during client handshakes
targetSocket.on('close', () => {
// If the outgoing socket closes during renegotiation, it's a critical issue
if (processingRenegotiation) {
console.log(`[${connectionId}] CRITICAL: Outgoing socket closed during TLS renegotiation!`);
console.log(`[${connectionId}] This likely explains cert mismatch errors in the browser.`);
// Force immediate cleanup on the client side
this.initiateCleanupOnce(record, 'target_closed_during_renegotiation');
}
});
}
// Now set up piping
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}`
);
}
}
// Clear the buffer now that we've processed it
record.pendingData = [];
record.pendingDataSize = 0;
// Renegotiation detection is now handled before piping is established
// This ensures the data listener receives all packets properly
// Set connection timeout with simpler logic
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
}
// For immortal keep-alive connections, skip setting a timeout completely
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime`
);
}
// No cleanup timer for immortal connections
}
// For TLS keep-alive connections, use a more generous timeout now that
// we've fixed the renegotiation handling issue that was causing certificate problems
else if (record.hasKeepAlive && record.isTLS) {
// Use a longer timeout for TLS connections now that renegotiation handling is fixed
// This reduces unnecessary reconnections while still ensuring certificate freshness
const tlsKeepAliveTimeout = 4 * 60 * 60 * 1000; // 4 hours for TLS keep-alive - increased from 30 minutes
const safeTimeout = ensureSafeTimeout(tlsKeepAliveTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] TLS keep-alive connection from ${
record.remoteIP
} exceeded max lifetime (${plugins.prettyMs(
tlsKeepAliveTimeout
)}), forcing cleanup to refresh certificate context.`
);
this.initiateCleanupOnce(record, 'tls_certificate_refresh');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] TLS keep-alive connection with aggressive certificate refresh protection, lifetime: ${plugins.prettyMs(
tlsKeepAliveTimeout
)}`
);
}
}
// For extended keep-alive connections, use extended timeout
else if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const extendedTimeout = this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000; // 7 days
const safeTimeout = ensureSafeTimeout(extendedTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Keep-alive connection from ${
record.remoteIP
} exceeded extended lifetime (${plugins.prettyMs(extendedTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'extended_lifetime');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs(
extendedTimeout
)}`
);
}
}
// For standard connections, use normal timeout
else {
// Use domain-specific timeout if available, otherwise use default
const connectionTimeout =
record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!;
const safeTimeout = ensureSafeTimeout(connectionTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Connection from ${
record.remoteIP
} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'connection_timeout');
}, safeTimeout);
// Make sure timeout doesn't keep the process alive
if (record.cleanupTimer.unref) {
record.cleanupTimer.unref();
}
}
// Mark TLS handshake as complete for TLS connections
if (record.isTLS) {
record.tlsHandshakeComplete = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}`
);
}
}
});
}
/**
* Get connections count by IP
*/
private getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
*/
private checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
}
/**
* Track connection by IP
*/
private trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
private removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Track connection termination statistic
*/
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Update connection activity timestamp with enhanced sleep detection
* Improved for chained proxy scenarios and more aggressive handling of stale connections
*/
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;
// Enhanced sleep detection with graduated thresholds - much more relaxed
// Using chain detection from settings instead of recalculating
const isChainedProxy = this.settings.isChainedProxy || false;
const minuteInMs = 60 * 1000;
const hourInMs = 60 * minuteInMs;
// Significantly relaxed thresholds for better stability
const shortInactivityThreshold = 30 * minuteInMs; // 30 minutes
const mediumInactivityThreshold = 2 * hourInMs; // 2 hours
const longInactivityThreshold = 8 * hourInMs; // 8 hours
// Short inactivity (10-15 mins) - Might be temporary network issue or short sleep
if (timeDiff > shortInactivityThreshold) {
if (record.isTLS && !record.possibleSystemSleep) {
// Record first detection of possible sleep/inactivity
record.possibleSystemSleep = true;
record.lastSleepDetection = now;
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] Detected possible short inactivity for ${plugins.prettyMs(timeDiff)}. ` +
`Monitoring for TLS connection health.`
);
}
// For TLS connections, send a minimal probe to check connection health
if (!record.usingNetworkProxy && record.outgoing && !record.outgoing.destroyed) {
try {
record.outgoing.write(Buffer.alloc(0));
} catch (err) {
console.log(`[${record.id}] Error sending TLS probe: ${err}`);
}
}
}
}
// Medium inactivity (20-30 mins) - Likely a sleep event or network change
if (timeDiff > mediumInactivityThreshold && record.hasKeepAlive) {
console.log(
`[${record.id}] Detected medium inactivity period for ${plugins.prettyMs(timeDiff)}. ` +
`Taking proactive steps for connection health.`
);
// For TLS connections, we need more aggressive handling
if (record.isTLS && record.tlsHandshakeComplete) {
// If in a chained proxy, we should be even more aggressive about refreshing
if (isChainedProxy) {
console.log(
`[${record.id}] TLS connection in chained proxy inactive for ${plugins.prettyMs(timeDiff)}. ` +
`Closing to prevent certificate inconsistencies across chain.`
);
return this.initiateCleanupOnce(record, 'chained_proxy_inactivity');
}
// For TLS in single proxy, try refresh first
console.log(
`[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` +
`Attempting active refresh of TLS state.`
);
// Attempt deep TLS state refresh with buffer flush
this.performDeepTlsRefresh(record);
// Schedule verification check with tighter timing for chained setups
const verificationTimeout = isChainedProxy ? 5 * minuteInMs : 10 * minuteInMs;
const refreshCheckId = record.id;
const refreshCheck = setTimeout(() => {
const currentRecord = this.connectionRecords.get(refreshCheckId);
if (currentRecord) {
const verificationTimeDiff = Date.now() - currentRecord.lastActivity;
if (verificationTimeDiff > verificationTimeout / 2) {
console.log(
`[${refreshCheckId}] No activity detected after TLS refresh (${plugins.prettyMs(verificationTimeDiff)}). ` +
`Closing connection to ensure proper browser reconnection.`
);
this.initiateCleanupOnce(currentRecord, 'tls_refresh_verification_failed');
}
}
}, verificationTimeout);
// Make sure timeout doesn't keep the process alive
if (refreshCheck.unref) {
refreshCheck.unref();
}
}
// Update sleep detection markers
record.possibleSystemSleep = true;
record.lastSleepDetection = now;
}
// Long inactivity (60-120 mins) - Definite sleep/suspend or major network change
if (timeDiff > longInactivityThreshold) {
console.log(
`[${record.id}] Detected long inactivity period of ${plugins.prettyMs(timeDiff)}. ` +
`Closing connection to ensure fresh certificate context.`
);
// For long periods, we always want to force close and let browser reconnect
// This ensures fresh certificates and proper TLS context across the chain
return this.initiateCleanupOnce(record, 'extended_inactivity_refresh');
}
}
// Update the activity timestamp
record.lastActivity = now;
// Clear any inactivity warning
if (record.inactivityWarningIssued) {
record.inactivityWarningIssued = false;
}
}
/**
* Perform deep TLS state refresh after sleep detection
* More aggressive than the standard refresh, specifically designed for
* recovering connections after system sleep in chained proxy setups
*/
private performDeepTlsRefresh(record: IConnectionRecord): void {
// Skip if we're using a NetworkProxy as it handles its own TLS state
if (record.usingNetworkProxy) {
return;
}
try {
// For outgoing connections that might need to be refreshed
if (record.outgoing && !record.outgoing.destroyed) {
// Check how long this connection has been established
const connectionAge = Date.now() - record.incomingStartTime;
const hourInMs = 60 * 60 * 1000;
// For very long-lived connections, just close them
if (connectionAge > 4 * hourInMs) { // Reduced from 8 hours to 4 hours for chained proxies
console.log(
`[${record.id}] Long-lived TLS connection (${plugins.prettyMs(connectionAge)}). ` +
`Closing to ensure proper certificate handling across proxy chain.`
);
return this.initiateCleanupOnce(record, 'certificate_age_refresh');
}
// Perform a series of actions to try to refresh the TLS state
// 1. Send a zero-length buffer to trigger any pending errors
record.outgoing.write(Buffer.alloc(0));
// 2. Check socket state
if (record.outgoing.writableEnded || !record.outgoing.writable) {
console.log(`[${record.id}] Socket no longer writable during refresh`);
return this.initiateCleanupOnce(record, 'socket_state_error');
}
// 3. For TLS connections, try to force background renegotiation
// by manipulating socket timeouts
const originalTimeout = record.outgoing.timeout;
record.outgoing.setTimeout(100); // Set very short timeout
// 4. Create a small delay to allow timeout to process
setTimeout(() => {
try {
if (record.outgoing && !record.outgoing.destroyed) {
// Reset timeout to original value
record.outgoing.setTimeout(originalTimeout || 0);
// Send another probe with random data (16 bytes) that will be ignored by TLS layer
// but might trigger internal state updates in the TLS implementation
const probeBuffer = Buffer.alloc(16);
// Fill with random data
for (let i = 0; i < 16; i++) {
probeBuffer[i] = Math.floor(Math.random() * 256);
}
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Completed deep TLS refresh sequence`);
}
}
} catch (innerErr) {
console.log(`[${record.id}] Error during deep TLS refresh: ${innerErr}`);
this.initiateCleanupOnce(record, 'deep_refresh_error');
}
}, 150);
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Initiated deep TLS refresh sequence`);
}
}
} catch (err) {
console.log(`[${record.id}] Error starting TLS state refresh: ${err}`);
// If we hit an error, it's likely the connection is already broken
// Force cleanup to ensure browser reconnects cleanly
return this.initiateCleanupOnce(record, 'tls_refresh_error');
}
}
/**
* Legacy refresh method for backward compatibility
*/
private refreshTlsStateAfterSleep(record: IConnectionRecord): void {
return this.performDeepTlsRefresh(record);
}
/**
* 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,
// Track keep-alive state for both sides of the connection
incomingKeepAliveEnabled: false,
outgoingKeepAliveEnabled: false,
};
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
// Configure incoming socket keep-alive
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
connectionRecord.hasKeepAlive = true; // Mark connection as having keep-alive
connectionRecord.incomingKeepAliveEnabled = true;
console.log(`[${connectionId}] Keep-alive enabled on incoming connection with initial delay: ${this.settings.keepAliveInitialDelay}ms`);
// 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
}
console.log(`[${connectionId}] Enhanced TCP keep-alive probes configured on incoming connection`);
} 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.
let domainConfig = forcedDomain
? forcedDomain
: serverName
? this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
)
: undefined;
// Enhanced logging to diagnose domain config selection issues
if (serverName && !domainConfig) {
console.log(`[${connectionId}] WARNING: No domain config found for SNI: ${serverName}`);
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
} else if (serverName && domainConfig) {
console.log(`[${connectionId}] Found domain config for SNI: ${serverName} -> ${domainConfig.domains.join(',')}`);
}
// For session resumption, ensure we use the domain config matching the resumed domain
// The resumed domain will be in serverName if this is a session resumption
if (serverName && connectionRecord.lockedDomain === serverName && serverName !== '') {
// Override domain config lookup for session resumption - crucial for certificate selection
// First try an exact match
let resumedDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
);
// If no exact match found, try a more flexible approach using domain parts
if (!resumedDomainConfig) {
console.log(`[${connectionId}] No exact domain config match for resumed domain: ${serverName}, trying flexible matching`);
// Extract domain parts (e.g., for "sub.example.com" try matching with "*.example.com")
const domainParts = serverName.split('.');
// Try matching with parent domains or wildcard patterns
if (domainParts.length > 2) {
const parentDomain = domainParts.slice(1).join('.');
const wildcardDomain = '*.' + parentDomain;
console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`);
resumedDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) =>
d === parentDomain ||
d === wildcardDomain ||
plugins.minimatch(parentDomain, d)
)
);
}
}
if (resumedDomainConfig) {
domainConfig = resumedDomainConfig;
console.log(`[${connectionId}] Found domain config for resumed session: ${serverName} -> ${resumedDomainConfig.domains.join(',')}`);
} else {
// As a fallback, use the first domain config with the same target IP if possible
if (domainConfig && domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const targetIP = domainConfig.targetIPs[0];
const similarConfig = this.settings.domainConfigs.find((config) =>
config.targetIPs && config.targetIPs.includes(targetIP)
);
if (similarConfig && similarConfig !== domainConfig) {
console.log(`[${connectionId}] Using similar domain config with matching target IP for resumed domain: ${serverName}`);
domainConfig = similarConfig;
} else {
console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`);
// Log available domains to help diagnose the issue
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
}
} else {
console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`);
// Log available domains to help diagnose the issue
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
}
}
}
// Save domain config in connection record
connectionRecord.domainConfig = domainConfig;
// Always set the lockedDomain, even for non-SNI connections
if (serverName) {
connectionRecord.lockedDomain = serverName;
console.log(`[${connectionId}] Locked connection to domain: ${serverName}`);
}
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs: string[] = [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
// Skip IP validation if allowedIPs is empty
if (
domainConfig.allowedIPs.length > 0 &&
!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(
', '
)}`
);
}
// Check if we should forward this to a NetworkProxy
if (
isTlsHandshakeDetected &&
domainConfig.useNetworkProxy === true &&
initialChunk &&
this.networkProxies.length > 0
) {
return this.forwardToNetworkProxy(
connectionId,
socket,
connectionRecord,
domainConfig,
initialChunk,
serverName
);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (
!isGlobIPAllowed(
remoteIP,
this.settings.defaultAllowedIPs,
this.settings.defaultBlockedIPs || []
)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed by default allowed list`
);
}
}
// If we didn't forward to NetworkProxy, proceed with direct connection
return this.setupDirectConnection(
connectionId,
socket,
connectionRecord,
domainConfig,
serverName,
initialChunk,
overridePort
);
};
// --- PORT RANGE-BASED HANDLING ---
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (
this.settings.globalPortRanges &&
isPortInRanges(localPort, this.settings.globalPortRanges)
) {
if (this.settings.forwardAllGlobalRanges) {
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!isAllowed(remoteIP, this.settings.defaultAllowedIPs)
) {
console.log(
`[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`
);
}
setupConnection(
'',
undefined,
{
domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [],
blockedIPs: this.settings.defaultBlockedIPs || [],
targetIPs: [this.settings.targetIP!],
portRanges: [],
},
localPort
);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domainConfigs.find(
(domain) =>
domain.portRanges &&
domain.portRanges.length > 0 &&
isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
const effectiveAllowedIPs: string[] = [
...forcedDomain.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs: string[] = [
...(forcedDomain.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
console.log(
`[${connectionId}] Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(
', '
)} on port ${localPort}.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(
', '
)}.`
);
}
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
// Try to extract SNI - with enhanced logging for troubleshooting
let serverName = '';
// Record the chunk size for diagnostic purposes
console.log(`[${connectionId}] Received initial data: ${chunk.length} bytes`);
if (isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
console.log(`[${connectionId}] Detected TLS handshake`);
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`
);
}
// Extract all TLS information including session resumption
const sniInfo = extractSNIInfo(chunk, this.settings.enableTlsDebugLogging);
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
// This is a session resumption with a known domain
serverName = sniInfo.resumedDomain;
console.log(`[${connectionId}] TLS Session resumption detected for domain: ${serverName}`);
// When resuming a session, explicitly set the domain in the record to ensure proper routing
// This is CRITICAL for ensuring we select the correct backend/certificate
connectionRecord.lockedDomain = serverName;
// Force detailed logging for resumed sessions to help with troubleshooting
console.log(`[${connectionId}] Resuming TLS session for domain ${serverName} - will use original certificate`);
} else {
// Normal SNI extraction
serverName = sniInfo?.serverName || '';
if (serverName) {
console.log(`[${connectionId}] Extracted SNI domain: ${serverName}`);
} else {
console.log(`[${connectionId}] No SNI found in TLS handshake`);
}
}
} else {
console.log(`[${connectionId}] Non-TLS connection detected`);
}
// Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = serverName;
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Received connection from ${remoteIP} with SNI: ${
serverName || '(empty)'
}`
);
}
setupConnection(serverName, chunk);
});
} else {
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!isAllowed(remoteIP, this.settings.defaultAllowedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`
);
}
setupConnection('');
}
};
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set<number>();
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// Listen on every port defined by the global ranges.
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isn't already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
}
// Create a server for each port.
for (const port of listeningPorts) {
const server = plugins.net.createServer(connectionHandler).on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
console.log(
`PortProxy -> OK: Now listening on port ${port}${
this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''
}${this.networkProxies.length > 0 ? ' (NetworkProxy integration enabled)' : ''}`
);
});
this.netServers.push(server);
}
// Log active connection count, longest running durations, and run parity checks periodically
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown) return;
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let tlsConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
} else {
pendingTlsHandshakes++;
}
} else {
nonTlsConnections++;
}
if (record.hasKeepAlive) {
keepAliveConnections++;
}
if (record.usingNetworkProxy) {
networkProxyConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// Parity check: if outgoing socket closed and incoming remains active
if (
record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
now - record.outgoingClosedTime > 120000
) {
const remoteIP = record.remoteIP;
console.log(
`[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(
now - record.outgoingClosedTime
)} after outgoing closed.`
);
this.cleanupConnection(record, 'parity_check');
}
// Check for stalled connections waiting for initial data
if (
!record.hasReceivedInitialData &&
now - record.incomingStartTime > this.settings.initialDataTimeout! / 2
) {
console.log(
`[${id}] Warning: Connection from ${
record.remoteIP
} has not received initial data after ${plugins.prettyMs(
now - record.incomingStartTime
)}`
);
}
// Skip inactivity check if disabled or for immortal keep-alive connections
if (
!this.settings.disableInactivityCheck &&
!(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
const inactivityTime = now - record.lastActivity;
// Special handling for TLS keep-alive connections
if (
record.hasKeepAlive &&
record.isTLS &&
inactivityTime > this.settings.inactivityTimeout! / 2
) {
// For TLS keep-alive connections that are getting stale, try to refresh before closing
if (!record.inactivityWarningIssued) {
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Attempting to preserve connection.`
);
// Set warning flag but give a much longer grace period for TLS connections
record.inactivityWarningIssued = true;
// For TLS connections, extend the last activity time considerably
// This gives browsers more time to re-establish the connection properly
record.lastActivity = now - this.settings.inactivityTimeout! / 3;
// Try to stimulate the connection with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
// For TLS connections, send a proper TLS heartbeat-like packet
// This is just a small empty buffer that won't affect the TLS session
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent TLS keep-alive probe packet`);
}
} catch (err) {
console.log(`[${id}] Error sending TLS probe packet: ${err}`);
}
}
// Don't proceed to the normal inactivity check logic
continue;
}
}
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
// For keep-alive connections, issue a warning first
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
console.log(
`[${id}] Warning: Keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Will close in 10 minutes if no activity.`
);
// Set warning flag and add grace period
record.inactivityWarningIssued = true;
record.lastActivity = now - (effectiveTimeout - 600000);
// Try to stimulate activity with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent probe packet to test keep-alive connection`);
}
} catch (err) {
console.log(`[${id}] Error sending probe packet: ${err}`);
}
}
} else {
// MODIFIED: For TLS connections, be more lenient before closing
// For TLS browser connections, we need to handle certificate context properly
if (record.isTLS && record.hasKeepAlive) {
// For very long inactivity, it's better to close the connection
// so the browser establishes a new one with a fresh certificate context
if (inactivityTime > 6 * 60 * 60 * 1000) {
// 6 hours
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Closing to ensure proper certificate handling on browser reconnect.`
);
this.cleanupConnection(record, 'tls_certificate_refresh');
} else {
// For shorter inactivity periods, add grace period
console.log(
`[${id}] TLS keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Adding extra grace period.`
);
// Give additional time for browsers to reconnect properly
record.lastActivity = now - effectiveTimeout / 2;
}
} else {
// For non-keep-alive or after warning, close the connection
console.log(
`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` +
`for ${plugins.prettyMs(inactivityTime)}.` +
(record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '')
);
this.cleanupConnection(record, 'inactivity');
}
}
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
// If activity detected after warning, clear the warning
if (this.settings.enableDetailedLogging) {
console.log(
`[${id}] Connection activity detected after inactivity warning, resetting warning`
);
}
record.inactivityWarningIssued = false;
}
}
}
// Log detailed stats periodically
console.log(
`Active connections: ${this.connectionRecords.size}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(
maxOutgoing
)}. ` +
`Termination stats: ${JSON.stringify({
IN: this.terminationStats.incoming,
OUT: this.terminationStats.outgoing,
})}`
);
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
}
/**
* Add or replace NetworkProxy instances
*/
public setNetworkProxies(networkProxies: NetworkProxy[]): void {
this.networkProxies = networkProxies;
console.log(`Updated NetworkProxy instances: ${this.networkProxies.length} proxies configured`);
}
/**
* Get a list of configured NetworkProxy instances
*/
public getNetworkProxies(): NetworkProxy[] {
return this.networkProxies;
}
/**
* Gracefully shut down the proxy
*/
public async stop() {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop the session cleanup timer
stopSessionCleanupTimer();
// 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.');
}
}