Enhance socket cleanup and management for improved connection handling
- Refactor cleanupSocket function to support options for immediate destruction, allowing drain, and grace periods. - Introduce createIndependentSocketHandlers for better management of half-open connections between client and server sockets. - Update various handlers (HTTP, HTTPS passthrough, HTTPS terminate) to utilize new cleanup and socket management functions. - Implement custom timeout handling in socket setup to prevent immediate closure during keep-alive connections. - Add tests for long-lived connections and half-open connection scenarios to ensure stability and reliability. - Adjust connection manager to handle socket cleanup based on activity status, improving resource management.
This commit is contained in:
parent
265b80ee04
commit
ad80798210
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiryDate": "2025-08-30T08:04:36.897Z",
|
"expiryDate": "2025-08-30T08:11:10.101Z",
|
||||||
"issueDate": "2025-06-01T08:04:36.897Z",
|
"issueDate": "2025-06-01T08:11:10.101Z",
|
||||||
"savedAt": "2025-06-01T08:04:36.897Z"
|
"savedAt": "2025-06-01T08:11:10.102Z"
|
||||||
}
|
}
|
1411
readme.plan.md
1411
readme.plan.md
File diff suppressed because it is too large
Load Diff
192
test/test.long-lived-connections.ts
Normal file
192
test/test.long-lived-connections.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as tls from 'tls';
|
||||||
|
import { SmartProxy } from '../ts/index.js';
|
||||||
|
|
||||||
|
let testProxy: SmartProxy;
|
||||||
|
let targetServer: net.Server;
|
||||||
|
|
||||||
|
// Create a simple echo server as target
|
||||||
|
tap.test('setup test environment', async () => {
|
||||||
|
// Create target server that echoes data back
|
||||||
|
targetServer = net.createServer((socket) => {
|
||||||
|
console.log('Target server: client connected');
|
||||||
|
|
||||||
|
// Echo data back
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
console.log(`Target server received: ${data.toString().trim()}`);
|
||||||
|
socket.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
console.log('Target server: client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
targetServer.listen(9876, () => {
|
||||||
|
console.log('Target server listening on port 9876');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create proxy with simple TCP forwarding (no TLS)
|
||||||
|
testProxy = new SmartProxy({
|
||||||
|
routes: [{
|
||||||
|
name: 'tcp-forward-test',
|
||||||
|
match: {
|
||||||
|
ports: 8888 // Plain TCP port
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9876
|
||||||
|
}
|
||||||
|
// No TLS configuration - just plain TCP forwarding
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
defaults: {
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9876
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enableDetailedLogging: true,
|
||||||
|
keepAliveTreatment: 'extended', // Allow long-lived connections
|
||||||
|
inactivityTimeout: 3600000, // 1 hour
|
||||||
|
socketTimeout: 3600000, // 1 hour
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelay: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
await testProxy.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
|
||||||
|
tools.timeout(65000); // 65 second test timeout
|
||||||
|
|
||||||
|
const client = new net.Socket();
|
||||||
|
let messagesReceived = 0;
|
||||||
|
let connectionClosed = false;
|
||||||
|
|
||||||
|
// Connect to proxy
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
client.connect(8888, 'localhost', () => {
|
||||||
|
console.log('Client connected to proxy');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up data handler
|
||||||
|
client.on('data', (data) => {
|
||||||
|
console.log(`Client received: ${data.toString().trim()}`);
|
||||||
|
messagesReceived++;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
console.log('Client connection closed');
|
||||||
|
connectionClosed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial handshake-like data
|
||||||
|
client.write('HELLO\n');
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
expect(messagesReceived).toEqual(1);
|
||||||
|
|
||||||
|
// Simulate WebSocket-like keep-alive pattern
|
||||||
|
// Send periodic messages over 60 seconds
|
||||||
|
const startTime = Date.now();
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
if (!connectionClosed && Date.now() - startTime < 60000) {
|
||||||
|
console.log('Sending ping...');
|
||||||
|
client.write('PING\n');
|
||||||
|
} else {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
}
|
||||||
|
}, 10000); // Every 10 seconds
|
||||||
|
|
||||||
|
// Wait for 61 seconds
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 61000));
|
||||||
|
|
||||||
|
// Clean up interval
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
|
||||||
|
// Connection should still be open
|
||||||
|
expect(connectionClosed).toEqual(false);
|
||||||
|
|
||||||
|
// Should have received responses (1 hello + 6 pings)
|
||||||
|
expect(messagesReceived).toBeGreaterThan(5);
|
||||||
|
|
||||||
|
// Close connection gracefully
|
||||||
|
client.end();
|
||||||
|
|
||||||
|
// Wait for close
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
expect(connectionClosed).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support half-open connections', async () => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
const serverSocket = await new Promise<net.Socket>((resolve) => {
|
||||||
|
targetServer.once('connection', resolve);
|
||||||
|
client.connect(8888, 'localhost');
|
||||||
|
});
|
||||||
|
|
||||||
|
let clientClosed = false;
|
||||||
|
let serverClosed = false;
|
||||||
|
let serverReceivedData = false;
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
clientClosed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
serverSocket.on('close', () => {
|
||||||
|
serverClosed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
serverSocket.on('data', () => {
|
||||||
|
serverReceivedData = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client sends data then closes write side
|
||||||
|
client.write('HALF-OPEN TEST\n');
|
||||||
|
client.end(); // Close write side only
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Server should still be able to send data
|
||||||
|
expect(serverClosed).toEqual(false);
|
||||||
|
serverSocket.write('RESPONSE\n');
|
||||||
|
|
||||||
|
// Wait for data
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Now close server side
|
||||||
|
serverSocket.end();
|
||||||
|
|
||||||
|
// Wait for full close
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
expect(clientClosed).toEqual(true);
|
||||||
|
expect(serverClosed).toEqual(true);
|
||||||
|
expect(serverReceivedData).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await testProxy.stop();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
targetServer.close(() => {
|
||||||
|
console.log('Target server closed');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@ -1,27 +1,62 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
|
||||||
|
export interface CleanupOptions {
|
||||||
|
immediate?: boolean; // Force immediate destruction
|
||||||
|
allowDrain?: boolean; // Allow write buffer to drain
|
||||||
|
gracePeriod?: number; // Ms to wait before force close
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely cleanup a socket by removing all listeners and destroying it
|
* Safely cleanup a socket by removing all listeners and destroying it
|
||||||
* @param socket The socket to cleanup
|
* @param socket The socket to cleanup
|
||||||
* @param socketName Optional name for logging
|
* @param socketName Optional name for logging
|
||||||
|
* @param options Cleanup options
|
||||||
*/
|
*/
|
||||||
export function cleanupSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket | null, socketName?: string): void {
|
export function cleanupSocket(
|
||||||
if (!socket) return;
|
socket: plugins.net.Socket | plugins.tls.TLSSocket | null,
|
||||||
|
socketName?: string,
|
||||||
|
options: CleanupOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (!socket || socket.destroyed) return Promise.resolve();
|
||||||
|
|
||||||
try {
|
return new Promise<void>((resolve) => {
|
||||||
// Remove all event listeners
|
const cleanup = () => {
|
||||||
socket.removeAllListeners();
|
try {
|
||||||
|
// Remove all event listeners
|
||||||
|
socket.removeAllListeners();
|
||||||
|
|
||||||
|
// Destroy if not already destroyed
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
// Unpipe any streams
|
if (options.immediate) {
|
||||||
socket.unpipe();
|
// Immediate cleanup (old behavior)
|
||||||
|
socket.unpipe();
|
||||||
// Destroy if not already destroyed
|
cleanup();
|
||||||
if (!socket.destroyed) {
|
} else if (options.allowDrain && socket.writable) {
|
||||||
socket.destroy();
|
// Allow pending writes to complete
|
||||||
|
socket.end(() => cleanup());
|
||||||
|
|
||||||
|
// Force cleanup after grace period
|
||||||
|
if (options.gracePeriod) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, options.gracePeriod);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: immediate cleanup
|
||||||
|
socket.unpipe();
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +65,7 @@ export function cleanupSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket
|
|||||||
* @param serverSocket The server socket (optional)
|
* @param serverSocket The server socket (optional)
|
||||||
* @param onCleanup Optional callback when cleanup is done
|
* @param onCleanup Optional callback when cleanup is done
|
||||||
* @returns A cleanup function that can be called multiple times safely
|
* @returns A cleanup function that can be called multiple times safely
|
||||||
|
* @deprecated Use createIndependentSocketHandlers for better half-open support
|
||||||
*/
|
*/
|
||||||
export function createSocketCleanupHandler(
|
export function createSocketCleanupHandler(
|
||||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
@ -42,10 +78,10 @@ export function createSocketCleanupHandler(
|
|||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
cleanedUp = true;
|
cleanedUp = true;
|
||||||
|
|
||||||
// Cleanup both sockets
|
// Cleanup both sockets (old behavior - too aggressive)
|
||||||
cleanupSocket(clientSocket, 'client');
|
cleanupSocket(clientSocket, 'client', { immediate: true });
|
||||||
if (serverSocket) {
|
if (serverSocket) {
|
||||||
cleanupSocket(serverSocket, 'server');
|
cleanupSocket(serverSocket, 'server', { immediate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call cleanup callback if provided
|
// Call cleanup callback if provided
|
||||||
@ -55,15 +91,79 @@ export function createSocketCleanupHandler(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create independent cleanup handlers for paired sockets that support half-open connections
|
||||||
|
* @param clientSocket The client socket
|
||||||
|
* @param serverSocket The server socket
|
||||||
|
* @param onBothClosed Callback when both sockets are closed
|
||||||
|
* @returns Independent cleanup functions for each socket
|
||||||
|
*/
|
||||||
|
export function createIndependentSocketHandlers(
|
||||||
|
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
|
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
|
onBothClosed: (reason: string) => void
|
||||||
|
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
|
||||||
|
let clientClosed = false;
|
||||||
|
let serverClosed = false;
|
||||||
|
let clientReason = '';
|
||||||
|
let serverReason = '';
|
||||||
|
|
||||||
|
const checkBothClosed = () => {
|
||||||
|
if (clientClosed && serverClosed) {
|
||||||
|
onBothClosed(`client: ${clientReason}, server: ${serverReason}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupClient = async (reason: string) => {
|
||||||
|
if (clientClosed) return;
|
||||||
|
clientClosed = true;
|
||||||
|
clientReason = reason;
|
||||||
|
|
||||||
|
// Allow server to continue if still active
|
||||||
|
if (!serverClosed && serverSocket.writable) {
|
||||||
|
// Half-close: stop reading from client, let server finish
|
||||||
|
clientSocket.pause();
|
||||||
|
clientSocket.unpipe(serverSocket);
|
||||||
|
await cleanupSocket(clientSocket, 'client', { allowDrain: true, gracePeriod: 5000 });
|
||||||
|
} else {
|
||||||
|
await cleanupSocket(clientSocket, 'client', { immediate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkBothClosed();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupServer = async (reason: string) => {
|
||||||
|
if (serverClosed) return;
|
||||||
|
serverClosed = true;
|
||||||
|
serverReason = reason;
|
||||||
|
|
||||||
|
// Allow client to continue if still active
|
||||||
|
if (!clientClosed && clientSocket.writable) {
|
||||||
|
// Half-close: stop reading from server, let client finish
|
||||||
|
serverSocket.pause();
|
||||||
|
serverSocket.unpipe(clientSocket);
|
||||||
|
await cleanupSocket(serverSocket, 'server', { allowDrain: true, gracePeriod: 5000 });
|
||||||
|
} else {
|
||||||
|
await cleanupSocket(serverSocket, 'server', { immediate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkBothClosed();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { cleanupClient, cleanupServer };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup socket error and close handlers with proper cleanup
|
* Setup socket error and close handlers with proper cleanup
|
||||||
* @param socket The socket to setup handlers for
|
* @param socket The socket to setup handlers for
|
||||||
* @param handleClose The cleanup function to call
|
* @param handleClose The cleanup function to call
|
||||||
|
* @param handleTimeout Optional custom timeout handler
|
||||||
* @param errorPrefix Optional prefix for error messages
|
* @param errorPrefix Optional prefix for error messages
|
||||||
*/
|
*/
|
||||||
export function setupSocketHandlers(
|
export function setupSocketHandlers(
|
||||||
socket: plugins.net.Socket | plugins.tls.TLSSocket,
|
socket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
handleClose: (reason: string) => void,
|
handleClose: (reason: string) => void,
|
||||||
|
handleTimeout?: (socket: plugins.net.Socket | plugins.tls.TLSSocket) => void,
|
||||||
errorPrefix?: string
|
errorPrefix?: string
|
||||||
): void {
|
): void {
|
||||||
socket.on('error', (error) => {
|
socket.on('error', (error) => {
|
||||||
@ -77,8 +177,12 @@ export function setupSocketHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
socket.on('timeout', () => {
|
||||||
const prefix = errorPrefix || 'socket';
|
if (handleTimeout) {
|
||||||
handleClose(`${prefix}_timeout`);
|
handleTimeout(socket); // Custom timeout handling
|
||||||
|
} else {
|
||||||
|
// Default: just log, don't close
|
||||||
|
console.warn(`Socket timeout: ${errorPrefix || 'socket'}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +49,12 @@ export class HttpForwardingHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setupSocketHandlers(socket, handleClose, 'http');
|
// Use custom timeout handler that doesn't close the socket
|
||||||
|
setupSocketHandlers(socket, handleClose, () => {
|
||||||
|
// For HTTP, we can be more aggressive with timeouts since connections are shorter
|
||||||
|
// But still don't close immediately - let the connection finish naturally
|
||||||
|
console.warn(`HTTP socket timeout from ${remoteAddress}`);
|
||||||
|
}, 'http');
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
socket.on('error', (error) => {
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||||
|
@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
|
|||||||
import { ForwardingHandler } from './base-handler.js';
|
import { ForwardingHandler } from './base-handler.js';
|
||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
import type { IForwardConfig } from '../config/forwarding-types.js';
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||||
import { createSocketCleanupHandler, setupSocketHandlers, pipeSockets } from '../../core/utils/socket-utils.js';
|
import { createIndependentSocketHandlers, setupSocketHandlers } from '../../core/utils/socket-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for HTTPS passthrough (SNI forwarding without termination)
|
* Handler for HTTPS passthrough (SNI forwarding without termination)
|
||||||
@ -55,19 +55,32 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
|||||||
let bytesSent = 0;
|
let bytesSent = 0;
|
||||||
let bytesReceived = 0;
|
let bytesReceived = 0;
|
||||||
|
|
||||||
// Create cleanup handler with our utility
|
// Create independent handlers for half-open connection support
|
||||||
const handleClose = createSocketCleanupHandler(clientSocket, serverSocket, (reason) => {
|
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
clientSocket,
|
||||||
remoteAddress,
|
serverSocket,
|
||||||
bytesSent,
|
(reason) => {
|
||||||
bytesReceived,
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||||
reason
|
remoteAddress,
|
||||||
});
|
bytesSent,
|
||||||
});
|
bytesReceived,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Setup error and close handlers for both sockets
|
// Setup handlers with custom timeout handling that doesn't close connections
|
||||||
setupSocketHandlers(serverSocket, handleClose, 'server');
|
const timeout = this.getTimeout();
|
||||||
setupSocketHandlers(clientSocket, handleClose, 'client');
|
|
||||||
|
setupSocketHandlers(clientSocket, cleanupClient, (socket) => {
|
||||||
|
// Just reset timeout, don't close
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
}, 'client');
|
||||||
|
|
||||||
|
setupSocketHandlers(serverSocket, cleanupServer, (socket) => {
|
||||||
|
// Just reset timeout, don't close
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
}, 'server');
|
||||||
|
|
||||||
// Forward data from client to server
|
// Forward data from client to server
|
||||||
clientSocket.on('data', (data) => {
|
clientSocket.on('data', (data) => {
|
||||||
@ -117,8 +130,7 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set timeouts
|
// Set initial timeouts - they will be reset on each timeout event
|
||||||
const timeout = this.getTimeout();
|
|
||||||
clientSocket.setTimeout(timeout);
|
clientSocket.setTimeout(timeout);
|
||||||
serverSocket.setTimeout(timeout);
|
serverSocket.setTimeout(timeout);
|
||||||
}
|
}
|
||||||
@ -128,7 +140,7 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
|||||||
* @param req The HTTP request
|
* @param req The HTTP request
|
||||||
* @param res The HTTP response
|
* @param res The HTTP response
|
||||||
*/
|
*/
|
||||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
public handleHttpRequest(_req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||||
// HTTPS passthrough doesn't support HTTP requests
|
// HTTPS passthrough doesn't support HTTP requests
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
res.end('HTTP not supported for this domain');
|
res.end('HTTP not supported for this domain');
|
||||||
|
@ -112,7 +112,7 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up error handling with our cleanup utility
|
// Set up error handling with our cleanup utility
|
||||||
setupSocketHandlers(tlsSocket, handleClose, 'tls');
|
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
const timeout = this.getTimeout();
|
const timeout = this.getTimeout();
|
||||||
@ -167,7 +167,7 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up handlers for backend socket
|
// Set up handlers for backend socket
|
||||||
setupSocketHandlers(backendSocket, newHandleClose, 'backend');
|
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
||||||
|
|
||||||
backendSocket.on('error', (error) => {
|
backendSocket.on('error', (error) => {
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||||
|
@ -106,7 +106,7 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up error handling with our cleanup utility
|
// Set up error handling with our cleanup utility
|
||||||
setupSocketHandlers(tlsSocket, handleClose, 'tls');
|
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
const timeout = this.getTimeout();
|
const timeout = this.getTimeout();
|
||||||
@ -151,7 +151,7 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up handlers for backend socket
|
// Set up handlers for backend socket
|
||||||
setupSocketHandlers(backendSocket, newHandleClose, 'backend');
|
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
||||||
|
|
||||||
backendSocket.on('error', (error) => {
|
backendSocket.on('error', (error) => {
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||||
|
@ -134,7 +134,7 @@ export class ConnectionPool {
|
|||||||
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
||||||
connections.length > (this.options.connectionPoolSize || 50)) {
|
connections.length > (this.options.connectionPoolSize || 50)) {
|
||||||
|
|
||||||
cleanupSocket(connection.socket, `pool-${host}-idle`);
|
cleanupSocket(connection.socket, `pool-${host}-idle`, { immediate: true }).catch(() => {});
|
||||||
|
|
||||||
connections.shift(); // Remove from pool
|
connections.shift(); // Remove from pool
|
||||||
removed++;
|
removed++;
|
||||||
@ -164,7 +164,7 @@ export class ConnectionPool {
|
|||||||
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
|
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
|
||||||
|
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
cleanupSocket(connection.socket, `pool-${host}-close`);
|
cleanupSocket(connection.socket, `pool-${host}-close`, { immediate: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -520,9 +520,10 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
this.webSocketHandler.shutdown();
|
this.webSocketHandler.shutdown();
|
||||||
|
|
||||||
// Close all tracked sockets
|
// Close all tracked sockets
|
||||||
for (const socket of this.socketMap.getArray()) {
|
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
|
||||||
cleanupSocket(socket, 'http-proxy-stop');
|
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
|
||||||
}
|
);
|
||||||
|
await Promise.all(socketCleanupPromises);
|
||||||
|
|
||||||
// Close all connection pool connections
|
// Close all connection pool connections
|
||||||
this.connectionPool.closeAllConnections();
|
this.connectionPool.closeAllConnections();
|
||||||
|
@ -278,12 +278,37 @@ export class ConnectionManager extends LifecycleComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle socket cleanup without delay
|
// Handle socket cleanup - check if sockets are still active
|
||||||
cleanupSocket(record.incoming, `${record.id}-incoming`);
|
const cleanupPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (record.incoming) {
|
||||||
|
if (!record.incoming.writable || record.incoming.destroyed) {
|
||||||
|
// Socket is not active, clean up immediately
|
||||||
|
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { immediate: true }));
|
||||||
|
} else {
|
||||||
|
// Socket is still active, allow graceful cleanup
|
||||||
|
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (record.outgoing) {
|
if (record.outgoing) {
|
||||||
cleanupSocket(record.outgoing, `${record.id}-outgoing`);
|
if (!record.outgoing.writable || record.outgoing.destroyed) {
|
||||||
|
// Socket is not active, clean up immediately
|
||||||
|
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { immediate: true }));
|
||||||
|
} else {
|
||||||
|
// Socket is still active, allow graceful cleanup
|
||||||
|
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for cleanup to complete
|
||||||
|
Promise.all(cleanupPromises).catch(err => {
|
||||||
|
logger.log('error', `Error during socket cleanup: ${err}`, {
|
||||||
|
connectionId: record.id,
|
||||||
|
error: err,
|
||||||
|
component: 'connection-manager'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Clear pendingData to avoid memory leaks
|
// Clear pendingData to avoid memory leaks
|
||||||
record.pendingData = [];
|
record.pendingData = [];
|
||||||
@ -484,19 +509,24 @@ export class ConnectionManager extends LifecycleComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parity check: if outgoing socket closed and incoming remains active
|
// Parity check: if outgoing socket closed and incoming remains active
|
||||||
|
// Increased from 2 minutes to 30 minutes for long-lived connections
|
||||||
if (
|
if (
|
||||||
record.outgoingClosedTime &&
|
record.outgoingClosedTime &&
|
||||||
!record.incoming.destroyed &&
|
!record.incoming.destroyed &&
|
||||||
!record.connectionClosed &&
|
!record.connectionClosed &&
|
||||||
now - record.outgoingClosedTime > 120000
|
now - record.outgoingClosedTime > 1800000 // 30 minutes
|
||||||
) {
|
) {
|
||||||
logger.log('warn', `Parity check failed: ${record.remoteIP}`, {
|
// Only close if no data activity for 10 minutes
|
||||||
connectionId,
|
if (now - record.lastActivity > 600000) {
|
||||||
remoteIP: record.remoteIP,
|
logger.log('warn', `Parity check failed after extended timeout: ${record.remoteIP}`, {
|
||||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
connectionId,
|
||||||
component: 'connection-manager'
|
remoteIP: record.remoteIP,
|
||||||
});
|
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||||
this.cleanupConnection(record, 'parity_check');
|
inactiveFor: plugins.prettyMs(now - record.lastActivity),
|
||||||
|
component: 'connection-manager'
|
||||||
|
});
|
||||||
|
this.cleanupConnection(record, 'parity_check');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -537,13 +567,18 @@ export class ConnectionManager extends LifecycleComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Immediate destruction using socket-utils
|
// Immediate destruction using socket-utils
|
||||||
|
const shutdownPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
if (record.incoming) {
|
if (record.incoming) {
|
||||||
cleanupSocket(record.incoming, `${record.id}-incoming-shutdown`);
|
shutdownPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming-shutdown`, { immediate: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.outgoing) {
|
if (record.outgoing) {
|
||||||
cleanupSocket(record.outgoing, `${record.id}-outgoing-shutdown`);
|
shutdownPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing-shutdown`, { immediate: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't wait for shutdown cleanup in this batch processing
|
||||||
|
Promise.all(shutdownPromises).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error during connection cleanup: ${err}`, {
|
logger.log('error', `Error during connection cleanup: ${err}`, {
|
||||||
connectionId: record.id,
|
connectionId: record.id,
|
||||||
|
@ -65,7 +65,7 @@ export class PortManager {
|
|||||||
const server = plugins.net.createServer((socket) => {
|
const server = plugins.net.createServer((socket) => {
|
||||||
// Check if shutting down
|
// Check if shutting down
|
||||||
if (this.isShuttingDown) {
|
if (this.isShuttingDown) {
|
||||||
cleanupSocket(socket, 'port-manager-shutdown');
|
cleanupSocket(socket, 'port-manager-shutdown', { immediate: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import { TlsManager } from './tls-manager.js';
|
|||||||
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||||
import { TimeoutManager } from './timeout-manager.js';
|
import { TimeoutManager } from './timeout-manager.js';
|
||||||
import { RouteManager } from './route-manager.js';
|
import { RouteManager } from './route-manager.js';
|
||||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers } from '../../core/utils/socket-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles new connection processing and setup logic with support for route-based configuration
|
* Handles new connection processing and setup logic with support for route-based configuration
|
||||||
@ -84,7 +84,7 @@ export class RouteConnectionHandler {
|
|||||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||||
if (!ipValidation.allowed) {
|
if (!ipValidation.allowed) {
|
||||||
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
||||||
cleanupSocket(socket, `rejected-${ipValidation.reason}`);
|
cleanupSocket(socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1110,9 +1110,8 @@ export class RouteConnectionHandler {
|
|||||||
// Setup improved error handling for outgoing connection
|
// Setup improved error handling for outgoing connection
|
||||||
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
||||||
|
|
||||||
// Setup close handlers
|
// Note: Close handlers are managed by independent socket handlers above
|
||||||
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
|
// We don't register handleClose here to avoid bilateral cleanup
|
||||||
socket.on('close', this.connectionManager.handleClose('incoming', record));
|
|
||||||
|
|
||||||
// Setup error handlers for incoming socket
|
// Setup error handlers for incoming socket
|
||||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||||
@ -1225,14 +1224,64 @@ export class RouteConnectionHandler {
|
|||||||
record.pendingDataSize = 0;
|
record.pendingDataSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediately setup bidirectional piping - much simpler than manual data management
|
// Set up independent socket handlers for half-open connection support
|
||||||
socket.pipe(targetSocket);
|
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
||||||
targetSocket.pipe(socket);
|
socket,
|
||||||
|
targetSocket,
|
||||||
|
(reason) => {
|
||||||
|
this.connectionManager.initiateCleanupOnce(record, reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Track incoming data for bytes counting - do this after piping is set up
|
// Setup socket handlers with custom timeout handling
|
||||||
|
setupSocketHandlers(socket, cleanupClient, (sock) => {
|
||||||
|
// Don't close on timeout for keep-alive connections
|
||||||
|
if (record.hasKeepAlive) {
|
||||||
|
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
||||||
|
}
|
||||||
|
}, 'client');
|
||||||
|
|
||||||
|
setupSocketHandlers(targetSocket, cleanupServer, (sock) => {
|
||||||
|
// Don't close on timeout for keep-alive connections
|
||||||
|
if (record.hasKeepAlive) {
|
||||||
|
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
||||||
|
}
|
||||||
|
}, 'server');
|
||||||
|
|
||||||
|
// Forward data from client to target with backpressure handling
|
||||||
socket.on('data', (chunk: Buffer) => {
|
socket.on('data', (chunk: Buffer) => {
|
||||||
record.bytesReceived += chunk.length;
|
record.bytesReceived += chunk.length;
|
||||||
this.timeoutManager.updateActivity(record);
|
this.timeoutManager.updateActivity(record);
|
||||||
|
|
||||||
|
if (targetSocket.writable) {
|
||||||
|
const flushed = targetSocket.write(chunk);
|
||||||
|
|
||||||
|
// Handle backpressure
|
||||||
|
if (!flushed) {
|
||||||
|
socket.pause();
|
||||||
|
targetSocket.once('drain', () => {
|
||||||
|
socket.resume();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward data from target to client with backpressure handling
|
||||||
|
targetSocket.on('data', (chunk: Buffer) => {
|
||||||
|
record.bytesSent += chunk.length;
|
||||||
|
this.timeoutManager.updateActivity(record);
|
||||||
|
|
||||||
|
if (socket.writable) {
|
||||||
|
const flushed = socket.write(chunk);
|
||||||
|
|
||||||
|
// Handle backpressure
|
||||||
|
if (!flushed) {
|
||||||
|
targetSocket.pause();
|
||||||
|
socket.once('drain', () => {
|
||||||
|
targetSocket.resume();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful connection
|
// Log successful connection
|
||||||
|
Loading…
x
Reference in New Issue
Block a user