@push.rocks/smartproxy
Version:
A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.
970 lines • 181 kB
JavaScript
import * as plugins from './plugins.js';
import { NetworkProxy } from './classes.networkproxy.js';
import { SniHandler } from './classes.snihandler.js';
// SNI functions are now imported from SniHandler class
// No need for wrapper functions
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port, ranges) => {
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, patterns) => {
if (!ip || !patterns || patterns.length === 0)
return false;
const normalizeIP = (ip) => {
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, allowed, blocked = []) => {
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 = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
// SNI functions are now imported from SniHandler class
// Helper: Ensure timeout values don't exceed Node.js max safe integer
const ensureSafeTimeout = (timeout) => {
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, variationPercent = 5) => {
const safeBaseTimeout = ensureSafeTimeout(baseTimeout);
const variation = safeBaseTimeout * (variationPercent / 100);
return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation);
};
export class PortProxy {
constructor(settingsArg) {
this.netServers = [];
this.connectionRecords = new Map();
this.connectionLogger = null;
this.isShuttingDown = false;
// Map to track round robin indices for each domain config
this.domainTargetIndices = new Map();
// Enhanced stats tracking
this.terminationStats = {
incoming: {},
outgoing: {},
};
// Connection tracking by IP for rate limiting
this.connectionsByIP = new Map();
this.connectionRateByIP = new Map();
// NetworkProxy instance for TLS termination
this.networkProxy = null;
// Set reasonable defaults for all settings
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
// Timeout settings with reasonable defaults
initialDataTimeout: settingsArg.initialDataTimeout || 120000, // 120 seconds for initial handshake
socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval
maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default
inactivityTimeout: ensureSafeTimeout(settingsArg.inactivityTimeout || 14400000), // 4 hours inactivity timeout
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
// Socket optimization settings
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB
// Feature flags
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
allowSessionTicket: settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
// Enhanced keep-alive settings
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days
// NetworkProxy settings
networkProxyPort: settingsArg.networkProxyPort || 8443, // Default NetworkProxy port
// ACME certificate settings with reasonable defaults
acme: settingsArg.acme || {
enabled: false,
port: 80,
contactEmail: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
},
};
// Initialize NetworkProxy if enabled
if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
this.initializeNetworkProxy();
}
}
/**
* Initialize NetworkProxy instance
*/
async initializeNetworkProxy() {
if (!this.networkProxy) {
// Configure NetworkProxy options based on PortProxy settings
const networkProxyOptions = {
port: this.settings.networkProxyPort,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info',
};
// Add ACME settings if configured
if (this.settings.acme) {
networkProxyOptions.acme = { ...this.settings.acme };
}
this.networkProxy = new NetworkProxy(networkProxyOptions);
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
// Convert and apply domain configurations to NetworkProxy
await this.syncDomainConfigsToNetworkProxy();
}
}
/**
* Updates the domain configurations for the proxy
* @param newDomainConfigs The new domain configurations
*/
async updateDomainConfigs(newDomainConfigs) {
console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`);
this.settings.domainConfigs = newDomainConfigs;
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxy) {
await this.syncDomainConfigsToNetworkProxy();
}
}
/**
* Updates the ACME certificate settings
* @param acmeSettings New ACME settings
*/
async updateAcmeSettings(acmeSettings) {
console.log('Updating ACME certificate settings');
// Update settings
this.settings.acme = {
...this.settings.acme,
...acmeSettings,
};
// If NetworkProxy is initialized, update its ACME settings
if (this.networkProxy) {
try {
// Recreate NetworkProxy with new settings if ACME enabled state has changed
if (this.settings.acme.enabled !== acmeSettings.enabled) {
console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`);
// Stop the current NetworkProxy
await this.networkProxy.stop();
this.networkProxy = null;
// Reinitialize with new settings
await this.initializeNetworkProxy();
// Use start() to make sure ACME gets initialized if newly enabled
await this.networkProxy.start();
}
else {
// Update existing NetworkProxy with new settings
// Note: Some settings may require a restart to take effect
console.log('Updating ACME settings in NetworkProxy');
// For certificate renewals, we might want to trigger checks with the new settings
if (acmeSettings.renewThresholdDays) {
console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`);
// This is implementation-dependent but gives an example
if (this.networkProxy.options.acme) {
this.networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays;
}
}
}
}
catch (err) {
console.log(`Error updating ACME settings: ${err}`);
}
}
}
/**
* Synchronizes PortProxy domain configurations to NetworkProxy
* This allows domains configured in PortProxy to be used by NetworkProxy
*/
async syncDomainConfigsToNetworkProxy() {
if (!this.networkProxy) {
console.log('Cannot sync configurations - NetworkProxy not initialized');
return;
}
try {
// Get SSL certificates from assets
// Import fs directly since it's not in plugins
const fs = await import('fs');
let certPair;
try {
certPair = {
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
};
}
catch (certError) {
console.log(`Warning: Could not read default certificates: ${certError}`);
console.log('Using empty certificate placeholders - ACME will generate proper certificates if enabled');
// Use empty placeholders - NetworkProxy will use its internal defaults
// or ACME will generate proper ones if enabled
certPair = {
key: '',
cert: '',
};
}
// Convert domain configs to NetworkProxy configs
const proxyConfigs = this.networkProxy.convertPortProxyConfigs(this.settings.domainConfigs, certPair);
// Log ACME-eligible domains if ACME is enabled
if (this.settings.acme?.enabled) {
const acmeEligibleDomains = proxyConfigs
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
.map((config) => config.hostName);
if (acmeEligibleDomains.length > 0) {
console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`);
}
else {
console.log('No domains eligible for ACME certificates found in configuration');
}
}
// Update NetworkProxy with the converted configs
this.networkProxy
.updateProxyConfigs(proxyConfigs)
.then(() => {
console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`);
})
.catch((err) => {
console.log(`Error synchronizing configurations: ${err.message}`);
});
}
catch (err) {
console.log(`Failed to sync configurations: ${err}`);
}
}
/**
* Requests a certificate for a specific domain
* @param domain The domain to request a certificate for
* @returns Promise that resolves to true if the request was successful, false otherwise
*/
async requestCertificate(domain) {
if (!this.networkProxy) {
console.log('Cannot request certificate - NetworkProxy not initialized');
return false;
}
if (!this.settings.acme?.enabled) {
console.log('Cannot request certificate - ACME is not enabled');
return false;
}
try {
const result = await this.networkProxy.requestCertificate(domain);
if (result) {
console.log(`Certificate request for ${domain} submitted successfully`);
}
else {
console.log(`Certificate request for ${domain} failed`);
}
return result;
}
catch (err) {
console.log(`Error requesting certificate: ${err}`);
return false;
}
}
/**
* 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 initialData - Initial data chunk (TLS ClientHello)
* @param customProxyPort - Optional custom port for NetworkProxy (for domain-specific settings)
*/
forwardToNetworkProxy(connectionId, socket, record, initialData, customProxyPort) {
// Ensure NetworkProxy is initialized
if (!this.networkProxy) {
console.log(`[${connectionId}] NetworkProxy not initialized. Using fallback direct connection.`);
// Fall back to direct connection
return this.setupDirectConnection(connectionId, socket, record, undefined, undefined, initialData);
}
// Use the custom port if provided, otherwise use the default NetworkProxy port
const proxyPort = customProxyPort || this.networkProxy.getListeningPort();
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// Create a connection to the NetworkProxy
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort,
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
// Set up error handlers
proxySocket.on('error', (err) => {
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
this.cleanupConnection(record, 'network_proxy_connect_error');
});
// Handle connection to NetworkProxy
proxySocket.on('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// First send the initial data that contains the TLS ClientHello
proxySocket.write(initialData);
// Now set up bidirectional piping between client and NetworkProxy
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Setup cleanup handlers
proxySocket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] NetworkProxy connection closed`);
}
this.cleanupConnection(record, 'network_proxy_closed');
});
socket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Client connection closed after forwarding to NetworkProxy`);
}
this.cleanupConnection(record, 'client_closed');
});
// Update activity on data transfer
socket.on('data', () => this.updateActivity(record));
proxySocket.on('data', () => this.updateActivity(record));
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`);
}
});
}
/**
* Sets up a direct connection to the target (original behavior)
* This is used when NetworkProxy isn't configured or as a fallback
*/
setupDirectConnection(connectionId, socket, record, domainConfig, serverName, initialChunk, overridePort) {
// Existing connection setup logic
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP;
const connectionOptions = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
// Create a safe queue for incoming data using a Buffer array
// We'll use this to ensure we don't lose data during handler transitions
const dataQueue = [];
let queueSize = 0;
let processingQueue = false;
let drainPending = false;
// Flag to track if we've switched to the final piping mechanism
// Once this is true, we no longer buffer data in dataQueue
let pipingEstablished = false;
// Pause the incoming socket to prevent buffer overflows
// This ensures we control the flow of data until piping is set up
socket.pause();
// Function to safely process the data queue without losing events
const processDataQueue = () => {
if (processingQueue || dataQueue.length === 0 || pipingEstablished)
return;
processingQueue = true;
try {
// Process all queued chunks with the current active handler
while (dataQueue.length > 0) {
const chunk = dataQueue.shift();
queueSize -= chunk.length;
// Once piping is established, we shouldn't get here,
// but just in case, pass to the outgoing socket directly
if (pipingEstablished && record.outgoing) {
record.outgoing.write(chunk);
continue;
}
// Track bytes received
record.bytesReceived += chunk.length;
// Check for TLS handshake
if (!record.isTLS && SniHandler.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
this.initiateCleanupOnce(record, 'buffer_limit_exceeded');
return;
}
// Buffer the chunk and update the size counter
record.pendingData.push(Buffer.from(chunk));
record.pendingDataSize = newSize;
this.updateActivity(record);
}
}
finally {
processingQueue = false;
// If there's a pending drain and we've processed everything,
// signal we're ready for more data if we haven't established piping yet
if (drainPending && dataQueue.length === 0 && !pipingEstablished) {
drainPending = false;
socket.resume();
}
}
};
// Unified data handler that safely queues incoming data
const safeDataHandler = (chunk) => {
// If piping is already established, just let the pipe handle it
if (pipingEstablished)
return;
// Add to our queue for orderly processing
dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe
queueSize += chunk.length;
// If queue is getting large, pause socket until we catch up
if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) {
socket.pause();
drainPending = true;
}
// Process the queue
processDataQueue();
};
// Add our safe data handler
socket.on('data', safeDataHandler);
// Add initial chunk to pending data if present
if (initialChunk) {
record.bytesReceived += initialChunk.length;
record.pendingData.push(Buffer.from(initialChunk));
record.pendingDataSize = initialChunk.length;
}
// Create the target socket but don't set up piping immediately
const targetSocket = plugins.net.connect(connectionOptions);
record.outgoing = targetSocket;
record.outgoingStartTime = Date.now();
// Apply socket optimizations
targetSocket.setNoDelay(this.settings.noDelay);
// Apply keep-alive settings to the outgoing connection as well
if (this.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
try {
if ('setKeepAliveProbes' in targetSocket) {
targetSocket.setKeepAliveProbes(10);
}
if ('setKeepAliveInterval' in targetSocket) {
targetSocket.setKeepAliveInterval(1000);
}
}
catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`);
}
}
}
}
// Setup specific error handler for connection phase
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
const code = err.code;
console.log(`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`);
// Resume the incoming socket to prevent it from hanging
socket.resume();
if (code === 'ECONNREFUSED') {
console.log(`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`);
}
else if (code === 'ETIMEDOUT') {
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`);
}
else if (code === 'ECONNRESET') {
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`);
}
else if (code === 'EHOSTUNREACH') {
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
}
// Clear any existing error handler after connection phase
targetSocket.removeAllListeners('error');
// Re-add the normal error handler for established connections
targetSocket.on('error', this.handleError('outgoing', record));
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'connection_failed';
this.incrementTerminationStat('outgoing', 'connection_failed');
}
// Clean up the connection
this.initiateCleanupOnce(record, `connection_failed_${code}`);
});
// Setup close handler
targetSocket.on('close', this.handleClose('outgoing', record));
socket.on('close', this.handleClose('incoming', record));
// Handle timeouts with keep-alive awareness
socket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(`[${connectionId}] Timeout event on incoming keep-alive connection from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`);
// Don't close the connection - just log
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(`[${connectionId}] Timeout on incoming side from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
this.initiateCleanupOnce(record, 'timeout_incoming');
});
targetSocket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(`[${connectionId}] Timeout event on outgoing keep-alive connection from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`);
// Don't close the connection - just log
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(`[${connectionId}] Timeout on outgoing side from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
}
this.initiateCleanupOnce(record, 'timeout_outgoing');
});
// Set appropriate timeouts, or disable for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
// Disable timeouts completely for immortal connections
socket.setTimeout(0);
targetSocket.setTimeout(0);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Disabled socket timeouts for immortal keep-alive connection`);
}
}
else {
// Set normal timeouts for other connections
socket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000));
targetSocket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000));
}
// Track outgoing data for bytes counting
targetSocket.on('data', (chunk) => {
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));
// Process any remaining data in the queue before switching to piping
processDataQueue();
// Set up piping immediately - don't delay this crucial step
pipingEstablished = true;
// Flush all pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`);
}
// Write pending data immediately
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.initiateCleanupOnce(record, 'write_error');
}
});
// Clear the buffer now that we've processed it
record.pendingData = [];
record.pendingDataSize = 0;
}
// Setup piping in both directions without any delays
socket.pipe(targetSocket);
targetSocket.pipe(socket);
// Resume the socket to ensure data flows - CRITICAL!
socket.resume();
// Process any data that might be queued in the interim
if (dataQueue.length > 0) {
// Write any remaining queued data directly to the target socket
for (const chunk of dataQueue) {
targetSocket.write(chunk);
}
// Clear the queue
dataQueue.length = 0;
queueSize = 0;
}
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(', ')})`
: ''}`);
}
// Add the renegotiation handler for SNI validation with strict domain enforcement
// This will be called after we've established piping
if (serverName) {
// Define a handler for checking renegotiation with improved detection
const renegotiationHandler = (renegChunk) => {
// Only process if this looks like a TLS ClientHello
if (SniHandler.isClientHello(renegChunk)) {
try {
// Extract SNI from ClientHello
// Create a connection info object for the existing connection
const connInfo = {
sourceIp: record.remoteIP,
sourcePort: record.incoming.remotePort || 0,
destIp: record.incoming.localAddress || '',
destPort: record.incoming.localPort || 0,
};
// Check for session tickets if allowSessionTicket is disabled
if (this.settings.allowSessionTicket === false) {
// Analyze for session resumption attempt (session ticket or PSK)
const resumptionInfo = SniHandler.hasSessionResumption(renegChunk, this.settings.enableTlsDebugLogging);
if (resumptionInfo.isResumption) {
// Always log resumption attempt for easier debugging
// Try to extract SNI for logging
const extractedSNI = SniHandler.extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
console.log(`[${connectionId}] Session resumption detected in renegotiation. ` +
`Has SNI: ${resumptionInfo.hasSNI ? 'Yes' : 'No'}, ` +
`SNI value: ${extractedSNI || 'None'}, ` +
`allowSessionTicket: ${this.settings.allowSessionTicket}`);
// Block if there's session resumption without SNI
if (!resumptionInfo.hasSNI) {
console.log(`[${connectionId}] Session resumption detected in renegotiation without SNI and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake.`);
this.initiateCleanupOnce(record, 'session_ticket_blocked');
return;
}
else {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Session resumption with SNI detected in renegotiation. ` +
`Allowing connection since SNI is present.`);
}
}
}
}
const newSNI = SniHandler.extractSNIWithResumptionSupport(renegChunk, connInfo, this.settings.enableTlsDebugLogging);
// Skip if no SNI was found
if (!newSNI)
return;
// Handle SNI change during renegotiation - always terminate for domain switches
if (newSNI !== record.lockedDomain) {
// Log and terminate the connection for any SNI change
console.log(`[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` +
`Terminating connection - SNI domain switching is not allowed.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
else if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`);
}
}
catch (err) {
console.log(`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`);
}
}
};
// Store the handler in the connection record so we can remove it during cleanup
record.renegotiationHandler = renegotiationHandler;
// The renegotiation handler is added when piping is established
// Making it part of setupPiping ensures proper sequencing of event handlers
socket.on('data', renegotiationHandler);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}`);
if (this.settings.allowSessionTicket === false) {
console.log(`[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.`);
}
}
}
// 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 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
*/
getConnectionCountByIP(ip) {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
*/
checkConnectionRate(ip) {
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
*/
trackConnectionByIP(ip, connectionId) {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip).add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
removeConnectionByIP(ip, connectionId) {
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
*/
incrementTerminationStat(side, reason) {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* 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)
*/
cleanupConnection(record, reason = 'normal') {
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;
// Remove all data handlers (both standard and renegotiation) to make sure we clean up properly
if (record.incoming) {
try {
// Remove our safe data handler
record.incoming.removeAllListeners('data');
// Reset the handler references
record.renegotiationHandler = undefined;
}
catch (err) {
console.log(`[${record.id}] Error removing data handlers: ${err}`);
}
}
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 ? ', Using NetworkProxy' : ''}` +
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`);
}
else {
console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
}
}
/**
* Update connection activity timestamp
*/
updateActivity(record) {
record.lastActivity = Date.now();
// Clear any inactivity warning
if (record.inactivityWarningIssued) {
record.inactivityWarningIssued = false;
}
}
/**
* Get target IP with round-robin support
*/
getTargetIP(domainConfig) {
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
*/
initiateCleanupOnce(record, reason = 'normal') {
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
*/
handleError(side, record) {
return (err) => {
const code = err.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.incrementTerm