- Renamed port proxy and SNI handler source files to classes.pp.portproxy.js and classes.pp.snihandler.js respectively - Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names - This refactor improves code organization but breaks direct imports from the old paths
982 lines
34 KiB
TypeScript
982 lines
34 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
|
|
import { ConnectionManager } from './classes.pp.connectionmanager.js';
|
|
import { SecurityManager } from './classes.pp.securitymanager.js';
|
|
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
|
|
import { TlsManager } from './classes.pp.tlsmanager.js';
|
|
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
|
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
|
|
|
/**
|
|
* Handles new connection processing and setup logic
|
|
*/
|
|
export class ConnectionHandler {
|
|
constructor(
|
|
private settings: IPortProxySettings,
|
|
private connectionManager: ConnectionManager,
|
|
private securityManager: SecurityManager,
|
|
private domainConfigManager: DomainConfigManager,
|
|
private tlsManager: TlsManager,
|
|
private networkProxyBridge: NetworkProxyBridge,
|
|
private timeoutManager: TimeoutManager,
|
|
private portRangeManager: PortRangeManager
|
|
) {}
|
|
|
|
/**
|
|
* Handle a new incoming connection
|
|
*/
|
|
public handleConnection(socket: plugins.net.Socket): void {
|
|
const remoteIP = socket.remoteAddress || '';
|
|
const localPort = socket.localPort || 0;
|
|
|
|
// Validate IP against rate limits and connection limits
|
|
const ipValidation = this.securityManager.validateIP(remoteIP);
|
|
if (!ipValidation.allowed) {
|
|
console.log(`Connection rejected from ${remoteIP}: ${ipValidation.reason}`);
|
|
socket.end();
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
// Create a new connection record
|
|
const record = this.connectionManager.createConnection(socket);
|
|
const connectionId = record.id;
|
|
|
|
// Apply socket optimizations
|
|
socket.setNoDelay(this.settings.noDelay);
|
|
|
|
// Apply keep-alive settings if enabled
|
|
if (this.settings.keepAlive) {
|
|
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
|
record.hasKeepAlive = true;
|
|
|
|
// 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);
|
|
}
|
|
if ('setKeepAliveInterval' in socket) {
|
|
(socket as any).setKeepAliveInterval(1000);
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors - these are optional enhancements
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` +
|
|
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
|
|
`Active connections: ${this.connectionManager.getConnectionCount()}`
|
|
);
|
|
} else {
|
|
console.log(
|
|
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}`
|
|
);
|
|
}
|
|
|
|
// Check if this connection should be forwarded directly to NetworkProxy
|
|
if (this.portRangeManager.shouldUseNetworkProxy(localPort)) {
|
|
this.handleNetworkProxyConnection(socket, record);
|
|
} else {
|
|
// For non-NetworkProxy ports, proceed with normal processing
|
|
this.handleStandardConnection(socket, record);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a connection that should be forwarded to NetworkProxy
|
|
*/
|
|
private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
|
const connectionId = record.id;
|
|
let initialDataReceived = false;
|
|
|
|
// Set an initial timeout for handshake data
|
|
let initialTimeout: NodeJS.Timeout | null = setTimeout(() => {
|
|
if (!initialDataReceived) {
|
|
console.log(
|
|
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
|
|
);
|
|
|
|
// Add a grace period instead of immediate termination
|
|
setTimeout(() => {
|
|
if (!initialDataReceived) {
|
|
console.log(`[${connectionId}] Final initial data timeout after grace period`);
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'initial_timeout';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'initial_timeout');
|
|
}
|
|
}, 30000); // 30 second grace period
|
|
}
|
|
}, this.settings.initialDataTimeout!);
|
|
|
|
// Make sure timeout doesn't keep the process alive
|
|
if (initialTimeout.unref) {
|
|
initialTimeout.unref();
|
|
}
|
|
|
|
// Set up error handler
|
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
|
|
|
// First data handler to capture initial TLS handshake for NetworkProxy
|
|
socket.once('data', (chunk: Buffer) => {
|
|
// Clear the initial timeout since we've received data
|
|
if (initialTimeout) {
|
|
clearTimeout(initialTimeout);
|
|
initialTimeout = null;
|
|
}
|
|
|
|
initialDataReceived = true;
|
|
record.hasReceivedInitialData = true;
|
|
|
|
// Block non-TLS connections on port 443
|
|
const localPort = record.localPort;
|
|
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
|
console.log(
|
|
`[${connectionId}] Non-TLS connection detected on port 443. ` +
|
|
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
|
);
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'non_tls_blocked';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
|
|
return;
|
|
}
|
|
|
|
// Check if this looks like a TLS handshake
|
|
if (this.tlsManager.isTlsHandshake(chunk)) {
|
|
record.isTLS = true;
|
|
|
|
// Check session tickets if they're disabled
|
|
if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) {
|
|
// Create connection info for SNI extraction
|
|
const connInfo = {
|
|
sourceIp: record.remoteIP,
|
|
sourcePort: socket.remotePort || 0,
|
|
destIp: socket.localAddress || '',
|
|
destPort: socket.localPort || 0,
|
|
};
|
|
|
|
// Extract SNI for domain-specific NetworkProxy handling
|
|
const serverName = this.tlsManager.extractSNI(chunk, connInfo);
|
|
|
|
if (serverName) {
|
|
// If we got an SNI, check for domain-specific NetworkProxy settings
|
|
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
|
|
|
|
// Save domain config and SNI in connection record
|
|
record.domainConfig = domainConfig;
|
|
record.lockedDomain = serverName;
|
|
|
|
// Use domain-specific NetworkProxy port if configured
|
|
if (domainConfig && this.domainConfigManager.shouldUseNetworkProxy(domainConfig)) {
|
|
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Using domain-specific NetworkProxy for ${serverName} on port ${networkProxyPort}`
|
|
);
|
|
}
|
|
|
|
// Forward to NetworkProxy with domain-specific port
|
|
this.networkProxyBridge.forwardToNetworkProxy(
|
|
connectionId,
|
|
socket,
|
|
record,
|
|
chunk,
|
|
networkProxyPort,
|
|
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward directly to NetworkProxy without domain-specific settings
|
|
this.networkProxyBridge.forwardToNetworkProxy(
|
|
connectionId,
|
|
socket,
|
|
record,
|
|
chunk,
|
|
undefined,
|
|
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
} else {
|
|
// If not TLS, use normal direct connection
|
|
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`);
|
|
this.setupDirectConnection(
|
|
socket,
|
|
record,
|
|
undefined,
|
|
undefined,
|
|
chunk
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a standard (non-NetworkProxy) connection
|
|
*/
|
|
private handleStandardConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
|
const connectionId = record.id;
|
|
const localPort = record.localPort;
|
|
|
|
// Define helpers for rejecting connections
|
|
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
|
console.log(`[${connectionId}] ${logMessage}`);
|
|
socket.end();
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = reason;
|
|
this.connectionManager.incrementTerminationStat('incoming', reason);
|
|
}
|
|
this.connectionManager.cleanupConnection(record, reason);
|
|
};
|
|
|
|
let initialDataReceived = false;
|
|
|
|
// 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 warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
|
|
);
|
|
|
|
// Add a grace period instead of immediate termination
|
|
setTimeout(() => {
|
|
if (!initialDataReceived) {
|
|
console.log(`[${connectionId}] Final initial data timeout after grace period`);
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'initial_timeout';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'initial_timeout');
|
|
}
|
|
}, 30000); // 30 second grace period
|
|
}
|
|
}, this.settings.initialDataTimeout!);
|
|
|
|
// Make sure timeout doesn't keep the process alive
|
|
if (initialTimeout.unref) {
|
|
initialTimeout.unref();
|
|
}
|
|
} else {
|
|
initialDataReceived = true;
|
|
record.hasReceivedInitialData = true;
|
|
}
|
|
|
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
|
|
|
// Track data for bytes counting
|
|
socket.on('data', (chunk: Buffer) => {
|
|
record.bytesReceived += chunk.length;
|
|
this.timeoutManager.updateActivity(record);
|
|
|
|
// Check for TLS handshake if this is the first chunk
|
|
if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) {
|
|
record.isTLS = true;
|
|
|
|
if (this.settings.enableTlsDebugLogging) {
|
|
console.log(
|
|
`[${connectionId}] TLS handshake detected from ${record.remoteIP}, ${chunk.length} bytes`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Sets up the connection to the target host.
|
|
*/
|
|
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;
|
|
record.hasReceivedInitialData = true;
|
|
|
|
// Check if this looks like a TLS handshake
|
|
if (initialChunk && this.tlsManager.isTlsHandshake(initialChunk)) {
|
|
record.isTLS = true;
|
|
|
|
if (this.settings.enableTlsDebugLogging) {
|
|
console.log(
|
|
`[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes`
|
|
);
|
|
}
|
|
}
|
|
|
|
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
|
const domainConfig = forcedDomain
|
|
? forcedDomain
|
|
: serverName
|
|
? this.domainConfigManager.findDomainConfig(serverName)
|
|
: undefined;
|
|
|
|
// Save domain config in connection record
|
|
record.domainConfig = domainConfig;
|
|
|
|
// Check if this domain should use NetworkProxy (domain-specific setting)
|
|
if (domainConfig &&
|
|
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
|
|
this.networkProxyBridge.getNetworkProxy()) {
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`
|
|
);
|
|
}
|
|
|
|
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
|
|
|
|
if (initialChunk && record.isTLS) {
|
|
// For TLS connections with initial chunk, forward to NetworkProxy
|
|
this.networkProxyBridge.forwardToNetworkProxy(
|
|
connectionId,
|
|
socket,
|
|
record,
|
|
initialChunk,
|
|
networkProxyPort,
|
|
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
return; // Skip normal connection setup
|
|
}
|
|
}
|
|
|
|
// IP validation
|
|
if (domainConfig) {
|
|
const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig);
|
|
|
|
// Skip IP validation if allowedIPs is empty
|
|
if (
|
|
domainConfig.allowedIPs.length > 0 &&
|
|
!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)
|
|
) {
|
|
return rejectIncomingConnection(
|
|
'rejected',
|
|
`Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join(
|
|
', '
|
|
)}`
|
|
);
|
|
}
|
|
} else if (
|
|
this.settings.defaultAllowedIPs &&
|
|
this.settings.defaultAllowedIPs.length > 0
|
|
) {
|
|
if (
|
|
!this.securityManager.isIPAuthorized(
|
|
record.remoteIP,
|
|
this.settings.defaultAllowedIPs,
|
|
this.settings.defaultBlockedIPs || []
|
|
)
|
|
) {
|
|
return rejectIncomingConnection(
|
|
'rejected',
|
|
`Connection rejected: IP ${record.remoteIP} not allowed by default allowed list`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Save the initial SNI
|
|
if (serverName) {
|
|
record.lockedDomain = serverName;
|
|
}
|
|
|
|
// Set up the direct connection
|
|
this.setupDirectConnection(
|
|
socket,
|
|
record,
|
|
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.portRangeManager.isPortInGlobalRanges(localPort)) {
|
|
if (this.portRangeManager.shouldUseGlobalForwarding(localPort)) {
|
|
if (
|
|
this.settings.defaultAllowedIPs &&
|
|
this.settings.defaultAllowedIPs.length > 0 &&
|
|
!this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs)
|
|
) {
|
|
console.log(
|
|
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP ${record.remoteIP} not allowed in global default allowed list.`
|
|
);
|
|
socket.end();
|
|
return;
|
|
}
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Port-based connection from ${record.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.domainConfigManager.findDomainConfigForPort(localPort);
|
|
|
|
if (forcedDomain) {
|
|
const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain);
|
|
|
|
if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) {
|
|
console.log(
|
|
`[${connectionId}] Connection from ${record.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 ${record.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) => {
|
|
// Clear timeout immediately
|
|
if (initialTimeout) {
|
|
clearTimeout(initialTimeout);
|
|
initialTimeout = null;
|
|
}
|
|
|
|
initialDataReceived = true;
|
|
|
|
// Block non-TLS connections on port 443
|
|
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
|
console.log(
|
|
`[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` +
|
|
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
|
);
|
|
if (record.incomingTerminationReason === null) {
|
|
record.incomingTerminationReason = 'non_tls_blocked';
|
|
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
|
|
}
|
|
socket.end();
|
|
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
|
|
return;
|
|
}
|
|
|
|
// Try to extract SNI
|
|
let serverName = '';
|
|
|
|
if (this.tlsManager.isTlsHandshake(chunk)) {
|
|
record.isTLS = true;
|
|
|
|
if (this.settings.enableTlsDebugLogging) {
|
|
console.log(
|
|
`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`
|
|
);
|
|
}
|
|
|
|
// Create connection info object for SNI extraction
|
|
const connInfo = {
|
|
sourceIp: record.remoteIP,
|
|
sourcePort: socket.remotePort || 0,
|
|
destIp: socket.localAddress || '',
|
|
destPort: socket.localPort || 0,
|
|
};
|
|
|
|
// Extract SNI
|
|
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
|
|
}
|
|
|
|
// Lock the connection to the negotiated SNI.
|
|
record.lockedDomain = serverName;
|
|
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Received connection from ${record.remoteIP} with SNI: ${
|
|
serverName || '(empty)'
|
|
}`
|
|
);
|
|
}
|
|
|
|
setupConnection(serverName, chunk);
|
|
});
|
|
} else {
|
|
initialDataReceived = true;
|
|
record.hasReceivedInitialData = true;
|
|
|
|
if (
|
|
this.settings.defaultAllowedIPs &&
|
|
this.settings.defaultAllowedIPs.length > 0 &&
|
|
!this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs)
|
|
) {
|
|
return rejectIncomingConnection(
|
|
'rejected',
|
|
`Connection rejected: IP ${record.remoteIP} not allowed for non-SNI connection`
|
|
);
|
|
}
|
|
|
|
setupConnection('');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up a direct connection to the target
|
|
*/
|
|
private setupDirectConnection(
|
|
socket: plugins.net.Socket,
|
|
record: IConnectionRecord,
|
|
domainConfig?: IDomainConfig,
|
|
serverName?: string,
|
|
initialChunk?: Buffer,
|
|
overridePort?: number
|
|
): void {
|
|
const connectionId = record.id;
|
|
|
|
// Determine target host
|
|
const targetHost = domainConfig
|
|
? this.domainConfigManager.getTargetIP(domainConfig)
|
|
: this.settings.targetIP!;
|
|
|
|
// Determine target port
|
|
const targetPort = overridePort !== undefined
|
|
? overridePort
|
|
: this.settings.toPort;
|
|
|
|
// Setup connection options
|
|
const connectionOptions: plugins.net.NetConnectOpts = {
|
|
host: targetHost,
|
|
port: targetPort,
|
|
};
|
|
|
|
// Preserve source IP if configured
|
|
if (this.settings.preserveSourceIP) {
|
|
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
|
|
}
|
|
|
|
// Create a safe queue for incoming data
|
|
const dataQueue: Buffer[] = [];
|
|
let queueSize = 0;
|
|
let processingQueue = false;
|
|
let drainPending = false;
|
|
let pipingEstablished = false;
|
|
|
|
// Pause the incoming socket to prevent buffer overflows
|
|
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 && this.tlsManager.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.connectionManager.initiateCleanupOnce(record, 'buffer_limit_exceeded');
|
|
return;
|
|
}
|
|
|
|
// Buffer the chunk and update the size counter
|
|
record.pendingData.push(Buffer.from(chunk));
|
|
record.pendingDataSize = newSize;
|
|
this.timeoutManager.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: Buffer) => {
|
|
// 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 as any).setKeepAliveProbes(10);
|
|
}
|
|
if ('setKeepAliveInterval' in targetSocket) {
|
|
(targetSocket as any).setKeepAliveInterval(1000);
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors - these are optional enhancements
|
|
if (this.settings.enableDetailedLogging) {
|
|
console.log(
|
|
`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup specific error handler for connection phase
|
|
targetSocket.once('error', (err) => {
|
|
// This handler runs only once during the initial connection phase
|
|
const code = (err as any).code;
|
|
console.log(
|
|
`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`
|
|
);
|
|
|
|
// Resume the incoming socket to prevent it from hanging
|
|
socket.resume();
|
|
|
|
if (code === 'ECONNREFUSED') {
|
|
console.log(
|
|
`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`
|
|
);
|
|
} else if (code === 'ETIMEDOUT') {
|
|
console.log(
|
|
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`
|
|
);
|
|
} else if (code === 'ECONNRESET') {
|
|
console.log(
|
|
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`
|
|
);
|
|
} else if (code === 'EHOSTUNREACH') {
|
|
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
|
|
}
|
|
|
|
// Clear any existing error handler after connection phase
|
|
targetSocket.removeAllListeners('error');
|
|
|
|
// Re-add the normal error handler for established connections
|
|
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
|
|
|
if (record.outgoingTerminationReason === null) {
|
|
record.outgoingTerminationReason = 'connection_failed';
|
|
this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed');
|
|
}
|
|
|
|
// Clean up the connection
|
|
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`);
|
|
});
|
|
|
|
// Setup close handler
|
|
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
|
|
socket.on('close', this.connectionManager.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.`
|
|
);
|
|
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.connectionManager.incrementTerminationStat('incoming', 'timeout');
|
|
}
|
|
this.connectionManager.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.`
|
|
);
|
|
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.connectionManager.incrementTerminationStat('outgoing', 'timeout');
|
|
}
|
|
this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing');
|
|
});
|
|
|
|
// Apply socket timeouts
|
|
this.timeoutManager.applySocketTimeouts(record);
|
|
|
|
// Track outgoing data for bytes counting
|
|
targetSocket.on('data', (chunk: Buffer) => {
|
|
record.bytesSent += chunk.length;
|
|
this.timeoutManager.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.connectionManager.handleError('outgoing', record));
|
|
|
|
// Process any remaining data in the queue before switching to piping
|
|
processDataQueue();
|
|
|
|
// Set up piping immediately
|
|
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.connectionManager.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
|
|
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
|
|
if (serverName) {
|
|
// Create 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,
|
|
};
|
|
|
|
// Create a renegotiation handler function
|
|
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
|
connectionId,
|
|
serverName,
|
|
connInfo,
|
|
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
);
|
|
|
|
// Store the handler in the connection record so we can remove it during cleanup
|
|
record.renegotiationHandler = renegotiationHandler;
|
|
|
|
// Add the handler to the socket
|
|
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
|
|
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(
|
|
record,
|
|
(record, reason) => {
|
|
console.log(
|
|
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
|
|
);
|
|
this.connectionManager.initiateCleanupOnce(record, reason);
|
|
}
|
|
);
|
|
|
|
// 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}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} |