fix(smartproxy): Improve error handling in forwarding connection handler and refine domain matching logic

This commit is contained in:
2025-05-19 18:29:56 +00:00
parent 98ef91b6ea
commit 3bf4e97e71
7 changed files with 303 additions and 280 deletions

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '19.3.6',
version: '19.3.7',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
}

View File

@ -372,14 +372,14 @@ export class RouteConnectionHandler {
initialChunk?: Buffer
): void {
const connectionId = record.id;
const action = route.action;
const action = route.action as IRouteAction;
// Check if this route uses NFTables for forwarding
if (action.forwardingEngine === 'nftables') {
// NFTables handles packet forwarding at the kernel level
// The application should NOT interfere with these connections
// Just log the connection for monitoring purposes
// Log the connection for monitoring purposes
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] NFTables forwarding (kernel-level): ` +
@ -407,9 +407,14 @@ export class RouteConnectionHandler {
);
}
}
// For NFTables routes, continue processing the connection normally
// since the packet forwarding happens transparently at the kernel level
// For NFTables routes, we should still track the connection but not interfere
// Mark the connection as using network proxy so it's cleaned up properly
record.usingNetworkProxy = true;
// We don't close the socket - just let it remain open
// The kernel-level NFTables rules will handle the actual forwarding
return;
}
// We should have a target configuration for forwarding
@ -657,6 +662,71 @@ export class RouteConnectionHandler {
}, record);
}
/**
* Setup improved error handling for the outgoing connection
*/
private setupOutgoingErrorHandler(
connectionId: string,
targetSocket: plugins.net.Socket,
record: IConnectionRecord,
socket: plugins.net.Socket,
finalTargetHost: string,
finalTargetPort: number
): void {
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 ${finalTargetHost}:${finalTargetPort}: ${err.message} (${code})`
);
// Resume the incoming socket to prevent it from hanging
socket.resume();
// Log specific error types for easier debugging
if (code === 'ECONNREFUSED') {
console.log(
`[${connectionId}] Target ${finalTargetHost}:${finalTargetPort} refused connection. ` +
`Check if the target service is running and listening on that port.`
);
} else if (code === 'ETIMEDOUT') {
console.log(
`[${connectionId}] Connection to ${finalTargetHost}:${finalTargetPort} timed out. ` +
`Check network conditions, firewall rules, or if the target is too far away.`
);
} else if (code === 'ECONNRESET') {
console.log(
`[${connectionId}] Connection to ${finalTargetHost}:${finalTargetPort} was reset. ` +
`The target might have closed the connection abruptly.`
);
} else if (code === 'EHOSTUNREACH') {
console.log(
`[${connectionId}] Host ${finalTargetHost} is unreachable. ` +
`Check DNS settings, network routing, or firewall rules.`
);
} else if (code === 'ENOTFOUND') {
console.log(
`[${connectionId}] DNS lookup failed for ${finalTargetHost}. ` +
`Check your DNS settings or if the hostname is correct.`
);
}
// 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}`);
});
}
/**
* Sets up a direct connection to the target
*/
@ -702,108 +772,14 @@ export class RouteConnectionHandler {
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
// Store initial data if provided
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
// Create the target socket
const targetSocket = plugins.net.connect(connectionOptions);
record.outgoing = targetSocket;
record.outgoingStartTime = Date.now();
@ -811,7 +787,7 @@ export class RouteConnectionHandler {
// Apply socket optimizations
targetSocket.setNoDelay(this.settings.noDelay);
// Apply keep-alive settings to the outgoing connection as well
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
@ -835,53 +811,15 @@ export class RouteConnectionHandler {
}
}
// 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 ${finalTargetHost}:${connectionOptions.port}: ${err.message} (${code})`
);
// Setup improved error handling for outgoing connection
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
// Resume the incoming socket to prevent it from hanging
socket.resume();
if (code === 'ECONNREFUSED') {
console.log(
`[${connectionId}] Target ${finalTargetHost}:${connectionOptions.port} refused connection`
);
} else if (code === 'ETIMEDOUT') {
console.log(
`[${connectionId}] Connection to ${finalTargetHost}:${connectionOptions.port} timed out`
);
} else if (code === 'ECONNRESET') {
console.log(
`[${connectionId}] Connection to ${finalTargetHost}:${connectionOptions.port} was reset`
);
} else if (code === 'EHOSTUNREACH') {
console.log(`[${connectionId}] Host ${finalTargetHost} 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');
}
// Route-based configuration doesn't use domain handlers
// Clean up the connection
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`);
});
// Setup close handler
// Setup close handlers
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
socket.on('close', this.connectionManager.handleClose('incoming', record));
// Setup error handlers for incoming socket
socket.on('error', this.connectionManager.handleError('incoming', record));
// Handle timeouts with keep-alive awareness
socket.on('timeout', () => {
@ -947,19 +885,19 @@ export class RouteConnectionHandler {
// Wait for the outgoing connection to be ready before setting up piping
targetSocket.once('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established to target: ${finalTargetHost}:${finalTargetPort}`
);
}
// 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
// Flush any pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
@ -982,52 +920,29 @@ export class RouteConnectionHandler {
record.pendingDataSize = 0;
}
// Setup piping in both directions without any delays
// Immediately setup bidirectional piping - much simpler than manual data management
socket.pipe(targetSocket);
targetSocket.pipe(socket);
// Resume the socket to ensure data flows
socket.resume();
// Track incoming data for bytes counting - do this after piping is set up
socket.on('data', (chunk: Buffer) => {
record.bytesReceived += chunk.length;
this.timeoutManager.updateActivity(record);
});
// 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;
}
// Log successful connection
console.log(
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
`${
serverName
? ` (SNI: ${serverName})`
: record.lockedDomain
? ` (Domain: ${record.lockedDomain})`
: ''
}`
);
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${finalTargetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: record.lockedDomain
? ` (Domain: ${record.lockedDomain})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: record.lockedDomain
? ` (Domain: ${record.lockedDomain})`
: ''
}`
);
}
// Add the renegotiation handler for SNI validation
// Add TLS renegotiation handler if needed
if (serverName) {
// Create connection info object for the existing connection
const connInfo = {
@ -1055,11 +970,6 @@ export class RouteConnectionHandler {
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.`
);
}
}
}
@ -1074,14 +984,7 @@ export class RouteConnectionHandler {
// 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}`
);
}
}
});
}
}
}

View File

@ -338,10 +338,19 @@ export class RouteManager extends plugins.EventEmitter {
// Find the first matching route based on priority order
for (const route of routesForPort) {
// Check domain match if specified
if (domain && !this.matchRouteDomain(route, domain)) {
continue;
// Check domain match
// If the route has domain restrictions and we have a domain to check
if (route.match.domains) {
// If no domain was provided (non-TLS or no SNI), this route doesn't match
if (!domain) {
continue;
}
// If domain is provided but doesn't match the route's domains, skip
if (!this.matchRouteDomain(route, domain)) {
continue;
}
}
// If route has no domain restrictions, it matches all domains
// Check path match if specified in both route and request
if (path && route.match.path) {