Compare commits

...

4 Commits

4 changed files with 71 additions and 190 deletions

View File

@ -1,5 +1,18 @@
# Changelog # Changelog
## 2025-03-03 - 3.22.2 - fix(portproxy)
Refactored connection cleanup logic in PortProxy
- Simplified the connection cleanup logic by removing redundant methods.
- Consolidated the cleanup initiation and execution into a single cleanup method.
- Improved error handling by ensuring connections are closed appropriately.
## 2025-03-03 - 3.22.1 - fix(PortProxy)
Fix connection timeout and IP validation handling for PortProxy
- Adjusted initial data timeout setting for SNI-enabled connections in PortProxy.
- Restored IP validation logic to original behavior, ensuring compatibility with domain configurations.
## 2025-03-03 - 3.22.0 - feat(classes.portproxy) ## 2025-03-03 - 3.22.0 - feat(classes.portproxy)
Enhanced PortProxy to support initial data timeout and improved IP handling Enhanced PortProxy to support initial data timeout and improved IP handling

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "3.22.0", "version": "3.22.2",
"private": false, "private": false,
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.", "description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '3.22.0', version: '3.22.2',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
} }

View File

@ -98,7 +98,6 @@ interface IConnectionRecord {
lockedDomain?: string; // Field to lock this connection to the initial SNI lockedDomain?: string; // Field to lock this connection to the initial SNI
connectionClosed: boolean; connectionClosed: boolean;
cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity 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 id: string; // Unique identifier for the connection
lastActivity: number; // Timestamp of last activity on either socket lastActivity: number; // Timestamp of last activity on either socket
} }
@ -174,77 +173,26 @@ export class PortProxy {
} }
/** /**
* Initiates the cleanup process for a connection. * Cleans up a connection record if not already cleaned up.
* Sets the flag to prevent duplicate cleanup attempts and schedules actual cleanup. * Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* Logs the cleanup event.
*/ */
private initiateCleanup(record: IConnectionRecord, reason: string = 'normal'): void { private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (record.cleanupInitiated) return; if (!record.connectionClosed) {
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; record.connectionClosed = true;
const remoteIP = record.incoming.remoteAddress || 'unknown';
if (record.cleanupTimer) { if (record.cleanupTimer) {
clearTimeout(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);
}
} catch (err) {
console.error(`Error ending incoming socket for ${remoteIP}:`, err);
if (!record.incoming.destroyed) { if (!record.incoming.destroyed) {
record.incoming.destroy(); 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) { if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy(); record.outgoing.destroy();
} }
}
// Remove the record after a delay to ensure all events have propagated
setTimeout(() => {
this.connectionRecords.delete(record.id); this.connectionRecords.delete(record.id);
console.log(`Connection ${record.id} from ${remoteIP} fully cleaned up. Active connections: ${this.connectionRecords.size}`); const remoteIP = record.incoming.remoteAddress || 'unknown';
}, 2000); console.log(`Connection from ${remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
} }
private getTargetIP(domainConfig: IDomainConfig): string { private getTargetIP(domainConfig: IDomainConfig): string {
@ -257,25 +205,8 @@ export class PortProxy {
return this.settings.targetIP!; return this.settings.targetIP!;
} }
/**
* Updates the last activity timestamp for a connection record
*/
private updateActivity(record: IConnectionRecord): void { private updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now(); 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() { public async start() {
@ -313,7 +244,6 @@ export class PortProxy {
this.initiateCleanup(connectionRecord, reason); this.initiateCleanup(connectionRecord, reason);
}; };
// Helper to reject an incoming connection.
const rejectIncomingConnection = (reason: string, logMessage: string) => { const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(logMessage); console.log(logMessage);
socket.end(); socket.end();
@ -321,15 +251,15 @@ export class PortProxy {
incomingTerminationReason = reason; incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason); this.incrementTerminationStat('incoming', reason);
} }
initiateCleanupOnce(reason); cleanupOnce();
}; };
// Set an initial timeout only if SNI is enabled or this is not a chained proxy // IMPORTANT: We won't set any initial timeout for a chained proxy scenario
// For chained proxies, we need to allow more time for data to flow through // The code below is commented out to restore original behavior
const initialTimeoutMs = this.settings.initialDataTimeout || /*
(this.settings.sniEnabled ? 15000 : 0); // Increased timeout for SNI, disabled for non-SNI by default
let initialTimeout: NodeJS.Timeout | null = null; let initialTimeout: NodeJS.Timeout | null = null;
const initialTimeoutMs = this.settings.initialDataTimeout ||
(this.settings.sniEnabled ? 15000 : 0);
if (initialTimeoutMs > 0) { if (initialTimeoutMs > 0) {
console.log(`Setting initial data timeout of ${initialTimeoutMs}ms for connection from ${remoteIP}`); console.log(`Setting initial data timeout of ${initialTimeoutMs}ms for connection from ${remoteIP}`);
@ -345,7 +275,22 @@ export class PortProxy {
}, initialTimeoutMs); }, initialTimeoutMs);
} else { } else {
console.log(`No initial timeout set for connection from ${remoteIP} (likely chained proxy)`); console.log(`No initial timeout set for connection from ${remoteIP} (likely chained proxy)`);
// Mark as received immediately if we're not waiting for data 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; initialDataReceived = true;
} }
@ -354,22 +299,6 @@ export class PortProxy {
? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}` ? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
: `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`; : `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
console.log(errorMessage); console.log(errorMessage);
// Clear the initial timeout if it exists
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
// For premature errors, we need to handle them explicitly
// since the standard error handlers might not be set up yet
if (!initialDataReceived) {
if (incomingTerminationReason === null) {
incomingTerminationReason = 'premature_error';
this.incrementTerminationStat('incoming', 'premature_error');
}
initiateCleanupOnce('premature_error');
}
}); });
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => { const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
@ -378,13 +307,9 @@ export class PortProxy {
if (code === 'ECONNRESET') { if (code === 'ECONNRESET') {
reason = 'econnreset'; reason = 'econnreset';
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`); 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 { } else {
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`); console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
} }
if (side === 'incoming' && incomingTerminationReason === null) { if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = reason; incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason); this.incrementTerminationStat('incoming', reason);
@ -392,13 +317,11 @@ export class PortProxy {
outgoingTerminationReason = reason; outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason); this.incrementTerminationStat('outgoing', reason);
} }
cleanupOnce();
initiateCleanupOnce(reason);
}; };
const handleClose = (side: 'incoming' | 'outgoing') => () => { const handleClose = (side: 'incoming' | 'outgoing') => () => {
console.log(`Connection closed on ${side} side from ${remoteIP}`); console.log(`Connection closed on ${side} side from ${remoteIP}`);
if (side === 'incoming' && incomingTerminationReason === null) { if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = 'normal'; incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal'); this.incrementTerminationStat('incoming', 'normal');
@ -407,24 +330,8 @@ export class PortProxy {
this.incrementTerminationStat('outgoing', 'normal'); this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed. // Record the time when outgoing socket closed.
connectionRecord.outgoingClosedTime = Date.now(); 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();
}; };
/** /**
@ -448,11 +355,9 @@ export class PortProxy {
) : undefined); ) : undefined);
// Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs. // Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs.
// In a chained proxy, relax IP validation unless explicitly configured // Use original domain configuration and IP validation logic
// If this is the first proxy in the chain, normal validation applies // This restores the behavior that was working before
if (domainConfig) { if (domainConfig) {
// Has specific domain config - check IP restrictions only if allowedIPs is non-empty
if (domainConfig.allowedIPs.length > 0) {
const effectiveAllowedIPs: string[] = [ const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs, ...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []) ...(this.settings.defaultAllowedIPs || [])
@ -461,22 +366,17 @@ export class PortProxy {
...(domainConfig.blockedIPs || []), ...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []) ...(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(', ')}`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
} }
} else {
console.log(`Domain config for ${domainConfig.domains.join(', ')} has empty allowedIPs, skipping IP validation`);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) { } else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
// No domain config but has default IP restrictions
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) { if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
} }
} else {
// No domain config and no default allowed IPs
// In a chained proxy setup, we'll allow this connection
console.log(`No specific IP restrictions found for ${remoteIP}. Allowing connection in potential chained proxy setup.`);
} }
// If no IP validation rules, allow the connection (original behavior)
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!; const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = { const connectionOptions: plugins.net.NetConnectOpts = {
@ -657,14 +557,8 @@ export class PortProxy {
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) --- // --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) { if (this.settings.sniEnabled) {
// If using SNI, we need to wait for data to establish the connection
if (initialDataReceived) {
console.log(`Initial data already marked as received for ${remoteIP}, but SNI is enabled. This is unexpected.`);
}
initialDataReceived = false; initialDataReceived = false;
console.log(`Waiting for TLS ClientHello from ${remoteIP} to extract SNI...`);
socket.once('data', (chunk: Buffer) => { socket.once('data', (chunk: Buffer) => {
if (initialTimeout) { if (initialTimeout) {
clearTimeout(initialTimeout); clearTimeout(initialTimeout);
@ -672,23 +566,10 @@ export class PortProxy {
} }
initialDataReceived = true; initialDataReceived = true;
console.log(`Received initial data from ${remoteIP}, length: ${chunk.length} bytes`); const serverName = extractSNI(chunk) || '';
let serverName = '';
try {
// Only try to extract SNI if the chunk looks like a TLS ClientHello
if (chunk.length > 5 && chunk.readUInt8(0) === 22) {
serverName = extractSNI(chunk) || '';
console.log(`Extracted SNI: "${serverName}" from connection ${remoteIP}`);
} else {
console.log(`Data from ${remoteIP} doesn't appear to be a TLS ClientHello. First byte: ${chunk.length > 0 ? chunk.readUInt8(0) : 'N/A'}`);
}
} catch (err) {
console.log(`Error extracting SNI from chunk: ${err}. Proceeding without SNI.`);
}
// Lock the connection to the negotiated SNI. // Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = serverName; connectionRecord.lockedDomain = serverName;
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
// Delay adding the renegotiation listener until the next tick, // Delay adding the renegotiation listener until the next tick,
// so the initial ClientHello is not reprocessed. // so the initial ClientHello is not reprocessed.
@ -714,23 +595,10 @@ export class PortProxy {
setupConnection(serverName, chunk); setupConnection(serverName, chunk);
}); });
} else { } else {
// Non-SNI mode: we can proceed immediately without waiting for data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true; initialDataReceived = true;
console.log(`SNI disabled for connection from ${remoteIP}, proceeding directly to connection setup`); if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
// Check IP restrictions only if explicitly configured
if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
} }
}
// Proceed with connection setup
setupConnection(''); setupConnection('');
} }
}; };