Compare commits

..

5 Commits

Author SHA1 Message Date
fa9166be4b 19.5.19
Some checks failed
Default (tags) / security (push) Failing after 14m47s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 15:45:03 +00:00
c5efee3bfe fix(smartproxy): Fix connection handling and improve route matching edge cases 2025-06-01 15:45:03 +00:00
47508eb1eb Refactor socket handling in forwarding handlers to use centralized utilities and remove deprecated functions 2025-06-01 15:35:45 +00:00
fb147148ef 19.5.18
Some checks failed
Default (tags) / security (push) Failing after 14m49s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 15:10:42 +00:00
07f5ceddc4 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.
2025-06-01 15:10:36 +00:00
11 changed files with 880 additions and 210 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## 2025-06-01 - 19.5.19 - fix(smartproxy)
Fix connection handling and improve route matching edge cases
- Enhanced cleanup logic to prevent connection accumulation under rapid retry scenarios
- Improved matching for wildcard domains and path parameters in the route configuration
- Minor refactoring in async utilities and internal socket handling for better performance
- Updated test suites and documentation for clearer configuration examples
## 2025-05-29 - 19.5.3 - fix(smartproxy)
Fix route security configuration location and improve ACME timing tests and socket mock implementations

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "19.5.17",
"version": "19.5.19",
"private": false,
"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.",
"main": "dist_ts/index.js",

View File

@ -582,4 +582,83 @@ onError: (error) => {
- **Positive**: Prevents resource exhaustion from rapid reconnection attempts
### Migration Notes
No configuration changes needed. The fix is automatic and backward compatible.
No configuration changes needed. The fix is automatic and backward compatible.
## Proxy Chain Connection Accumulation Fix (v19.5.14+)
### Issue
When chaining SmartProxies (Client → SmartProxy1 → SmartProxy2 → Backend), connections would accumulate and never be cleaned up. This was particularly severe when the backend was down or closing connections immediately.
### Root Cause
The half-open connection support was preventing proper cascade cleanup in proxy chains:
1. Backend closes → SmartProxy2's server socket closes
2. SmartProxy2 keeps client socket open (half-open support)
3. SmartProxy1 never gets notified that downstream is closed
4. Connections accumulate at each proxy in the chain
The issue was in `createIndependentSocketHandlers()` which waited for BOTH sockets to close before cleanup.
### Solution
1. **Changed default behavior**: When one socket closes, both close immediately
2. **Made half-open support opt-in**: Only enabled when explicitly requested
3. **Centralized socket handling**: Created `setupBidirectionalForwarding()` for consistent behavior
4. **Applied everywhere**: Updated HttpProxyBridge and route-connection-handler to use centralized handling
### Changes Made
```typescript
// socket-utils.ts - Default behavior now closes both sockets
export function createIndependentSocketHandlers(
clientSocket, serverSocket, onBothClosed,
options: { enableHalfOpen?: boolean } = {} // Half-open is opt-in
) {
// When server closes, immediately close client (unless half-open enabled)
if (!clientClosed && !options.enableHalfOpen) {
clientSocket.destroy();
}
}
// New centralized function for consistent socket pairing
export function setupBidirectionalForwarding(
clientSocket, serverSocket,
handlers: {
onClientData?: (chunk) => void;
onServerData?: (chunk) => void;
onCleanup: (reason) => void;
enableHalfOpen?: boolean; // Default: false
}
)
```
### Test Coverage
- `test/test.proxy-chain-simple.node.ts` - Verifies proxy chains don't accumulate connections
- Tests confirm connections stay at 0 even with backend closing immediately
- Works for any proxy chain configuration (not just localhost)
### Performance Impact
- **Positive**: No more connection accumulation in proxy chains
- **Positive**: Immediate cleanup reduces memory usage
- **Neutral**: Half-open connections still available when needed (opt-in)
### Migration Notes
No configuration changes needed. The fix applies to all proxy chains automatically.
## Socket Cleanup Handler Deprecation (v19.5.15+)
### Issue
The deprecated `createSocketCleanupHandler()` function was still being used in forwarding handlers, despite being marked as deprecated.
### Solution
Updated all forwarding handlers to use the new centralized socket utilities:
1. **Replaced `createSocketCleanupHandler()`** with `setupBidirectionalForwarding()` in:
- `https-terminate-to-https-handler.ts`
- `https-terminate-to-http-handler.ts`
2. **Removed deprecated function** from `socket-utils.ts`
### Benefits
- Consistent socket handling across all handlers
- Proper cleanup in proxy chains (no half-open connections by default)
- Better backpressure handling with the centralized implementation
- Reduced code duplication
### Migration Notes
No user-facing changes. All forwarding handlers now use the same robust socket handling as the main SmartProxy connection handler.

View File

@ -0,0 +1,195 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as plugins from '../ts/plugins.js';
// Import SmartProxy and configurations
import { SmartProxy } from '../ts/index.js';
tap.test('simple proxy chain test - identify connection accumulation', async () => {
console.log('\n=== Simple Proxy Chain Test ===');
console.log('Setup: Client → SmartProxy1 (8590) → SmartProxy2 (8591) → Backend (down)');
// Create backend server that accepts and immediately closes connections
const backend = net.createServer((socket) => {
console.log('Backend: Connection received, closing immediately');
socket.destroy();
});
await new Promise<void>((resolve) => {
backend.listen(9998, () => {
console.log('✓ Backend server started on port 9998 (closes connections immediately)');
resolve();
});
});
// Create SmartProxy2 (downstream)
const proxy2 = new SmartProxy({
ports: [8591],
enableDetailedLogging: true,
socketTimeout: 5000,
routes: [{
name: 'to-backend',
match: { ports: 8591 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 9998 // Backend that closes immediately
}
}
}]
});
// Create SmartProxy1 (upstream)
const proxy1 = new SmartProxy({
ports: [8590],
enableDetailedLogging: true,
socketTimeout: 5000,
routes: [{
name: 'to-proxy2',
match: { ports: 8590 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 8591 // Forward to proxy2
}
}
}]
});
await proxy2.start();
console.log('✓ SmartProxy2 started on port 8591');
await proxy1.start();
console.log('✓ SmartProxy1 started on port 8590');
// Helper to get connection counts
const getConnectionCounts = () => {
const conn1 = (proxy1 as any).connectionManager;
const conn2 = (proxy2 as any).connectionManager;
return {
proxy1: conn1 ? conn1.getConnectionCount() : 0,
proxy2: conn2 ? conn2.getConnectionCount() : 0
};
};
console.log('\n--- Making 5 sequential connections ---');
for (let i = 0; i < 5; i++) {
console.log(`\n=== Connection ${i + 1} ===`);
const counts = getConnectionCounts();
console.log(`Before: Proxy1=${counts.proxy1}, Proxy2=${counts.proxy2}`);
await new Promise<void>((resolve) => {
const client = new net.Socket();
let dataReceived = false;
client.on('data', (data) => {
console.log(`Client received data: ${data.toString()}`);
dataReceived = true;
});
client.on('error', (err) => {
console.log(`Client error: ${err.code}`);
resolve();
});
client.on('close', () => {
console.log(`Client closed (data received: ${dataReceived})`);
resolve();
});
client.connect(8590, 'localhost', () => {
console.log('Client connected to Proxy1');
// Send HTTP request
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
});
// Timeout
setTimeout(() => {
if (!client.destroyed) {
console.log('Client timeout, destroying');
client.destroy();
}
resolve();
}, 2000);
});
// Wait a bit and check counts
await new Promise(resolve => setTimeout(resolve, 500));
const afterCounts = getConnectionCounts();
console.log(`After: Proxy1=${afterCounts.proxy1}, Proxy2=${afterCounts.proxy2}`);
if (afterCounts.proxy1 > 0 || afterCounts.proxy2 > 0) {
console.log('⚠️ WARNING: Connections not cleaned up!');
}
}
console.log('\n--- Test with backend completely down ---');
// Stop backend
backend.close();
await new Promise(resolve => setTimeout(resolve, 100));
console.log('✓ Backend stopped');
// Make more connections with backend down
for (let i = 0; i < 3; i++) {
console.log(`\n=== Connection ${i + 6} (backend down) ===`);
const counts = getConnectionCounts();
console.log(`Before: Proxy1=${counts.proxy1}, Proxy2=${counts.proxy2}`);
await new Promise<void>((resolve) => {
const client = new net.Socket();
client.on('error', () => {
resolve();
});
client.on('close', () => {
resolve();
});
client.connect(8590, 'localhost', () => {
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
});
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 1000);
});
await new Promise(resolve => setTimeout(resolve, 500));
const afterCounts = getConnectionCounts();
console.log(`After: Proxy1=${afterCounts.proxy1}, Proxy2=${afterCounts.proxy2}`);
}
// Final check
console.log('\n--- Final Check ---');
await new Promise(resolve => setTimeout(resolve, 1000));
const finalCounts = getConnectionCounts();
console.log(`Final counts: Proxy1=${finalCounts.proxy1}, Proxy2=${finalCounts.proxy2}`);
await proxy1.stop();
await proxy2.stop();
// Verify
if (finalCounts.proxy1 > 0 || finalCounts.proxy2 > 0) {
console.log('\n❌ FAIL: Connections accumulated!');
} else {
console.log('\n✅ PASS: No connection accumulation');
}
expect(finalCounts.proxy1).toEqual(0);
expect(finalCounts.proxy2).toEqual(0);
});
tap.start();

View File

@ -0,0 +1,368 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as plugins from '../ts/plugins.js';
// Import SmartProxy and configurations
import { SmartProxy } from '../ts/index.js';
tap.test('should handle proxy chaining without connection accumulation', async () => {
console.log('\n=== Testing Proxy Chaining Connection Accumulation ===');
console.log('Setup: Client → SmartProxy1 → SmartProxy2 → Backend (down)');
// Create SmartProxy2 (downstream proxy)
const proxy2 = new SmartProxy({
ports: [8581],
enableDetailedLogging: false,
socketTimeout: 5000,
routes: [{
name: 'backend-route',
match: { ports: 8581 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 9999 // Non-existent backend
}
}
}]
});
// Create SmartProxy1 (upstream proxy)
const proxy1 = new SmartProxy({
ports: [8580],
enableDetailedLogging: false,
socketTimeout: 5000,
routes: [{
name: 'chain-route',
match: { ports: 8580 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 8581 // Forward to proxy2
}
}
}]
});
// Start both proxies
await proxy2.start();
console.log('✓ SmartProxy2 started on port 8581');
await proxy1.start();
console.log('✓ SmartProxy1 started on port 8580');
// Helper to get connection counts
const getConnectionCounts = () => {
const conn1 = (proxy1 as any).connectionManager;
const conn2 = (proxy2 as any).connectionManager;
return {
proxy1: conn1 ? conn1.getConnectionCount() : 0,
proxy2: conn2 ? conn2.getConnectionCount() : 0
};
};
const initialCounts = getConnectionCounts();
console.log(`\nInitial connection counts - Proxy1: ${initialCounts.proxy1}, Proxy2: ${initialCounts.proxy2}`);
// Test 1: Single connection attempt
console.log('\n--- Test 1: Single connection through chain ---');
await new Promise<void>((resolve) => {
const client = new net.Socket();
client.on('error', (err) => {
console.log(`Client received error: ${err.code}`);
resolve();
});
client.on('close', () => {
console.log('Client connection closed');
resolve();
});
client.connect(8580, 'localhost', () => {
console.log('Client connected to Proxy1');
// Send data to trigger routing
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
});
// Timeout
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 1000);
});
// Check connections after single attempt
await new Promise(resolve => setTimeout(resolve, 500));
let counts = getConnectionCounts();
console.log(`After single connection - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
// Test 2: Multiple simultaneous connections
console.log('\n--- Test 2: Multiple simultaneous connections ---');
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(new Promise<void>((resolve) => {
const client = new net.Socket();
client.on('error', () => {
resolve();
});
client.on('close', () => {
resolve();
});
client.connect(8580, 'localhost', () => {
// Send data
client.write(`GET /test${i} HTTP/1.1\r\nHost: test.com\r\n\r\n`);
});
// Timeout
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 500);
}));
}
await Promise.all(promises);
console.log('✓ All simultaneous connections completed');
// Check connections
counts = getConnectionCounts();
console.log(`After simultaneous connections - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
// Test 3: Rapid serial connections (simulating retries)
console.log('\n--- Test 3: Rapid serial connections (retries) ---');
for (let i = 0; i < 20; i++) {
await new Promise<void>((resolve) => {
const client = new net.Socket();
client.on('error', () => {
resolve();
});
client.on('close', () => {
resolve();
});
client.connect(8580, 'localhost', () => {
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
// Quick disconnect to simulate retry behavior
setTimeout(() => client.destroy(), 50);
});
// Timeout
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 200);
});
if ((i + 1) % 5 === 0) {
counts = getConnectionCounts();
console.log(`After ${i + 1} retries - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
}
// Small delay between retries
await new Promise(resolve => setTimeout(resolve, 50));
}
// Test 4: Long-lived connection attempt
console.log('\n--- Test 4: Long-lived connection attempt ---');
await new Promise<void>((resolve) => {
const client = new net.Socket();
client.on('error', () => {
resolve();
});
client.on('close', () => {
console.log('Long-lived client closed');
resolve();
});
client.connect(8580, 'localhost', () => {
console.log('Long-lived client connected');
// Send data periodically
const interval = setInterval(() => {
if (!client.destroyed && client.writable) {
client.write('PING\r\n');
} else {
clearInterval(interval);
}
}, 100);
// Close after 2 seconds
setTimeout(() => {
clearInterval(interval);
client.destroy();
}, 2000);
});
// Timeout
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 3000);
});
// Final check
await new Promise(resolve => setTimeout(resolve, 1000));
const finalCounts = getConnectionCounts();
console.log(`\nFinal connection counts - Proxy1: ${finalCounts.proxy1}, Proxy2: ${finalCounts.proxy2}`);
// Monitor for a bit to see if connections are cleaned up
console.log('\nMonitoring connection cleanup...');
for (let i = 0; i < 3; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
counts = getConnectionCounts();
console.log(`After ${(i + 1) * 0.5}s - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
}
// Stop proxies
await proxy1.stop();
console.log('\n✓ SmartProxy1 stopped');
await proxy2.stop();
console.log('✓ SmartProxy2 stopped');
// Analysis
console.log('\n=== Analysis ===');
if (finalCounts.proxy1 > 0 || finalCounts.proxy2 > 0) {
console.log('❌ FAIL: Connections accumulated!');
console.log(`Proxy1 leaked ${finalCounts.proxy1} connections`);
console.log(`Proxy2 leaked ${finalCounts.proxy2} connections`);
} else {
console.log('✅ PASS: No connection accumulation detected');
}
// Verify
expect(finalCounts.proxy1).toEqual(0);
expect(finalCounts.proxy2).toEqual(0);
});
tap.test('should handle proxy chain with HTTP traffic', async () => {
console.log('\n=== Testing Proxy Chain with HTTP Traffic ===');
// Create SmartProxy2 with HTTP handling
const proxy2 = new SmartProxy({
ports: [8583],
useHttpProxy: [8583], // Enable HTTP proxy handling
httpProxyPort: 8584,
enableDetailedLogging: false,
routes: [{
name: 'http-backend',
match: { ports: 8583 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 9999 // Non-existent backend
}
}
}]
});
// Create SmartProxy1 with HTTP handling
const proxy1 = new SmartProxy({
ports: [8582],
useHttpProxy: [8582], // Enable HTTP proxy handling
httpProxyPort: 8585,
enableDetailedLogging: false,
routes: [{
name: 'http-chain',
match: { ports: 8582 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 8583 // Forward to proxy2
}
}
}]
});
await proxy2.start();
console.log('✓ SmartProxy2 (HTTP) started on port 8583');
await proxy1.start();
console.log('✓ SmartProxy1 (HTTP) started on port 8582');
// Helper to get connection counts
const getConnectionCounts = () => {
const conn1 = (proxy1 as any).connectionManager;
const conn2 = (proxy2 as any).connectionManager;
return {
proxy1: conn1 ? conn1.getConnectionCount() : 0,
proxy2: conn2 ? conn2.getConnectionCount() : 0
};
};
console.log('\nSending HTTP requests through chain...');
// Make HTTP requests
for (let i = 0; i < 5; i++) {
await new Promise<void>((resolve) => {
const client = new net.Socket();
let responseData = '';
client.on('data', (data) => {
responseData += data.toString();
// Check if we got a complete HTTP response
if (responseData.includes('\r\n\r\n')) {
console.log(`Response ${i + 1}: ${responseData.split('\r\n')[0]}`);
client.destroy();
}
});
client.on('error', () => {
resolve();
});
client.on('close', () => {
resolve();
});
client.connect(8582, 'localhost', () => {
client.write(`GET /test${i} HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n`);
});
setTimeout(() => {
if (!client.destroyed) {
client.destroy();
}
resolve();
}, 1000);
});
await new Promise(resolve => setTimeout(resolve, 100));
}
await new Promise(resolve => setTimeout(resolve, 1000));
const finalCounts = getConnectionCounts();
console.log(`\nFinal HTTP proxy counts - Proxy1: ${finalCounts.proxy1}, Proxy2: ${finalCounts.proxy2}`);
await proxy1.stop();
await proxy2.stop();
expect(finalCounts.proxy1).toEqual(0);
expect(finalCounts.proxy2).toEqual(0);
});
tap.start();

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '19.5.3',
version: '19.5.19',
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

@ -67,37 +67,6 @@ export function cleanupSocket(
});
}
/**
* Create a cleanup handler for paired sockets (client and server)
* @param clientSocket The client socket
* @param serverSocket The server socket (optional)
* @param onCleanup Optional callback when cleanup is done
* @returns A cleanup function that can be called multiple times safely
* @deprecated Use createIndependentSocketHandlers for better half-open support
*/
export function createSocketCleanupHandler(
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
serverSocket?: plugins.net.Socket | plugins.tls.TLSSocket | null,
onCleanup?: (reason: string) => void
): (reason: string) => void {
let cleanedUp = false;
return (reason: string) => {
if (cleanedUp) return;
cleanedUp = true;
// Cleanup both sockets (old behavior - too aggressive)
cleanupSocket(clientSocket, 'client', { immediate: true });
if (serverSocket) {
cleanupSocket(serverSocket, 'server', { immediate: true });
}
// Call cleanup callback if provided
if (onCleanup) {
onCleanup(reason);
}
};
}
/**
* Create independent cleanup handlers for paired sockets that support half-open connections
@ -109,7 +78,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 +97,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 +120,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);
@ -195,16 +175,76 @@ export function setupSocketHandlers(
}
/**
* Pipe two sockets together with proper cleanup on either end
* @param socket1 First socket
* @param socket2 Second socket
* 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 pipeSockets(
socket1: plugins.net.Socket | plugins.tls.TLSSocket,
socket2: plugins.net.Socket | plugins.tls.TLSSocket
): void {
socket1.pipe(socket2);
socket2.pipe(socket1);
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 };
}
/**

View File

@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { createSocketCleanupHandler, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTP backend
@ -100,19 +100,30 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
let backendSocket: plugins.net.Socket | null = null;
let dataBuffer = Buffer.alloc(0);
let connectionEstablished = false;
let forwardingSetup = false;
// Create cleanup handler for all sockets
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
});
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!forwardingSetup) {
// If forwarding not set up yet, emit disconnected and cleanup
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
if (backendSocket && !backendSocket.destroyed) {
backendSocket.destroy();
}
}
// If forwarding is setup, setupBidirectionalForwarding will handle cleanup
};
// Set up error handling with our cleanup utility
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
@ -123,7 +134,7 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
remoteAddress,
error: 'TLS connection timeout'
});
handleClose('timeout');
tlsCleanupHandler('timeout');
});
// Handle TLS data
@ -172,30 +183,33 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
dataBuffer = Buffer.alloc(0);
}
// Set up bidirectional data flow
tlsSocket.pipe(backendSocket!);
backendSocket!.pipe(tlsSocket);
// Now set up bidirectional forwarding with proper cleanup
forwardingSetup = true;
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
forwardingSetup = false;
},
enableHalfOpen: false // Close both when one closes
});
}
});
// Update the cleanup handler with the backend socket
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
});
// Set up handlers for backend socket
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
// Additional error logging for backend socket
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
if (!connectionEstablished) {
// Connection failed during setup
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
}
// If connected, setupBidirectionalForwarding handles cleanup
});
}
});

View File

@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { createSocketCleanupHandler, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTPS backend
@ -96,17 +96,26 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
// Variable to track backend socket
let backendSocket: plugins.tls.TLSSocket | null = null;
let isConnectedToBackend = false;
// Create cleanup handler for both sockets
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
});
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!isConnectedToBackend) {
// If backend not connected yet, just emit disconnected event
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
// Cleanup TLS socket if needed
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
}
// If connected to backend, setupBidirectionalForwarding will handle cleanup
};
// Set up error handling with our cleanup utility
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
@ -117,7 +126,7 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
remoteAddress,
error: 'TLS connection timeout'
});
handleClose('timeout');
tlsCleanupHandler('timeout');
});
// Get the target from configuration
@ -131,44 +140,55 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
}, () => {
isConnectedToBackend = true;
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
target: `${target.host}:${target.port}`,
tls: true
});
// Set up bidirectional data flow
tlsSocket.pipe(backendSocket!);
backendSocket!.pipe(tlsSocket);
});
// Update the cleanup handler with the backend socket
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
// Set up bidirectional forwarding with proper cleanup
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
},
enableHalfOpen: false // Close both when one closes
});
// Set timeout for backend socket
backendSocket!.setTimeout(timeout);
backendSocket!.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
// Let setupBidirectionalForwarding handle the cleanup
});
});
// Set up handlers for backend socket
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
// Handle backend connection errors
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Backend connection error: ${error.message}`
});
});
// Set timeout for backend socket
backendSocket.setTimeout(timeout);
backendSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
newHandleClose('backend_timeout');
if (!isConnectedToBackend) {
// Connection failed, clean up TLS socket
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
}
// If connected, let setupBidirectionalForwarding handle cleanup
});
};

View File

@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { HttpProxy } from '../http-proxy/index.js';
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
@ -123,36 +124,25 @@ export class HttpProxyBridge {
proxySocket.write(initialChunk);
}
// Pipe the sockets together
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Handle cleanup
let cleanedUp = false;
const cleanup = (reason: string) => {
if (cleanedUp) return;
cleanedUp = true;
// Remove all event listeners to prevent memory leaks
socket.removeAllListeners('end');
socket.removeAllListeners('error');
proxySocket.removeAllListeners('end');
proxySocket.removeAllListeners('error');
socket.unpipe(proxySocket);
proxySocket.unpipe(socket);
if (!proxySocket.destroyed) {
proxySocket.destroy();
}
cleanupCallback(reason);
};
socket.on('end', () => cleanup('socket_end'));
socket.on('error', () => cleanup('socket_error'));
proxySocket.on('end', () => cleanup('proxy_end'));
proxySocket.on('error', () => cleanup('proxy_error'));
// Use centralized bidirectional forwarding
setupBidirectionalForwarding(socket, proxySocket, {
onClientData: (chunk) => {
// Update stats if needed
if (record) {
record.bytesReceived += chunk.length;
}
},
onServerData: (chunk) => {
// Update stats if needed
if (record) {
record.bytesSent += chunk.length;
}
},
onCleanup: (reason) => {
cleanupCallback(reason);
},
enableHalfOpen: false // Close both when one closes (required for proxy chains)
});
}
/**

View File

@ -9,7 +9,7 @@ import { TlsManager } from './tls-manager.js';
import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js';
import { RouteManager } from './route-manager.js';
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handles new connection processing and setup logic with support for route-based configuration
@ -1137,65 +1137,27 @@ export class RouteConnectionHandler {
record.pendingDataSize = 0;
}
// Set up independent socket handlers for half-open connection support
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
socket,
targetSocket,
(reason) => {
// Use centralized bidirectional forwarding setup
setupBidirectionalForwarding(socket, targetSocket, {
onClientData: (chunk) => {
record.bytesReceived += chunk.length;
this.timeoutManager.updateActivity(record);
},
onServerData: (chunk) => {
record.bytesSent += chunk.length;
this.timeoutManager.updateActivity(record);
},
onCleanup: (reason) => {
this.connectionManager.cleanupConnection(record, reason);
}
);
// 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) => {
record.bytesReceived += chunk.length;
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();
});
}
}
},
enableHalfOpen: false // Default: close both when one closes (required for proxy chains)
});
// Apply timeouts if keep-alive is enabled
if (record.hasKeepAlive) {
socket.setTimeout(this.settings.socketTimeout || 3600000);
targetSocket.setTimeout(this.settings.socketTimeout || 3600000);
}
// Log successful connection
logger.log('info',
@ -1354,11 +1316,5 @@ export class RouteConnectionHandler {
// Apply socket timeouts
this.timeoutManager.applySocketTimeouts(record);
// Track outgoing data for bytes counting (moved from the duplicate connect handler)
targetSocket.on('data', (chunk: Buffer) => {
record.bytesSent += chunk.length;
this.timeoutManager.updateActivity(record);
});
}
}