- 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
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js';
|
|
import { SecurityManager } from './classes.pp.securitymanager.js';
|
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
|
|
|
/**
|
|
* Manages connection lifecycle, tracking, and cleanup
|
|
*/
|
|
export class ConnectionManager {
|
|
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
|
private terminationStats: {
|
|
incoming: Record<string, number>;
|
|
outgoing: Record<string, number>;
|
|
} = { incoming: {}, outgoing: {} };
|
|
|
|
constructor(
|
|
private settings: IPortProxySettings,
|
|
private securityManager: SecurityManager,
|
|
private timeoutManager: TimeoutManager
|
|
) {}
|
|
|
|
/**
|
|
* Generate a unique connection ID
|
|
*/
|
|
public generateConnectionId(): string {
|
|
return Math.random().toString(36).substring(2, 15) +
|
|
Math.random().toString(36).substring(2, 15);
|
|
}
|
|
|
|
/**
|
|
* Create and track a new connection
|
|
*/
|
|
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
|
|
const connectionId = this.generateConnectionId();
|
|
const remoteIP = socket.remoteAddress || '';
|
|
const localPort = socket.localPort || 0;
|
|
|
|
const record: IConnectionRecord = {
|
|
id: connectionId,
|
|
incoming: socket,
|
|
outgoing: null,
|
|
incomingStartTime: Date.now(),
|
|
lastActivity: Date.now(),
|
|
connectionClosed: false,
|
|
pendingData: [],
|
|
pendingDataSize: 0,
|
|
bytesReceived: 0,
|
|
bytesSent: 0,
|
|
remoteIP,
|
|
localPort,
|
|
isTLS: false,
|
|
tlsHandshakeComplete: false,
|
|
hasReceivedInitialData: false,
|
|
hasKeepAlive: false,
|
|
incomingTerminationReason: null,
|
|
outgoingTerminationReason: null,
|
|
usingNetworkProxy: false,
|
|
isBrowserConnection: false,
|
|
domainSwitches: 0
|
|
};
|
|
|
|
this.trackConnection(connectionId, record);
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* Track an existing connection
|
|
*/
|
|
public trackConnection(connectionId: string, record: IConnectionRecord): void {
|
|
this.connectionRecords.set(connectionId, record);
|
|
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
|
|
}
|
|
|
|
/**
|
|
* Get a connection by ID
|
|
*/
|
|
public getConnection(connectionId: string): IConnectionRecord | undefined {
|
|
return this.connectionRecords.get(connectionId);
|
|
}
|
|
|
|
/**
|
|
* Get all active connections
|
|
*/
|
|
public getConnections(): Map<string, IConnectionRecord> {
|
|
return this.connectionRecords;
|
|
}
|
|
|
|
/**
|
|
* Get count of active connections
|
|
*/
|
|
public getConnectionCount(): number {
|
|
return this.connectionRecords.size;
|
|
}
|
|
|
|
/**
|
|
* Initiates cleanup once for a connection
|
|
*/
|
|
public 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);
|
|
}
|
|
|
|
/**
|
|
* Clean up a connection record
|
|
*/
|
|
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
|
if (!record.connectionClosed) {
|
|
record.connectionClosed = true;
|
|
|
|
// Track connection termination
|
|
this.securityManager.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 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}`);
|
|
}
|
|
}
|
|
|
|
// Handle incoming socket
|
|
this.cleanupSocket(record, 'incoming', record.incoming);
|
|
|
|
// Handle outgoing socket
|
|
if (record.outgoing) {
|
|
this.cleanupSocket(record, 'outgoing', record.outgoing);
|
|
}
|
|
|
|
// 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}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to clean up a socket
|
|
*/
|
|
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
|
|
try {
|
|
if (!socket.destroyed) {
|
|
// Try graceful shutdown first, then force destroy after a short timeout
|
|
socket.end();
|
|
const socketTimeout = setTimeout(() => {
|
|
try {
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
} catch (err) {
|
|
console.log(`[${record.id}] Error destroying ${side} socket: ${err}`);
|
|
}
|
|
}, 1000);
|
|
|
|
// Ensure the timeout doesn't block Node from exiting
|
|
if (socketTimeout.unref) {
|
|
socketTimeout.unref();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(`[${record.id}] Error closing ${side} socket: ${err}`);
|
|
try {
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
} catch (destroyErr) {
|
|
console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a generic error handler for incoming or outgoing sockets
|
|
*/
|
|
public 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
|
|
*/
|
|
public 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);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Increment termination statistics
|
|
*/
|
|
public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
|
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
|
}
|
|
|
|
/**
|
|
* Get termination statistics
|
|
*/
|
|
public getTerminationStats(): { incoming: Record<string, number>; outgoing: Record<string, number> } {
|
|
return this.terminationStats;
|
|
}
|
|
|
|
/**
|
|
* Check for stalled/inactive connections
|
|
*/
|
|
public performInactivityCheck(): void {
|
|
const now = Date.now();
|
|
const connectionIds = [...this.connectionRecords.keys()];
|
|
|
|
for (const id of connectionIds) {
|
|
const record = this.connectionRecords.get(id);
|
|
if (!record) continue;
|
|
|
|
// Skip inactivity check if disabled or for immortal keep-alive connections
|
|
if (
|
|
this.settings.disableInactivityCheck ||
|
|
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const inactivityTime = now - record.lastActivity;
|
|
|
|
// 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 {
|
|
// 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;
|
|
}
|
|
|
|
// Parity check: if outgoing socket closed and incoming remains active
|
|
if (
|
|
record.outgoingClosedTime &&
|
|
!record.incoming.destroyed &&
|
|
!record.connectionClosed &&
|
|
now - record.outgoingClosedTime > 120000
|
|
) {
|
|
console.log(
|
|
`[${id}] Parity check: Incoming socket for ${record.remoteIP} still active ${
|
|
plugins.prettyMs(now - record.outgoingClosedTime)
|
|
} after outgoing closed.`
|
|
);
|
|
this.cleanupConnection(record, 'parity_check');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all connections (for shutdown)
|
|
*/
|
|
public clearConnections(): void {
|
|
// Create a copy of the keys to avoid modification during iteration
|
|
const connectionIds = [...this.connectionRecords.keys()];
|
|
|
|
// 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
|
|
setTimeout(() => {
|
|
// 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 maps
|
|
this.connectionRecords.clear();
|
|
this.terminationStats = { incoming: {}, outgoing: {} };
|
|
}, 100);
|
|
}
|
|
} |