|
|
|
@ -23,6 +23,7 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
|
|
|
|
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
|
|
|
|
|
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
|
|
|
|
initialDataTimeout?: number; // (ms) timeout for receiving initial data, useful for chained proxies
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@ -97,7 +98,6 @@ interface IConnectionRecord {
|
|
|
|
|
lockedDomain?: string; // Field to lock this connection to the initial SNI
|
|
|
|
|
connectionClosed: boolean;
|
|
|
|
|
cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
|
|
|
|
|
cleanupInitiated: boolean; // Flag to track if cleanup has been initiated but not completed
|
|
|
|
|
id: string; // Unique identifier for the connection
|
|
|
|
|
lastActivity: number; // Timestamp of last activity on either socket
|
|
|
|
|
}
|
|
|
|
@ -163,6 +163,9 @@ export class PortProxy {
|
|
|
|
|
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
|
|
|
|
|
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Debug logging for constructor settings
|
|
|
|
|
console.log(`PortProxy initialized with targetIP: ${this.settings.targetIP}, toPort: ${this.settings.toPort}, fromPort: ${this.settings.fromPort}, sniEnabled: ${this.settings.sniEnabled}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
|
|
|
@ -170,77 +173,26 @@ export class PortProxy {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initiates the cleanup process for a connection.
|
|
|
|
|
* Sets the flag to prevent duplicate cleanup attempts and schedules actual cleanup.
|
|
|
|
|
* Cleans up a connection record if not already cleaned up.
|
|
|
|
|
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
|
|
|
|
|
* Logs the cleanup event.
|
|
|
|
|
*/
|
|
|
|
|
private initiateCleanup(record: IConnectionRecord, reason: string = 'normal'): void {
|
|
|
|
|
if (record.cleanupInitiated) return;
|
|
|
|
|
|
|
|
|
|
record.cleanupInitiated = true;
|
|
|
|
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
|
|
|
|
console.log(`Initiating cleanup for connection ${record.id} from ${remoteIP} (reason: ${reason})`);
|
|
|
|
|
|
|
|
|
|
// Execute cleanup immediately to prevent lingering connections
|
|
|
|
|
this.executeCleanup(record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Executes the actual cleanup of a connection.
|
|
|
|
|
* Destroys sockets, clears timers, and removes the record.
|
|
|
|
|
*/
|
|
|
|
|
private executeCleanup(record: IConnectionRecord): void {
|
|
|
|
|
if (record.connectionClosed) return;
|
|
|
|
|
|
|
|
|
|
record.connectionClosed = true;
|
|
|
|
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
|
|
|
|
|
|
|
|
|
if (record.cleanupTimer) {
|
|
|
|
|
clearTimeout(record.cleanupTimer);
|
|
|
|
|
record.cleanupTimer = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// End the sockets first to allow for graceful closure
|
|
|
|
|
try {
|
|
|
|
|
if (!record.incoming.destroyed) {
|
|
|
|
|
record.incoming.end();
|
|
|
|
|
// Set a safety timeout to force destroy if end doesn't complete
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!record.incoming.destroyed) {
|
|
|
|
|
console.log(`Forcing destruction of incoming socket for ${remoteIP}`);
|
|
|
|
|
record.incoming.destroy();
|
|
|
|
|
}
|
|
|
|
|
}, 1000);
|
|
|
|
|
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
|
|
|
|
if (!record.connectionClosed) {
|
|
|
|
|
record.connectionClosed = true;
|
|
|
|
|
if (record.cleanupTimer) {
|
|
|
|
|
clearTimeout(record.cleanupTimer);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`Error ending incoming socket for ${remoteIP}:`, err);
|
|
|
|
|
if (!record.incoming.destroyed) {
|
|
|
|
|
record.incoming.destroy();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (record.outgoing && !record.outgoing.destroyed) {
|
|
|
|
|
record.outgoing.end();
|
|
|
|
|
// Set a safety timeout to force destroy if end doesn't complete
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (record.outgoing && !record.outgoing.destroyed) {
|
|
|
|
|
console.log(`Forcing destruction of outgoing socket for ${remoteIP}`);
|
|
|
|
|
record.outgoing.destroy();
|
|
|
|
|
}
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`Error ending outgoing socket for ${remoteIP}:`, err);
|
|
|
|
|
if (record.outgoing && !record.outgoing.destroyed) {
|
|
|
|
|
record.outgoing.destroy();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove the record after a delay to ensure all events have propagated
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.connectionRecords.delete(record.id);
|
|
|
|
|
console.log(`Connection ${record.id} from ${remoteIP} fully cleaned up. Active connections: ${this.connectionRecords.size}`);
|
|
|
|
|
}, 2000);
|
|
|
|
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
|
|
|
|
console.log(`Connection from ${remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getTargetIP(domainConfig: IDomainConfig): string {
|
|
|
|
@ -253,25 +205,8 @@ export class PortProxy {
|
|
|
|
|
return this.settings.targetIP!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Updates the last activity timestamp for a connection record
|
|
|
|
|
*/
|
|
|
|
|
private updateActivity(record: IConnectionRecord): void {
|
|
|
|
|
record.lastActivity = Date.now();
|
|
|
|
|
|
|
|
|
|
// Reset the inactivity timer if one is set
|
|
|
|
|
if (this.settings.maxConnectionLifetime && record.cleanupTimer) {
|
|
|
|
|
clearTimeout(record.cleanupTimer);
|
|
|
|
|
|
|
|
|
|
// Set a new cleanup timer
|
|
|
|
|
record.cleanupTimer = setTimeout(() => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const inactivityTime = now - record.lastActivity;
|
|
|
|
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
|
|
|
|
console.log(`Connection ${record.id} from ${remoteIP} exceeded max lifetime or inactivity period (${inactivityTime}ms), forcing cleanup.`);
|
|
|
|
|
this.initiateCleanup(record, 'timeout');
|
|
|
|
|
}, this.settings.maxConnectionLifetime);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async start() {
|
|
|
|
@ -309,7 +244,6 @@ export class PortProxy {
|
|
|
|
|
this.initiateCleanup(connectionRecord, reason);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Helper to reject an incoming connection.
|
|
|
|
|
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
|
|
|
|
console.log(logMessage);
|
|
|
|
|
socket.end();
|
|
|
|
@ -317,31 +251,54 @@ export class PortProxy {
|
|
|
|
|
incomingTerminationReason = reason;
|
|
|
|
|
this.incrementTerminationStat('incoming', reason);
|
|
|
|
|
}
|
|
|
|
|
initiateCleanupOnce(reason);
|
|
|
|
|
cleanupOnce();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Set an initial timeout immediately
|
|
|
|
|
const initialTimeout = setTimeout(() => {
|
|
|
|
|
if (!initialDataReceived) {
|
|
|
|
|
console.log(`Initial connection timeout for ${remoteIP} (no data received)`);
|
|
|
|
|
if (incomingTerminationReason === null) {
|
|
|
|
|
incomingTerminationReason = 'initial_timeout';
|
|
|
|
|
this.incrementTerminationStat('incoming', 'initial_timeout');
|
|
|
|
|
// IMPORTANT: We won't set any initial timeout for a chained proxy scenario
|
|
|
|
|
// The code below is commented out to restore original behavior
|
|
|
|
|
/*
|
|
|
|
|
let initialTimeout: NodeJS.Timeout | null = null;
|
|
|
|
|
const initialTimeoutMs = this.settings.initialDataTimeout ||
|
|
|
|
|
(this.settings.sniEnabled ? 15000 : 0);
|
|
|
|
|
|
|
|
|
|
if (initialTimeoutMs > 0) {
|
|
|
|
|
console.log(`Setting initial data timeout of ${initialTimeoutMs}ms for connection from ${remoteIP}`);
|
|
|
|
|
initialTimeout = setTimeout(() => {
|
|
|
|
|
if (!initialDataReceived) {
|
|
|
|
|
console.log(`Initial connection timeout for ${remoteIP} (no data received after ${initialTimeoutMs}ms)`);
|
|
|
|
|
if (incomingTerminationReason === null) {
|
|
|
|
|
incomingTerminationReason = 'initial_timeout';
|
|
|
|
|
this.incrementTerminationStat('incoming', 'initial_timeout');
|
|
|
|
|
}
|
|
|
|
|
initiateCleanupOnce('initial_timeout');
|
|
|
|
|
}
|
|
|
|
|
initiateCleanupOnce('initial_timeout');
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
|
}, initialTimeoutMs);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`No initial timeout set for connection from ${remoteIP} (likely chained proxy)`);
|
|
|
|
|
initialDataReceived = true;
|
|
|
|
|
}
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Original behavior: only set timeout if SNI is enabled, and use a fixed 5 second timeout
|
|
|
|
|
let initialTimeout: NodeJS.Timeout | null = null;
|
|
|
|
|
if (this.settings.sniEnabled) {
|
|
|
|
|
console.log(`Setting 5 second initial timeout for SNI extraction from ${remoteIP}`);
|
|
|
|
|
initialTimeout = setTimeout(() => {
|
|
|
|
|
if (!initialDataReceived) {
|
|
|
|
|
console.log(`Initial data timeout for ${remoteIP}`);
|
|
|
|
|
socket.end();
|
|
|
|
|
initiateCleanupOnce('initial_timeout');
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
|
} else {
|
|
|
|
|
initialDataReceived = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket.on('error', (err: Error) => {
|
|
|
|
|
const errorMessage = initialDataReceived
|
|
|
|
|
? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
|
|
|
|
|
: `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
|
|
|
|
|
console.log(errorMessage);
|
|
|
|
|
|
|
|
|
|
// Clear the initial timeout if it exists
|
|
|
|
|
if (initialTimeout) {
|
|
|
|
|
clearTimeout(initialTimeout);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
|
|
|
|
@ -350,13 +307,9 @@ export class PortProxy {
|
|
|
|
|
if (code === 'ECONNRESET') {
|
|
|
|
|
reason = 'econnreset';
|
|
|
|
|
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
|
|
|
|
|
} else if (code === 'ECONNREFUSED') {
|
|
|
|
|
reason = 'econnrefused';
|
|
|
|
|
console.log(`ECONNREFUSED on ${side} side from ${remoteIP}: ${err.message}`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
|
|
|
|
incomingTerminationReason = reason;
|
|
|
|
|
this.incrementTerminationStat('incoming', reason);
|
|
|
|
@ -364,13 +317,11 @@ export class PortProxy {
|
|
|
|
|
outgoingTerminationReason = reason;
|
|
|
|
|
this.incrementTerminationStat('outgoing', reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initiateCleanupOnce(reason);
|
|
|
|
|
cleanupOnce();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
|
|
|
|
console.log(`Connection closed on ${side} side from ${remoteIP}`);
|
|
|
|
|
|
|
|
|
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
|
|
|
|
incomingTerminationReason = 'normal';
|
|
|
|
|
this.incrementTerminationStat('incoming', 'normal');
|
|
|
|
@ -379,24 +330,8 @@ export class PortProxy {
|
|
|
|
|
this.incrementTerminationStat('outgoing', 'normal');
|
|
|
|
|
// Record the time when outgoing socket closed.
|
|
|
|
|
connectionRecord.outgoingClosedTime = Date.now();
|
|
|
|
|
|
|
|
|
|
// If incoming is still active but outgoing closed, set a shorter timeout
|
|
|
|
|
if (!connectionRecord.incoming.destroyed) {
|
|
|
|
|
console.log(`Outgoing socket closed but incoming still active for ${remoteIP}. Setting cleanup timeout.`);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!connectionRecord.connectionClosed && !connectionRecord.incoming.destroyed) {
|
|
|
|
|
console.log(`Incoming socket still active ${Date.now() - connectionRecord.outgoingClosedTime!}ms after outgoing closed for ${remoteIP}. Cleaning up.`);
|
|
|
|
|
initiateCleanupOnce('outgoing_closed_timeout');
|
|
|
|
|
}
|
|
|
|
|
}, 10000); // 10 second timeout instead of waiting for the next parity check
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If both sides are closed/destroyed, clean up
|
|
|
|
|
if ((side === 'incoming' && connectionRecord.outgoing?.destroyed) ||
|
|
|
|
|
(side === 'outgoing' && connectionRecord.incoming.destroyed)) {
|
|
|
|
|
initiateCleanupOnce('both_closed');
|
|
|
|
|
}
|
|
|
|
|
cleanupOnce();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@ -420,6 +355,8 @@ export class PortProxy {
|
|
|
|
|
) : undefined);
|
|
|
|
|
|
|
|
|
|
// Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs.
|
|
|
|
|
// Use original domain configuration and IP validation logic
|
|
|
|
|
// This restores the behavior that was working before
|
|
|
|
|
if (domainConfig) {
|
|
|
|
|
const effectiveAllowedIPs: string[] = [
|
|
|
|
|
...domainConfig.allowedIPs,
|
|
|
|
@ -429,17 +366,17 @@ export class PortProxy {
|
|
|
|
|
...(domainConfig.blockedIPs || []),
|
|
|
|
|
...(this.settings.defaultBlockedIPs || [])
|
|
|
|
|
];
|
|
|
|
|
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
|
|
|
|
|
|
|
|
// Special case: if allowedIPs is empty, skip IP validation for backward compatibility
|
|
|
|
|
if (domainConfig.allowedIPs.length > 0 && !isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
|
|
|
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
} 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`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No domain config and no default allowed IPs - reject the connection
|
|
|
|
|
return rejectIncomingConnection('no_config', `Connection rejected: No matching domain configuration or default allowed IPs for ${remoteIP}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// If no IP validation rules, allow the connection (original behavior)
|
|
|
|
|
|
|
|
|
|
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
|
|
|
|
|
const connectionOptions: plugins.net.NetConnectOpts = {
|
|
|
|
@ -454,7 +391,7 @@ export class PortProxy {
|
|
|
|
|
let connectionTimeout: NodeJS.Timeout | null = null;
|
|
|
|
|
let connectionSucceeded = false;
|
|
|
|
|
|
|
|
|
|
// Set connection timeout
|
|
|
|
|
// Set connection timeout - longer for chained proxies
|
|
|
|
|
connectionTimeout = setTimeout(() => {
|
|
|
|
|
if (!connectionSucceeded) {
|
|
|
|
|
console.log(`Connection timeout connecting to ${targetHost}:${connectionOptions.port} for ${remoteIP}`);
|
|
|
|
@ -464,7 +401,7 @@ export class PortProxy {
|
|
|
|
|
}
|
|
|
|
|
initiateCleanupOnce('connection_timeout');
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
|
}, 10000); // Increased from 5s to 10s to accommodate chained proxies
|
|
|
|
|
|
|
|
|
|
console.log(`Attempting to connect to ${targetHost}:${connectionOptions.port} for client ${remoteIP}...`);
|
|
|
|
|
|
|
|
|
@ -623,6 +560,11 @@ export class PortProxy {
|
|
|
|
|
initialDataReceived = false;
|
|
|
|
|
|
|
|
|
|
socket.once('data', (chunk: Buffer) => {
|
|
|
|
|
if (initialTimeout) {
|
|
|
|
|
clearTimeout(initialTimeout);
|
|
|
|
|
initialTimeout = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initialDataReceived = true;
|
|
|
|
|
const serverName = extractSNI(chunk) || '';
|
|
|
|
|
// Lock the connection to the negotiated SNI.
|
|
|
|
@ -654,7 +596,7 @@ export class PortProxy {
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
initialDataReceived = true;
|
|
|
|
|
if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
|
|
|
|
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('');
|
|
|
|
|