Implement proxy chain connection accumulation fix and add comprehensive tests

- Updated socket handling to prevent connection accumulation in chained proxies.
- Introduced centralized bidirectional forwarding for consistent socket management.
- Enhanced cleanup logic to ensure immediate closure of sockets when one closes.
- Added tests to verify connection behavior under various scenarios, including backend failures and rapid reconnections.
This commit is contained in:
2025-06-01 15:10:36 +00:00
parent 3ac3345be8
commit 07f5ceddc4
6 changed files with 751 additions and 100 deletions

View File

@ -109,7 +109,8 @@ export function createSocketCleanupHandler(
export function createIndependentSocketHandlers(
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
onBothClosed: (reason: string) => void
onBothClosed: (reason: string) => void,
options: { enableHalfOpen?: boolean } = {}
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
let clientClosed = false;
let serverClosed = false;
@ -127,8 +128,13 @@ export function createIndependentSocketHandlers(
clientClosed = true;
clientReason = reason;
// Allow server to continue if still active
if (!serverClosed && serverSocket.writable) {
// Default behavior: close both sockets when one closes (required for proxy chains)
if (!serverClosed && !options.enableHalfOpen) {
serverSocket.destroy();
}
// Half-open support (opt-in only)
if (!serverClosed && serverSocket.writable && options.enableHalfOpen) {
// Half-close: stop reading from client, let server finish
clientSocket.pause();
clientSocket.unpipe(serverSocket);
@ -145,8 +151,13 @@ export function createIndependentSocketHandlers(
serverClosed = true;
serverReason = reason;
// Allow client to continue if still active
if (!clientClosed && clientSocket.writable) {
// Default behavior: close both sockets when one closes (required for proxy chains)
if (!clientClosed && !options.enableHalfOpen) {
clientSocket.destroy();
}
// Half-open support (opt-in only)
if (!clientClosed && clientSocket.writable && options.enableHalfOpen) {
// Half-close: stop reading from server, let client finish
serverSocket.pause();
serverSocket.unpipe(clientSocket);
@ -194,6 +205,79 @@ export function setupSocketHandlers(
});
}
/**
* Setup bidirectional data forwarding between two sockets with proper cleanup
* @param clientSocket The client/incoming socket
* @param serverSocket The server/outgoing socket
* @param handlers Object containing optional handlers for data and cleanup
* @returns Cleanup functions for both sockets
*/
export function setupBidirectionalForwarding(
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
handlers: {
onClientData?: (chunk: Buffer) => void;
onServerData?: (chunk: Buffer) => void;
onCleanup: (reason: string) => void;
enableHalfOpen?: boolean;
}
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
// Set up cleanup handlers
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
clientSocket,
serverSocket,
handlers.onCleanup,
{ enableHalfOpen: handlers.enableHalfOpen }
);
// Set up error and close handlers
setupSocketHandlers(clientSocket, cleanupClient, undefined, 'client');
setupSocketHandlers(serverSocket, cleanupServer, undefined, 'server');
// Set up data forwarding with backpressure handling
clientSocket.on('data', (chunk: Buffer) => {
if (handlers.onClientData) {
handlers.onClientData(chunk);
}
if (serverSocket.writable) {
const flushed = serverSocket.write(chunk);
// Handle backpressure
if (!flushed) {
clientSocket.pause();
serverSocket.once('drain', () => {
if (!clientSocket.destroyed) {
clientSocket.resume();
}
});
}
}
});
serverSocket.on('data', (chunk: Buffer) => {
if (handlers.onServerData) {
handlers.onServerData(chunk);
}
if (clientSocket.writable) {
const flushed = clientSocket.write(chunk);
// Handle backpressure
if (!flushed) {
serverSocket.pause();
clientSocket.once('drain', () => {
if (!serverSocket.destroyed) {
serverSocket.resume();
}
});
}
}
});
return { cleanupClient, cleanupServer };
}
/**
* Pipe two sockets together with proper cleanup on either end
* @param socket1 First socket