Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
fa9166be4b | |||
c5efee3bfe | |||
47508eb1eb | |||
fb147148ef | |||
07f5ceddc4 | |||
3ac3345be8 | |||
5b40e82c41 | |||
2a75a86d73 | |||
250eafd36c | |||
facb68a9d0 | |||
23898c1577 | |||
2d240671ab | |||
705a59413d |
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2025-05-29 - 19.5.3 - fix(smartproxy)
|
||||||
Fix route security configuration location and improve ACME timing tests and socket mock implementations
|
Fix route security configuration location and improve ACME timing tests and socket mock implementations
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "19.5.12",
|
"version": "19.5.19",
|
||||||
"private": false,
|
"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.",
|
"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",
|
"main": "dist_ts/index.js",
|
||||||
|
199
readme.hints.md
199
readme.hints.md
@ -464,4 +464,201 @@ The fix was applied in two places:
|
|||||||
1. **ForwardingHandler classes** (`https-passthrough-handler.ts`, etc.) - These are standalone forwarding utilities
|
1. **ForwardingHandler classes** (`https-passthrough-handler.ts`, etc.) - These are standalone forwarding utilities
|
||||||
2. **SmartProxy route-connection-handler** (`route-connection-handler.ts`) - This is where the actual SmartProxy connection handling happens
|
2. **SmartProxy route-connection-handler** (`route-connection-handler.ts`) - This is where the actual SmartProxy connection handling happens
|
||||||
|
|
||||||
The critical fix for SmartProxy was in `setupDirectConnection()` method in route-connection-handler.ts, which now uses `createSocketWithErrorHandler()` to properly handle connection failures and clean up connection records.
|
The critical fix for SmartProxy was in `setupDirectConnection()` method in route-connection-handler.ts, which now uses `createSocketWithErrorHandler()` to properly handle connection failures and clean up connection records.
|
||||||
|
|
||||||
|
## Connection Cleanup Improvements (v19.5.12+)
|
||||||
|
|
||||||
|
### Issue
|
||||||
|
Connections were still counting up during rapid retry scenarios, especially when routing failed or backend connections were refused. This was due to:
|
||||||
|
1. **Delayed Cleanup**: Using `initiateCleanupOnce` queued cleanup operations (batch of 100 every 100ms) instead of immediate cleanup
|
||||||
|
2. **NFTables Memory Leak**: NFTables connections were never cleaned up, staying in memory forever
|
||||||
|
3. **Connection Limit Bypass**: When max connections reached, connection record check happened after creation
|
||||||
|
|
||||||
|
### Root Cause Analysis
|
||||||
|
1. **Queued vs Immediate Cleanup**:
|
||||||
|
- `initiateCleanupOnce()`: Adds to cleanup queue, processes up to 100 connections every 100ms
|
||||||
|
- `cleanupConnection()`: Immediate synchronous cleanup
|
||||||
|
- Under rapid retries, connections were created faster than the queue could process them
|
||||||
|
|
||||||
|
2. **NFTables Connections**:
|
||||||
|
- Marked with `usingNetworkProxy = true` but never cleaned up
|
||||||
|
- Connection records stayed in memory indefinitely
|
||||||
|
|
||||||
|
3. **Error Path Cleanup**:
|
||||||
|
- Many error paths used `socket.end()` (async) followed by cleanup
|
||||||
|
- Created timing windows where connections weren't fully cleaned
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
1. **Immediate Cleanup**: Changed all error paths from `initiateCleanupOnce()` to `cleanupConnection()` for immediate cleanup
|
||||||
|
2. **NFTables Cleanup**: Added socket close listener to clean up connection records when NFTables connections close
|
||||||
|
3. **Connection Limit Fix**: Added null check after `createConnection()` to handle rejection properly
|
||||||
|
|
||||||
|
### Changes Made in route-connection-handler.ts
|
||||||
|
```typescript
|
||||||
|
// 1. NFTables cleanup (line 551-553)
|
||||||
|
socket.once('close', () => {
|
||||||
|
this.connectionManager.cleanupConnection(record, 'nftables_closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Connection limit check (line 93-96)
|
||||||
|
const record = this.connectionManager.createConnection(socket);
|
||||||
|
if (!record) {
|
||||||
|
// Connection was rejected due to limit - socket already destroyed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Changed all error paths to use immediate cleanup
|
||||||
|
// Before: this.connectionManager.initiateCleanupOnce(record, reason)
|
||||||
|
// After: this.connectionManager.cleanupConnection(record, reason)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- `test/test.rapid-retry-cleanup.node.ts` - Verifies connection cleanup under rapid retry scenarios
|
||||||
|
- Test shows connection count stays at 0 even with 20 rapid retries with 50ms intervals
|
||||||
|
- Confirms both ECONNREFUSED and routing failure scenarios are handled correctly
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
- **Positive**: No more connection accumulation under load
|
||||||
|
- **Positive**: Immediate cleanup reduces memory usage
|
||||||
|
- **Consideration**: More frequent cleanup operations, but prevents queue backlog
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
No configuration changes needed. The improvements are automatic and backward compatible.
|
||||||
|
|
||||||
|
## Early Client Disconnect Handling (v19.5.13+)
|
||||||
|
|
||||||
|
### Issue
|
||||||
|
Connections were accumulating when clients connected but disconnected before sending data or during routing. This occurred in two scenarios:
|
||||||
|
1. **TLS Path**: Clients connecting and disconnecting before sending initial TLS handshake data
|
||||||
|
2. **Non-TLS Immediate Routing**: Clients disconnecting while backend connection was being established
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
1. **Missing Cleanup Handlers**: During initial data wait and immediate routing, no close/end handlers were attached to catch early disconnections
|
||||||
|
2. **Race Condition**: Backend connection attempts continued even after client disconnected, causing unhandled errors
|
||||||
|
3. **Timing Window**: Between accepting connection and establishing full bidirectional flow, disconnections weren't properly handled
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
1. **TLS Path Fix**: Added close/end handlers during initial data wait (lines 224-253 in route-connection-handler.ts)
|
||||||
|
2. **Immediate Routing Fix**: Used `setupSocketHandlers` for proper handler attachment (lines 180-205)
|
||||||
|
3. **Backend Error Handling**: Check if connection already closed before handling backend errors (line 1144)
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
```typescript
|
||||||
|
// 1. TLS path - handle disconnect before initial data
|
||||||
|
socket.once('close', () => {
|
||||||
|
if (!initialDataReceived) {
|
||||||
|
this.connectionManager.cleanupConnection(record, 'closed_before_data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Immediate routing path - proper handler setup
|
||||||
|
setupSocketHandlers(socket, (reason) => {
|
||||||
|
if (!record.outgoing || record.outgoing.readyState !== 'open') {
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy(); // Abort pending backend connection
|
||||||
|
}
|
||||||
|
this.connectionManager.cleanupConnection(record, reason);
|
||||||
|
}
|
||||||
|
}, undefined, 'immediate-route-client');
|
||||||
|
|
||||||
|
// 3. Backend connection error handling
|
||||||
|
onError: (error) => {
|
||||||
|
if (record.connectionClosed) {
|
||||||
|
logger.log('debug', 'Backend connection failed but client already disconnected');
|
||||||
|
return; // Client already gone, nothing to clean up
|
||||||
|
}
|
||||||
|
// ... normal error handling
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- `test/test.connect-disconnect-cleanup.node.ts` - Comprehensive test for early disconnect scenarios
|
||||||
|
- Tests verify connection count stays at 0 even with rapid connect/disconnect patterns
|
||||||
|
- Covers immediate disconnect, delayed disconnect, and mixed patterns
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
- **Positive**: No more connection accumulation from early disconnects
|
||||||
|
- **Positive**: Immediate cleanup reduces memory usage
|
||||||
|
- **Positive**: Prevents resource exhaustion from rapid reconnection attempts
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
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.
|
165
readme.plan.md
165
readme.plan.md
@ -1,165 +0,0 @@
|
|||||||
# SmartProxy Socket Handling Fix Plan
|
|
||||||
|
|
||||||
Reread CLAUDE.md file for guidelines
|
|
||||||
|
|
||||||
## Implementation Summary (COMPLETED)
|
|
||||||
|
|
||||||
The critical socket handling issues have been fixed:
|
|
||||||
|
|
||||||
1. **Prevented Server Crashes**: Created `createSocketWithErrorHandler()` utility that attaches error handlers immediately upon socket creation, preventing unhandled ECONNREFUSED errors from crashing the server.
|
|
||||||
|
|
||||||
2. **Fixed Memory Leaks**: Updated forwarding handlers to properly clean up client sockets when server connections fail, ensuring connection records are removed from tracking.
|
|
||||||
|
|
||||||
3. **Key Changes Made**:
|
|
||||||
- Added `createSocketWithErrorHandler()` in `socket-utils.ts`
|
|
||||||
- Updated `https-passthrough-handler.ts` to use safe socket creation
|
|
||||||
- Updated `https-terminate-to-http-handler.ts` to use safe socket creation
|
|
||||||
- Ensured client sockets are destroyed when server connections fail
|
|
||||||
- Connection cleanup now triggered by socket close events
|
|
||||||
|
|
||||||
4. **Test Results**: Server no longer crashes on ECONNREFUSED errors, and connections are properly cleaned up.
|
|
||||||
|
|
||||||
## Problem Summary
|
|
||||||
|
|
||||||
The SmartProxy server is experiencing critical issues:
|
|
||||||
1. **Server crashes** due to unhandled socket connection errors (ECONNREFUSED)
|
|
||||||
2. **Memory leak** with steadily rising active connection count
|
|
||||||
3. **Race conditions** between socket creation and error handler attachment
|
|
||||||
4. **Orphaned sockets** when server connections fail
|
|
||||||
|
|
||||||
## Root Causes
|
|
||||||
|
|
||||||
### 1. Delayed Error Handler Attachment
|
|
||||||
- Sockets created without immediate error handlers
|
|
||||||
- Error events can fire before handlers attached
|
|
||||||
- Causes uncaught exceptions and server crashes
|
|
||||||
|
|
||||||
### 2. Incomplete Cleanup Logic
|
|
||||||
- Client sockets not cleaned up when server connection fails
|
|
||||||
- Connection counter only decrements after BOTH sockets close
|
|
||||||
- Failed server connections leave orphaned client sockets
|
|
||||||
|
|
||||||
### 3. Missing Global Error Handlers
|
|
||||||
- No process-level uncaughtException handler
|
|
||||||
- No process-level unhandledRejection handler
|
|
||||||
- Any unhandled error crashes entire server
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Prevent Server Crashes (Critical)
|
|
||||||
|
|
||||||
#### 1.1 Add Global Error Handlers
|
|
||||||
- [x] ~~Add global error handlers in main entry point~~ (Removed per user request - no global handlers)
|
|
||||||
- [x] Log errors with context
|
|
||||||
- [x] ~~Implement graceful shutdown sequence~~ (Removed - handled locally)
|
|
||||||
|
|
||||||
#### 1.2 Fix Socket Creation Race Condition
|
|
||||||
- [x] Modify socket creation to attach error handlers immediately
|
|
||||||
- [x] Update all forwarding handlers (https-passthrough, http, etc.)
|
|
||||||
- [x] Ensure error handlers attached in same tick as socket creation
|
|
||||||
|
|
||||||
### Phase 2: Fix Memory Leaks (High Priority)
|
|
||||||
|
|
||||||
#### 2.1 Fix Connection Cleanup Logic
|
|
||||||
- [x] Clean up client socket immediately if server connection fails
|
|
||||||
- [x] Decrement connection counter on any socket failure (handled by socket close events)
|
|
||||||
- [x] Implement proper cleanup for half-open connections
|
|
||||||
|
|
||||||
#### 2.2 Improve Socket Utils
|
|
||||||
- [x] Create new utility function for safe socket creation with immediate error handling
|
|
||||||
- [x] Update createIndependentSocketHandlers to handle immediate failures
|
|
||||||
- [ ] Add connection tracking debug utilities
|
|
||||||
|
|
||||||
### Phase 3: Comprehensive Testing (Important)
|
|
||||||
|
|
||||||
#### 3.1 Create Test Cases
|
|
||||||
- [x] Test ECONNREFUSED scenario
|
|
||||||
- [ ] Test timeout handling
|
|
||||||
- [ ] Test half-open connections
|
|
||||||
- [ ] Test rapid connect/disconnect cycles
|
|
||||||
|
|
||||||
#### 3.2 Add Monitoring
|
|
||||||
- [ ] Add connection leak detection
|
|
||||||
- [ ] Add metrics for connection lifecycle
|
|
||||||
- [ ] Add debug logging for socket state transitions
|
|
||||||
|
|
||||||
## Detailed Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Global Error Handlers (ts/proxies/smart-proxy/smart-proxy.ts)
|
|
||||||
```typescript
|
|
||||||
// Add in constructor or start method
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
logger.log('error', 'Uncaught exception', { error });
|
|
||||||
// Graceful shutdown
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
logger.log('error', 'Unhandled rejection', { reason, promise });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Safe Socket Creation Utility (ts/core/utils/socket-utils.ts)
|
|
||||||
```typescript
|
|
||||||
export function createSocketWithErrorHandler(
|
|
||||||
options: net.NetConnectOpts,
|
|
||||||
onError: (err: Error) => void
|
|
||||||
): net.Socket {
|
|
||||||
const socket = net.connect(options);
|
|
||||||
socket.on('error', onError);
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Fix HttpsPassthroughHandler (ts/forwarding/handlers/https-passthrough-handler.ts)
|
|
||||||
- Replace direct socket creation with safe creation
|
|
||||||
- Handle server connection failures immediately
|
|
||||||
- Clean up client socket on server connection failure
|
|
||||||
|
|
||||||
### Step 4: Fix Connection Counting
|
|
||||||
- Decrement on ANY socket close, not just when both close
|
|
||||||
- Track failed connections separately
|
|
||||||
- Add connection state tracking
|
|
||||||
|
|
||||||
### Step 5: Update All Handlers
|
|
||||||
- [ ] https-passthrough-handler.ts
|
|
||||||
- [ ] http-handler.ts
|
|
||||||
- [ ] https-terminate-to-http-handler.ts
|
|
||||||
- [ ] https-terminate-to-https-handler.ts
|
|
||||||
- [ ] route-connection-handler.ts
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
1. **No server crashes** on ECONNREFUSED or other socket errors
|
|
||||||
2. **Active connections** remain stable (no steady increase)
|
|
||||||
3. **All sockets** properly cleaned up on errors
|
|
||||||
4. **Memory usage** remains stable under load
|
|
||||||
5. **Graceful handling** of all error scenarios
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
1. Simulate ECONNREFUSED by targeting closed ports
|
|
||||||
2. Monitor active connection count over time
|
|
||||||
3. Stress test with rapid connections
|
|
||||||
4. Test with unreachable hosts
|
|
||||||
5. Test with slow/timing out connections
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If issues arise:
|
|
||||||
1. Revert socket creation changes
|
|
||||||
2. Keep global error handlers (they add safety)
|
|
||||||
3. Add more detailed logging for debugging
|
|
||||||
4. Implement fixes incrementally
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
- Phase 1: Immediate (prevents crashes)
|
|
||||||
- Phase 2: Within 24 hours (fixes leaks)
|
|
||||||
- Phase 3: Within 48 hours (ensures stability)
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The race condition is the most critical issue
|
|
||||||
- Connection counting logic needs complete overhaul
|
|
||||||
- Consider using a connection state machine for clarity
|
|
||||||
- Add connection lifecycle events for debugging
|
|
242
test/test.connect-disconnect-cleanup.node.ts
Normal file
242
test/test.connect-disconnect-cleanup.node.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
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 clients that connect and immediately disconnect without sending data', async () => {
|
||||||
|
console.log('\n=== Testing Connect-Disconnect Cleanup ===');
|
||||||
|
|
||||||
|
// Create a SmartProxy instance
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
ports: [8560],
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
initialDataTimeout: 5000, // 5 second timeout for initial data
|
||||||
|
routes: [{
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 8560 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999 // Non-existent port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the proxy
|
||||||
|
await proxy.start();
|
||||||
|
console.log('✓ Proxy started on port 8560');
|
||||||
|
|
||||||
|
// Helper to get active connection count
|
||||||
|
const getActiveConnections = () => {
|
||||||
|
const connectionManager = (proxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCount = getActiveConnections();
|
||||||
|
console.log(`Initial connection count: ${initialCount}`);
|
||||||
|
|
||||||
|
// Test 1: Connect and immediately disconnect without sending data
|
||||||
|
console.log('\n--- Test 1: Immediate disconnect ---');
|
||||||
|
const connectionCounts: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
// Connect and immediately destroy
|
||||||
|
client.connect(8560, 'localhost', () => {
|
||||||
|
// Connected - immediately destroy without sending data
|
||||||
|
client.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a tiny bit
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const count = getActiveConnections();
|
||||||
|
connectionCounts.push(count);
|
||||||
|
if ((i + 1) % 5 === 0) {
|
||||||
|
console.log(`After ${i + 1} connect/disconnect cycles: ${count} active connections`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit for cleanup
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const afterImmediateDisconnect = getActiveConnections();
|
||||||
|
console.log(`After immediate disconnect test: ${afterImmediateDisconnect} active connections`);
|
||||||
|
|
||||||
|
// Test 2: Connect, wait a bit, then disconnect without sending data
|
||||||
|
console.log('\n--- Test 2: Delayed disconnect ---');
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
// Ignore errors
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8560, 'localhost', () => {
|
||||||
|
// Wait 100ms then disconnect without sending data
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check count immediately
|
||||||
|
const duringDelayed = getActiveConnections();
|
||||||
|
console.log(`During delayed disconnect test: ${duringDelayed} active connections`);
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const afterDelayedDisconnect = getActiveConnections();
|
||||||
|
console.log(`After delayed disconnect test: ${afterDelayedDisconnect} active connections`);
|
||||||
|
|
||||||
|
// Test 3: Mix of immediate and delayed disconnects
|
||||||
|
console.log('\n--- Test 3: Mixed disconnect patterns ---');
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
promises.push(new Promise<void>((resolve) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8560, 'localhost', () => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
// Half disconnect immediately
|
||||||
|
client.destroy();
|
||||||
|
} else {
|
||||||
|
// Half wait 50ms
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Failsafe timeout
|
||||||
|
setTimeout(() => resolve(), 200);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const duringMixed = getActiveConnections();
|
||||||
|
console.log(`During mixed test: ${duringMixed} active connections`);
|
||||||
|
|
||||||
|
// Final cleanup wait
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const finalCount = getActiveConnections();
|
||||||
|
console.log(`\nFinal connection count: ${finalCount}`);
|
||||||
|
|
||||||
|
// Stop the proxy
|
||||||
|
await proxy.stop();
|
||||||
|
console.log('✓ Proxy stopped');
|
||||||
|
|
||||||
|
// Verify all connections were cleaned up
|
||||||
|
expect(finalCount).toEqual(initialCount);
|
||||||
|
expect(afterImmediateDisconnect).toEqual(initialCount);
|
||||||
|
expect(afterDelayedDisconnect).toEqual(initialCount);
|
||||||
|
|
||||||
|
// Check that connections didn't accumulate during the test
|
||||||
|
const maxCount = Math.max(...connectionCounts);
|
||||||
|
console.log(`\nMax connection count during immediate disconnect test: ${maxCount}`);
|
||||||
|
expect(maxCount).toBeLessThan(3); // Should stay very low
|
||||||
|
|
||||||
|
console.log('\n✅ PASS: Connect-disconnect cleanup working correctly!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle clients that error during connection', async () => {
|
||||||
|
console.log('\n=== Testing Connection Error Cleanup ===');
|
||||||
|
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
ports: [8561],
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
routes: [{
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 8561 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
await proxy.start();
|
||||||
|
console.log('✓ Proxy started on port 8561');
|
||||||
|
|
||||||
|
const getActiveConnections = () => {
|
||||||
|
const connectionManager = (proxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCount = getActiveConnections();
|
||||||
|
console.log(`Initial connection count: ${initialCount}`);
|
||||||
|
|
||||||
|
// Create connections that will error
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to proxy
|
||||||
|
client.connect(8561, 'localhost', () => {
|
||||||
|
// Force an error by writing invalid data then destroying
|
||||||
|
try {
|
||||||
|
client.write(Buffer.alloc(1024 * 1024)); // Large write
|
||||||
|
client.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
setTimeout(() => resolve(), 500);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log('✓ All error connections completed');
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const finalCount = getActiveConnections();
|
||||||
|
console.log(`Final connection count: ${finalCount}`);
|
||||||
|
|
||||||
|
await proxy.stop();
|
||||||
|
console.log('✓ Proxy stopped');
|
||||||
|
|
||||||
|
expect(finalCount).toEqual(initialCount);
|
||||||
|
|
||||||
|
console.log('\n✅ PASS: Connection error cleanup working correctly!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
279
test/test.connection-cleanup-comprehensive.node.ts
Normal file
279
test/test.connection-cleanup-comprehensive.node.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
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('comprehensive connection cleanup test - all scenarios', async () => {
|
||||||
|
console.log('\n=== Comprehensive Connection Cleanup Test ===');
|
||||||
|
|
||||||
|
// Create a SmartProxy instance
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
ports: [8570, 8571], // One for immediate routing, one for TLS
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
initialDataTimeout: 2000,
|
||||||
|
socketTimeout: 5000,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'non-tls-route',
|
||||||
|
match: { ports: 8570 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999 // Non-existent port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tls-route',
|
||||||
|
match: { ports: 8571 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999 // Non-existent port
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
mode: 'passthrough'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the proxy
|
||||||
|
await proxy.start();
|
||||||
|
console.log('✓ Proxy started on ports 8570 (non-TLS) and 8571 (TLS)');
|
||||||
|
|
||||||
|
// Helper to get active connection count
|
||||||
|
const getActiveConnections = () => {
|
||||||
|
const connectionManager = (proxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCount = getActiveConnections();
|
||||||
|
console.log(`Initial connection count: ${initialCount}`);
|
||||||
|
|
||||||
|
// Test 1: Rapid ECONNREFUSED retries (from original issue)
|
||||||
|
console.log('\n--- Test 1: Rapid ECONNREFUSED retries ---');
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8570, 'localhost', () => {
|
||||||
|
// Send data to trigger routing
|
||||||
|
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((i + 1) % 5 === 0) {
|
||||||
|
const count = getActiveConnections();
|
||||||
|
console.log(`After ${i + 1} ECONNREFUSED retries: ${count} active connections`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Connect without sending data (immediate disconnect)
|
||||||
|
console.log('\n--- Test 2: Connect without sending data ---');
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
// Ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to non-TLS port and immediately disconnect
|
||||||
|
client.connect(8570, 'localhost', () => {
|
||||||
|
client.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterNoData = getActiveConnections();
|
||||||
|
console.log(`After connect-without-data test: ${afterNoData} active connections`);
|
||||||
|
|
||||||
|
// Test 3: TLS connections that disconnect before handshake
|
||||||
|
console.log('\n--- Test 3: TLS early disconnect ---');
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
// Ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to TLS port but disconnect before sending handshake
|
||||||
|
client.connect(8571, 'localhost', () => {
|
||||||
|
// Wait 50ms then disconnect (before initial data timeout)
|
||||||
|
setTimeout(() => {
|
||||||
|
client.destroy();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterTlsEarly = getActiveConnections();
|
||||||
|
console.log(`After TLS early disconnect test: ${afterTlsEarly} active connections`);
|
||||||
|
|
||||||
|
// Test 4: Mixed pattern - simulating real-world chaos
|
||||||
|
console.log('\n--- Test 4: Mixed chaos pattern ---');
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
promises.push(new Promise<void>((resolve) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
const port = i % 2 === 0 ? 8570 : 8571;
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(port, 'localhost', () => {
|
||||||
|
const scenario = i % 5;
|
||||||
|
|
||||||
|
switch (scenario) {
|
||||||
|
case 0:
|
||||||
|
// Immediate disconnect
|
||||||
|
client.destroy();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
// Send data then disconnect
|
||||||
|
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||||
|
setTimeout(() => client.destroy(), 20);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
// Disconnect after delay
|
||||||
|
setTimeout(() => client.destroy(), 100);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
// Send partial TLS handshake
|
||||||
|
if (port === 8571) {
|
||||||
|
client.write(Buffer.from([0x16, 0x03, 0x01])); // Partial TLS
|
||||||
|
}
|
||||||
|
setTimeout(() => client.destroy(), 50);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
// Just let it timeout
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Failsafe
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 500);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Small delay between connections
|
||||||
|
if (i % 5 === 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log('✓ Chaos test completed');
|
||||||
|
|
||||||
|
// Wait for any cleanup
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const afterChaos = getActiveConnections();
|
||||||
|
console.log(`After chaos test: ${afterChaos} active connections`);
|
||||||
|
|
||||||
|
// Test 5: NFTables route (should cleanup properly)
|
||||||
|
console.log('\n--- Test 5: NFTables route cleanup ---');
|
||||||
|
const nftProxy = new SmartProxy({
|
||||||
|
ports: [8572],
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
routes: [{
|
||||||
|
name: 'nftables-route',
|
||||||
|
match: { ports: 8572 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forwardingEngine: 'nftables',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
await nftProxy.start();
|
||||||
|
|
||||||
|
const getNftConnections = () => {
|
||||||
|
const connectionManager = (nftProxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create NFTables connections
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
// Ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8572, 'localhost', () => {
|
||||||
|
setTimeout(() => client.destroy(), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const nftFinal = getNftConnections();
|
||||||
|
console.log(`NFTables connections after test: ${nftFinal}`);
|
||||||
|
|
||||||
|
await nftProxy.stop();
|
||||||
|
|
||||||
|
// Final check on main proxy
|
||||||
|
const finalCount = getActiveConnections();
|
||||||
|
console.log(`\nFinal connection count: ${finalCount}`);
|
||||||
|
|
||||||
|
// Stop the proxy
|
||||||
|
await proxy.stop();
|
||||||
|
console.log('✓ Proxy stopped');
|
||||||
|
|
||||||
|
// Verify all connections were cleaned up
|
||||||
|
expect(finalCount).toEqual(initialCount);
|
||||||
|
expect(afterNoData).toEqual(initialCount);
|
||||||
|
expect(afterTlsEarly).toEqual(initialCount);
|
||||||
|
expect(afterChaos).toEqual(initialCount);
|
||||||
|
expect(nftFinal).toEqual(0);
|
||||||
|
|
||||||
|
console.log('\n✅ PASS: Comprehensive connection cleanup test passed!');
|
||||||
|
console.log('All connection scenarios properly cleaned up:');
|
||||||
|
console.log('- ECONNREFUSED rapid retries');
|
||||||
|
console.log('- Connect without sending data');
|
||||||
|
console.log('- TLS early disconnect');
|
||||||
|
console.log('- Mixed chaos patterns');
|
||||||
|
console.log('- NFTables connections');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
195
test/test.proxy-chain-simple.node.ts
Normal file
195
test/test.proxy-chain-simple.node.ts
Normal 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();
|
368
test/test.proxy-chaining-accumulation.node.ts
Normal file
368
test/test.proxy-chaining-accumulation.node.ts
Normal 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();
|
201
test/test.rapid-retry-cleanup.node.ts
Normal file
201
test/test.rapid-retry-cleanup.node.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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 rapid connection retries without leaking connections', async () => {
|
||||||
|
console.log('\n=== Testing Rapid Connection Retry Cleanup ===');
|
||||||
|
|
||||||
|
// Create a SmartProxy instance
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
ports: [8550],
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
maxConnectionLifetime: 10000,
|
||||||
|
socketTimeout: 5000,
|
||||||
|
routes: [{
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 8550 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9999 // Non-existent port to force connection failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the proxy
|
||||||
|
await proxy.start();
|
||||||
|
console.log('✓ Proxy started on port 8550');
|
||||||
|
|
||||||
|
// Helper to get active connection count
|
||||||
|
const getActiveConnections = () => {
|
||||||
|
const connectionManager = (proxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track connection counts
|
||||||
|
const connectionCounts: number[] = [];
|
||||||
|
const initialCount = getActiveConnections();
|
||||||
|
console.log(`Initial connection count: ${initialCount}`);
|
||||||
|
|
||||||
|
// Simulate rapid retries
|
||||||
|
const retryCount = 20;
|
||||||
|
const retryDelay = 50; // 50ms between retries
|
||||||
|
let successfulConnections = 0;
|
||||||
|
let failedConnections = 0;
|
||||||
|
|
||||||
|
console.log(`\nSimulating ${retryCount} rapid connection attempts...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < retryCount; i++) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
failedConnections++;
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8550, 'localhost', () => {
|
||||||
|
// Send some data to trigger routing
|
||||||
|
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||||
|
successfulConnections++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force close after a short time
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay between retries
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
|
||||||
|
// Check connection count after each attempt
|
||||||
|
const currentCount = getActiveConnections();
|
||||||
|
connectionCounts.push(currentCount);
|
||||||
|
|
||||||
|
if ((i + 1) % 5 === 0) {
|
||||||
|
console.log(`After ${i + 1} attempts: ${currentCount} active connections`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nConnection attempts complete:`);
|
||||||
|
console.log(`- Successful: ${successfulConnections}`);
|
||||||
|
console.log(`- Failed: ${failedConnections}`);
|
||||||
|
|
||||||
|
// Wait a bit for any pending cleanups
|
||||||
|
console.log('\nWaiting for cleanup...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Check final connection count
|
||||||
|
const finalCount = getActiveConnections();
|
||||||
|
console.log(`\nFinal connection count: ${finalCount}`);
|
||||||
|
|
||||||
|
// Analyze connection count trend
|
||||||
|
const maxCount = Math.max(...connectionCounts);
|
||||||
|
const avgCount = connectionCounts.reduce((a, b) => a + b, 0) / connectionCounts.length;
|
||||||
|
|
||||||
|
console.log(`\nConnection count statistics:`);
|
||||||
|
console.log(`- Maximum: ${maxCount}`);
|
||||||
|
console.log(`- Average: ${avgCount.toFixed(2)}`);
|
||||||
|
console.log(`- Initial: ${initialCount}`);
|
||||||
|
console.log(`- Final: ${finalCount}`);
|
||||||
|
|
||||||
|
// Stop the proxy
|
||||||
|
await proxy.stop();
|
||||||
|
console.log('\n✓ Proxy stopped');
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
expect(finalCount).toEqual(initialCount);
|
||||||
|
expect(maxCount).toBeLessThan(10); // Should not accumulate many connections
|
||||||
|
|
||||||
|
console.log('\n✅ PASS: Connection cleanup working correctly under rapid retries!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle routing failures without leaking connections', async () => {
|
||||||
|
console.log('\n=== Testing Routing Failure Cleanup ===');
|
||||||
|
|
||||||
|
// Create a SmartProxy instance with no routes
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
ports: [8551],
|
||||||
|
enableDetailedLogging: false,
|
||||||
|
maxConnectionLifetime: 10000,
|
||||||
|
socketTimeout: 5000,
|
||||||
|
routes: [] // No routes - all connections will fail routing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the proxy
|
||||||
|
await proxy.start();
|
||||||
|
console.log('✓ Proxy started on port 8551 with no routes');
|
||||||
|
|
||||||
|
// Helper to get active connection count
|
||||||
|
const getActiveConnections = () => {
|
||||||
|
const connectionManager = (proxy as any).connectionManager;
|
||||||
|
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCount = getActiveConnections();
|
||||||
|
console.log(`Initial connection count: ${initialCount}`);
|
||||||
|
|
||||||
|
// Create multiple connections that will fail routing
|
||||||
|
const connectionPromises = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
connectionPromises.push(new Promise<void>((resolve) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(8551, 'localhost', () => {
|
||||||
|
// Send data to trigger routing (which will fail)
|
||||||
|
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force close after a short time
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 500);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all connections to complete
|
||||||
|
await Promise.all(connectionPromises);
|
||||||
|
console.log('✓ All connection attempts completed');
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const finalCount = getActiveConnections();
|
||||||
|
console.log(`Final connection count: ${finalCount}`);
|
||||||
|
|
||||||
|
// Stop the proxy
|
||||||
|
await proxy.stop();
|
||||||
|
console.log('✓ Proxy stopped');
|
||||||
|
|
||||||
|
// Verify no connections leaked
|
||||||
|
expect(finalCount).toEqual(initialCount);
|
||||||
|
|
||||||
|
console.log('\n✅ PASS: Routing failures cleaned up correctly!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
@ -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
|
* Create independent cleanup handlers for paired sockets that support half-open connections
|
||||||
@ -109,7 +78,8 @@ export function createSocketCleanupHandler(
|
|||||||
export function createIndependentSocketHandlers(
|
export function createIndependentSocketHandlers(
|
||||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
serverSocket: 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> } {
|
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
|
||||||
let clientClosed = false;
|
let clientClosed = false;
|
||||||
let serverClosed = false;
|
let serverClosed = false;
|
||||||
@ -127,8 +97,13 @@ export function createIndependentSocketHandlers(
|
|||||||
clientClosed = true;
|
clientClosed = true;
|
||||||
clientReason = reason;
|
clientReason = reason;
|
||||||
|
|
||||||
// Allow server to continue if still active
|
// Default behavior: close both sockets when one closes (required for proxy chains)
|
||||||
if (!serverClosed && serverSocket.writable) {
|
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
|
// Half-close: stop reading from client, let server finish
|
||||||
clientSocket.pause();
|
clientSocket.pause();
|
||||||
clientSocket.unpipe(serverSocket);
|
clientSocket.unpipe(serverSocket);
|
||||||
@ -145,8 +120,13 @@ export function createIndependentSocketHandlers(
|
|||||||
serverClosed = true;
|
serverClosed = true;
|
||||||
serverReason = reason;
|
serverReason = reason;
|
||||||
|
|
||||||
// Allow client to continue if still active
|
// Default behavior: close both sockets when one closes (required for proxy chains)
|
||||||
if (!clientClosed && clientSocket.writable) {
|
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
|
// Half-close: stop reading from server, let client finish
|
||||||
serverSocket.pause();
|
serverSocket.pause();
|
||||||
serverSocket.unpipe(clientSocket);
|
serverSocket.unpipe(clientSocket);
|
||||||
@ -195,16 +175,76 @@ export function setupSocketHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pipe two sockets together with proper cleanup on either end
|
* Setup bidirectional data forwarding between two sockets with proper cleanup
|
||||||
* @param socket1 First socket
|
* @param clientSocket The client/incoming socket
|
||||||
* @param socket2 Second 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(
|
export function setupBidirectionalForwarding(
|
||||||
socket1: plugins.net.Socket | plugins.tls.TLSSocket,
|
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
socket2: plugins.net.Socket | plugins.tls.TLSSocket
|
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||||
): void {
|
handlers: {
|
||||||
socket1.pipe(socket2);
|
onClientData?: (chunk: Buffer) => void;
|
||||||
socket2.pipe(socket1);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for HTTPS termination with HTTP backend
|
* Handler for HTTPS termination with HTTP backend
|
||||||
@ -100,19 +100,30 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|||||||
let backendSocket: plugins.net.Socket | null = null;
|
let backendSocket: plugins.net.Socket | null = null;
|
||||||
let dataBuffer = Buffer.alloc(0);
|
let dataBuffer = Buffer.alloc(0);
|
||||||
let connectionEstablished = false;
|
let connectionEstablished = false;
|
||||||
|
let forwardingSetup = false;
|
||||||
|
|
||||||
// Create cleanup handler for all sockets
|
// Set up initial error handling for TLS socket
|
||||||
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
const tlsCleanupHandler = (reason: string) => {
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
if (!forwardingSetup) {
|
||||||
remoteAddress,
|
// If forwarding not set up yet, emit disconnected and cleanup
|
||||||
reason
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||||
});
|
remoteAddress,
|
||||||
dataBuffer = Buffer.alloc(0);
|
reason
|
||||||
connectionEstablished = false;
|
});
|
||||||
});
|
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, tlsCleanupHandler, undefined, 'tls');
|
||||||
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
const timeout = this.getTimeout();
|
const timeout = this.getTimeout();
|
||||||
@ -123,7 +134,7 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|||||||
remoteAddress,
|
remoteAddress,
|
||||||
error: 'TLS connection timeout'
|
error: 'TLS connection timeout'
|
||||||
});
|
});
|
||||||
handleClose('timeout');
|
tlsCleanupHandler('timeout');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle TLS data
|
// Handle TLS data
|
||||||
@ -172,30 +183,33 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|||||||
dataBuffer = Buffer.alloc(0);
|
dataBuffer = Buffer.alloc(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up bidirectional data flow
|
// Now set up bidirectional forwarding with proper cleanup
|
||||||
tlsSocket.pipe(backendSocket!);
|
forwardingSetup = true;
|
||||||
backendSocket!.pipe(tlsSocket);
|
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
|
// Additional error logging for 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');
|
|
||||||
|
|
||||||
backendSocket.on('error', (error) => {
|
backendSocket.on('error', (error) => {
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
if (!connectionEstablished) {
|
||||||
remoteAddress,
|
// Connection failed during setup
|
||||||
error: `Target connection error: ${error.message}`
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||||
});
|
remoteAddress,
|
||||||
|
error: `Target connection error: ${error.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If connected, setupBidirectionalForwarding handles cleanup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for HTTPS termination with HTTPS backend
|
* Handler for HTTPS termination with HTTPS backend
|
||||||
@ -96,17 +96,26 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|||||||
|
|
||||||
// Variable to track backend socket
|
// Variable to track backend socket
|
||||||
let backendSocket: plugins.tls.TLSSocket | null = null;
|
let backendSocket: plugins.tls.TLSSocket | null = null;
|
||||||
|
let isConnectedToBackend = false;
|
||||||
|
|
||||||
// Create cleanup handler for both sockets
|
// Set up initial error handling for TLS socket
|
||||||
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
const tlsCleanupHandler = (reason: string) => {
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
if (!isConnectedToBackend) {
|
||||||
remoteAddress,
|
// If backend not connected yet, just emit disconnected event
|
||||||
reason
|
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, tlsCleanupHandler, undefined, 'tls');
|
||||||
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
const timeout = this.getTimeout();
|
const timeout = this.getTimeout();
|
||||||
@ -117,7 +126,7 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|||||||
remoteAddress,
|
remoteAddress,
|
||||||
error: 'TLS connection timeout'
|
error: 'TLS connection timeout'
|
||||||
});
|
});
|
||||||
handleClose('timeout');
|
tlsCleanupHandler('timeout');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the target from configuration
|
// Get the target from configuration
|
||||||
@ -131,44 +140,55 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|||||||
// In a real implementation, we would configure TLS options
|
// In a real implementation, we would configure TLS options
|
||||||
rejectUnauthorized: false // For testing only, never use in production
|
rejectUnauthorized: false // For testing only, never use in production
|
||||||
}, () => {
|
}, () => {
|
||||||
|
isConnectedToBackend = true;
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
target: `${target.host}:${target.port}`,
|
target: `${target.host}:${target.port}`,
|
||||||
tls: true
|
tls: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up bidirectional data flow
|
// Set up bidirectional forwarding with proper cleanup
|
||||||
tlsSocket.pipe(backendSocket!);
|
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
|
||||||
backendSocket!.pipe(tlsSocket);
|
onCleanup: (reason) => {
|
||||||
});
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||||
|
remoteAddress,
|
||||||
// Update the cleanup handler with the backend socket
|
reason
|
||||||
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
});
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
},
|
||||||
remoteAddress,
|
enableHalfOpen: false // Close both when one closes
|
||||||
reason
|
});
|
||||||
|
|
||||||
|
// 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
|
// Handle backend connection errors
|
||||||
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
|
||||||
|
|
||||||
backendSocket.on('error', (error) => {
|
backendSocket.on('error', (error) => {
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||||
remoteAddress,
|
remoteAddress,
|
||||||
error: `Backend connection error: ${error.message}`
|
error: `Backend connection error: ${error.message}`
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
if (!isConnectedToBackend) {
|
||||||
// Set timeout for backend socket
|
// Connection failed, clean up TLS socket
|
||||||
backendSocket.setTimeout(timeout);
|
if (!tlsSocket.destroyed) {
|
||||||
|
tlsSocket.destroy();
|
||||||
backendSocket.on('timeout', () => {
|
}
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||||
remoteAddress,
|
remoteAddress,
|
||||||
error: 'Backend connection timeout'
|
reason: `backend_connection_failed: ${error.message}`
|
||||||
});
|
});
|
||||||
newHandleClose('backend_timeout');
|
}
|
||||||
|
// If connected, let setupBidirectionalForwarding handle cleanup
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { HttpProxy } from '../http-proxy/index.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 { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type { IRouteConfig } from './models/route-types.js';
|
||||||
|
|
||||||
@ -123,36 +124,25 @@ export class HttpProxyBridge {
|
|||||||
proxySocket.write(initialChunk);
|
proxySocket.write(initialChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe the sockets together
|
// Use centralized bidirectional forwarding
|
||||||
socket.pipe(proxySocket);
|
setupBidirectionalForwarding(socket, proxySocket, {
|
||||||
proxySocket.pipe(socket);
|
onClientData: (chunk) => {
|
||||||
|
// Update stats if needed
|
||||||
// Handle cleanup
|
if (record) {
|
||||||
let cleanedUp = false;
|
record.bytesReceived += chunk.length;
|
||||||
const cleanup = (reason: string) => {
|
}
|
||||||
if (cleanedUp) return;
|
},
|
||||||
cleanedUp = true;
|
onServerData: (chunk) => {
|
||||||
|
// Update stats if needed
|
||||||
// Remove all event listeners to prevent memory leaks
|
if (record) {
|
||||||
socket.removeAllListeners('end');
|
record.bytesSent += chunk.length;
|
||||||
socket.removeAllListeners('error');
|
}
|
||||||
proxySocket.removeAllListeners('end');
|
},
|
||||||
proxySocket.removeAllListeners('error');
|
onCleanup: (reason) => {
|
||||||
|
cleanupCallback(reason);
|
||||||
socket.unpipe(proxySocket);
|
},
|
||||||
proxySocket.unpipe(socket);
|
enableHalfOpen: false // Close both when one closes (required for proxy chains)
|
||||||
|
});
|
||||||
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'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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, 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
|
* Handles new connection processing and setup logic with support for route-based configuration
|
||||||
@ -90,6 +90,10 @@ export class RouteConnectionHandler {
|
|||||||
|
|
||||||
// Create a new connection record
|
// Create a new connection record
|
||||||
const record = this.connectionManager.createConnection(socket);
|
const record = this.connectionManager.createConnection(socket);
|
||||||
|
if (!record) {
|
||||||
|
// Connection was rejected due to limit - socket already destroyed by connection manager
|
||||||
|
return;
|
||||||
|
}
|
||||||
const connectionId = record.id;
|
const connectionId = record.id;
|
||||||
|
|
||||||
// Apply socket optimizations
|
// Apply socket optimizations
|
||||||
@ -172,8 +176,33 @@ export class RouteConnectionHandler {
|
|||||||
|
|
||||||
// If no routes require TLS handling and it's not port 443, route immediately
|
// If no routes require TLS handling and it's not port 443, route immediately
|
||||||
if (!needsTlsHandling && localPort !== 443) {
|
if (!needsTlsHandling && localPort !== 443) {
|
||||||
// Set up error handler
|
// Set up proper socket handlers for immediate routing
|
||||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
setupSocketHandlers(
|
||||||
|
socket,
|
||||||
|
(reason) => {
|
||||||
|
// Only cleanup if connection hasn't been fully established
|
||||||
|
// Check if outgoing connection exists and is connected
|
||||||
|
if (!record.outgoing || record.outgoing.readyState !== 'open') {
|
||||||
|
logger.log('debug', `Connection ${connectionId} closed during immediate routing: ${reason}`, {
|
||||||
|
connectionId,
|
||||||
|
remoteIP: record.remoteIP,
|
||||||
|
reason,
|
||||||
|
hasOutgoing: !!record.outgoing,
|
||||||
|
outgoingState: record.outgoing?.readyState,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there's a pending outgoing connection, destroy it
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionManager.cleanupConnection(record, reason);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
undefined, // Use default timeout handler
|
||||||
|
'immediate-route-client'
|
||||||
|
);
|
||||||
|
|
||||||
// Route immediately for non-TLS connections
|
// Route immediately for non-TLS connections
|
||||||
this.routeConnection(socket, record, '', undefined);
|
this.routeConnection(socket, record, '', undefined);
|
||||||
@ -217,6 +246,37 @@ export class RouteConnectionHandler {
|
|||||||
// Set up error handler
|
// Set up error handler
|
||||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||||
|
|
||||||
|
// Add close/end handlers to catch immediate disconnections
|
||||||
|
socket.once('close', () => {
|
||||||
|
if (!initialDataReceived) {
|
||||||
|
logger.log('warn', `Connection ${connectionId} closed before sending initial data`, {
|
||||||
|
connectionId,
|
||||||
|
remoteIP: record.remoteIP,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
if (initialTimeout) {
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
initialTimeout = null;
|
||||||
|
}
|
||||||
|
this.connectionManager.cleanupConnection(record, 'closed_before_data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once('end', () => {
|
||||||
|
if (!initialDataReceived) {
|
||||||
|
logger.log('debug', `Connection ${connectionId} ended before sending initial data`, {
|
||||||
|
connectionId,
|
||||||
|
remoteIP: record.remoteIP,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
if (initialTimeout) {
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
initialTimeout = null;
|
||||||
|
}
|
||||||
|
// Don't cleanup on 'end' - wait for 'close'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// First data handler to capture initial TLS handshake
|
// First data handler to capture initial TLS handshake
|
||||||
socket.once('data', (chunk: Buffer) => {
|
socket.once('data', (chunk: Buffer) => {
|
||||||
// Clear the initial timeout since we've received data
|
// Clear the initial timeout since we've received data
|
||||||
@ -546,6 +606,12 @@ export class RouteConnectionHandler {
|
|||||||
|
|
||||||
// We don't close the socket - just let it remain open
|
// We don't close the socket - just let it remain open
|
||||||
// The kernel-level NFTables rules will handle the actual forwarding
|
// The kernel-level NFTables rules will handle the actual forwarding
|
||||||
|
|
||||||
|
// Set up cleanup when the socket eventually closes
|
||||||
|
socket.once('close', () => {
|
||||||
|
this.connectionManager.cleanupConnection(record, 'nftables_closed');
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -687,7 +753,7 @@ export class RouteConnectionHandler {
|
|||||||
record,
|
record,
|
||||||
initialChunk,
|
initialChunk,
|
||||||
this.settings.httpProxyPort || 8443,
|
this.settings.httpProxyPort || 8443,
|
||||||
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
(reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -742,7 +808,7 @@ export class RouteConnectionHandler {
|
|||||||
record,
|
record,
|
||||||
initialChunk,
|
initialChunk,
|
||||||
this.settings.httpProxyPort || 8443,
|
this.settings.httpProxyPort || 8443,
|
||||||
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
(reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@ -917,106 +983,6 @@ export class RouteConnectionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
logger.log('error',
|
|
||||||
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${err.message} (${code})`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
errorMessage: err.message,
|
|
||||||
errorCode: code,
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resume the incoming socket to prevent it from hanging
|
|
||||||
socket.resume();
|
|
||||||
|
|
||||||
// Log specific error types for easier debugging
|
|
||||||
if (code === 'ECONNREFUSED') {
|
|
||||||
logger.log('error',
|
|
||||||
`Connection ${connectionId}: Target ${finalTargetHost}:${finalTargetPort} refused connection. Check if the target service is running and listening on that port.`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
recommendation: 'Check if the target service is running and listening on that port.',
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (code === 'ETIMEDOUT') {
|
|
||||||
logger.log('error',
|
|
||||||
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} timed out. Check network conditions, firewall rules, or if the target is too far away.`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
recommendation: 'Check network conditions, firewall rules, or if the target is too far away.',
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (code === 'ECONNRESET') {
|
|
||||||
logger.log('error',
|
|
||||||
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} was reset. The target might have closed the connection abruptly.`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
recommendation: 'The target might have closed the connection abruptly.',
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (code === 'EHOSTUNREACH') {
|
|
||||||
logger.log('error',
|
|
||||||
`Connection ${connectionId}: Host ${finalTargetHost} is unreachable. Check DNS settings, network routing, or firewall rules.`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
recommendation: 'Check DNS settings, network routing, or firewall rules.',
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (code === 'ENOTFOUND') {
|
|
||||||
logger.log('error',
|
|
||||||
`Connection ${connectionId}: DNS lookup failed for ${finalTargetHost}. Check your DNS settings or if the hostname is correct.`,
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
recommendation: 'Check your DNS settings or if the hostname is correct.',
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
* Sets up a direct connection to the target
|
||||||
@ -1074,19 +1040,21 @@ export class RouteConnectionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the target socket with immediate error handling
|
// Create the target socket with immediate error handling
|
||||||
let targetSocket: plugins.net.Socket;
|
const targetSocket = createSocketWithErrorHandler({
|
||||||
|
|
||||||
// Flag to track if initial connection failed
|
|
||||||
let connectionFailed = false;
|
|
||||||
|
|
||||||
targetSocket = createSocketWithErrorHandler({
|
|
||||||
port: finalTargetPort,
|
port: finalTargetPort,
|
||||||
host: finalTargetHost,
|
host: finalTargetHost,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Mark connection as failed
|
// Connection failed - clean up everything immediately
|
||||||
connectionFailed = true;
|
// Check if connection record is still valid (client might have disconnected)
|
||||||
|
if (record.connectionClosed) {
|
||||||
|
logger.log('debug', `Backend connection failed but client already disconnected for ${connectionId}`, {
|
||||||
|
connectionId,
|
||||||
|
errorCode: (error as any).code,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Connection failed - clean up immediately
|
|
||||||
logger.log('error',
|
logger.log('error',
|
||||||
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${error.message} (${(error as any).code})`,
|
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${error.message} (${(error as any).code})`,
|
||||||
{
|
{
|
||||||
@ -1099,26 +1067,168 @@ export class RouteConnectionHandler {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log specific error types for easier debugging
|
||||||
|
if ((error as any).code === 'ECONNREFUSED') {
|
||||||
|
logger.log('error',
|
||||||
|
`Connection ${connectionId}: Target ${finalTargetHost}:${finalTargetPort} refused connection. Check if the target service is running and listening on that port.`,
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
targetHost: finalTargetHost,
|
||||||
|
targetPort: finalTargetPort,
|
||||||
|
recommendation: 'Check if the target service is running and listening on that port.',
|
||||||
|
component: 'route-handler'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Resume the incoming socket to prevent it from hanging
|
// Resume the incoming socket to prevent it from hanging
|
||||||
socket.resume();
|
if (socket && !socket.destroyed) {
|
||||||
|
socket.resume();
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up the incoming socket
|
// Clean up the incoming socket
|
||||||
if (!socket.destroyed) {
|
if (socket && !socket.destroyed) {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the connection record
|
// Clean up the connection record - this is critical!
|
||||||
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
this.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
||||||
|
},
|
||||||
|
onConnect: () => {
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
||||||
|
connectionId,
|
||||||
|
targetHost: finalTargetHost,
|
||||||
|
targetPort: finalTargetPort,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any error listeners added by createSocketWithErrorHandler
|
||||||
|
targetSocket.removeAllListeners('error');
|
||||||
|
|
||||||
|
// Add the normal error handler for established connections
|
||||||
|
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
||||||
|
|
||||||
|
// Flush any pending data to target
|
||||||
|
if (record.pendingData.length > 0) {
|
||||||
|
const combinedData = Buffer.concat(record.pendingData);
|
||||||
|
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write pending data immediately
|
||||||
|
targetSocket.write(combinedData, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.log('error', `Error writing pending data to target for connection ${connectionId}: ${err.message}`, {
|
||||||
|
connectionId,
|
||||||
|
error: err.message,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
return this.connectionManager.cleanupConnection(record, 'write_error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the buffer now that we've processed it
|
||||||
|
record.pendingData = [];
|
||||||
|
record.pendingDataSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
|
||||||
|
`${serverName ? ` (SNI: ${serverName})` : record.lockedDomain ? ` (Domain: ${record.lockedDomain})` : ''}`,
|
||||||
|
{
|
||||||
|
remoteIP: record.remoteIP,
|
||||||
|
targetHost: finalTargetHost,
|
||||||
|
targetPort: finalTargetPort,
|
||||||
|
sni: serverName || undefined,
|
||||||
|
domain: !serverName && record.lockedDomain ? record.lockedDomain : undefined,
|
||||||
|
component: 'route-handler'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add TLS renegotiation handler if needed
|
||||||
|
if (serverName) {
|
||||||
|
// Create connection info object for the existing connection
|
||||||
|
const connInfo = {
|
||||||
|
sourceIp: record.remoteIP,
|
||||||
|
sourcePort: record.incoming.remotePort || 0,
|
||||||
|
destIp: record.incoming.localAddress || '',
|
||||||
|
destPort: record.incoming.localPort || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a renegotiation handler function
|
||||||
|
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
||||||
|
connectionId,
|
||||||
|
serverName,
|
||||||
|
connInfo,
|
||||||
|
(_connectionId, reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the handler in the connection record so we can remove it during cleanup
|
||||||
|
record.renegotiationHandler = renegotiationHandler;
|
||||||
|
|
||||||
|
// Add the handler to the socket
|
||||||
|
socket.on('data', renegotiationHandler);
|
||||||
|
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
|
||||||
|
connectionId,
|
||||||
|
serverName,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connection timeout
|
||||||
|
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
||||||
|
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
|
||||||
|
connectionId,
|
||||||
|
remoteIP: record.remoteIP,
|
||||||
|
component: 'route-handler'
|
||||||
|
});
|
||||||
|
this.connectionManager.cleanupConnection(record, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark TLS handshake as complete for TLS connections
|
||||||
|
if (record.isTLS) {
|
||||||
|
record.tlsHandshakeComplete = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only proceed with setup if connection didn't fail immediately
|
// Set outgoing socket immediately so it can be cleaned up if client disconnects
|
||||||
if (!connectionFailed) {
|
record.outgoing = targetSocket;
|
||||||
record.outgoing = targetSocket;
|
record.outgoingStartTime = Date.now();
|
||||||
record.outgoingStartTime = Date.now();
|
|
||||||
|
|
||||||
// Apply socket optimizations
|
// Apply socket optimizations
|
||||||
targetSocket.setNoDelay(this.settings.noDelay);
|
targetSocket.setNoDelay(this.settings.noDelay);
|
||||||
|
|
||||||
// Apply keep-alive settings if enabled
|
// Apply keep-alive settings if enabled
|
||||||
if (this.settings.keepAlive) {
|
if (this.settings.keepAlive) {
|
||||||
@ -1146,12 +1256,6 @@ export class RouteConnectionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup improved error handling for outgoing connection
|
|
||||||
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
|
||||||
|
|
||||||
// Note: Close handlers are managed by independent socket handlers above
|
|
||||||
// We don't register handleClose here to avoid bilateral cleanup
|
|
||||||
|
|
||||||
// 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));
|
||||||
|
|
||||||
@ -1180,7 +1284,7 @@ export class RouteConnectionHandler {
|
|||||||
record.incomingTerminationReason = 'timeout';
|
record.incomingTerminationReason = 'timeout';
|
||||||
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
|
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
|
||||||
}
|
}
|
||||||
this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming');
|
this.connectionManager.cleanupConnection(record, 'timeout_incoming');
|
||||||
});
|
});
|
||||||
|
|
||||||
targetSocket.on('timeout', () => {
|
targetSocket.on('timeout', () => {
|
||||||
@ -1207,184 +1311,10 @@ export class RouteConnectionHandler {
|
|||||||
record.outgoingTerminationReason = 'timeout';
|
record.outgoingTerminationReason = 'timeout';
|
||||||
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
|
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
|
||||||
}
|
}
|
||||||
this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing');
|
this.connectionManager.cleanupConnection(record, 'timeout_outgoing');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply socket timeouts
|
// Apply socket timeouts
|
||||||
this.timeoutManager.applySocketTimeouts(record);
|
this.timeoutManager.applySocketTimeouts(record);
|
||||||
|
|
||||||
// Track outgoing data for bytes counting
|
|
||||||
targetSocket.on('data', (chunk: Buffer) => {
|
|
||||||
record.bytesSent += chunk.length;
|
|
||||||
this.timeoutManager.updateActivity(record);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the outgoing connection to be ready before setting up piping
|
|
||||||
targetSocket.once('connect', () => {
|
|
||||||
if (this.settings.enableDetailedLogging) {
|
|
||||||
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
|
||||||
connectionId,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
component: 'route-handler'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
// Flush any pending data to target
|
|
||||||
if (record.pendingData.length > 0) {
|
|
||||||
const combinedData = Buffer.concat(record.pendingData);
|
|
||||||
|
|
||||||
if (this.settings.enableDetailedLogging) {
|
|
||||||
console.log(
|
|
||||||
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write pending data immediately
|
|
||||||
targetSocket.write(combinedData, (err) => {
|
|
||||||
if (err) {
|
|
||||||
logger.log('error', `Error writing pending data to target for connection ${connectionId}: ${err.message}`, {
|
|
||||||
connectionId,
|
|
||||||
error: err.message,
|
|
||||||
component: 'route-handler'
|
|
||||||
});
|
|
||||||
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the buffer now that we've processed it
|
|
||||||
record.pendingData = [];
|
|
||||||
record.pendingDataSize = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up independent socket handlers for half-open connection support
|
|
||||||
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
|
||||||
socket,
|
|
||||||
targetSocket,
|
|
||||||
(reason) => {
|
|
||||||
this.connectionManager.initiateCleanupOnce(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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log successful connection
|
|
||||||
logger.log('info',
|
|
||||||
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
|
|
||||||
`${serverName ? ` (SNI: ${serverName})` : record.lockedDomain ? ` (Domain: ${record.lockedDomain})` : ''}`,
|
|
||||||
{
|
|
||||||
remoteIP: record.remoteIP,
|
|
||||||
targetHost: finalTargetHost,
|
|
||||||
targetPort: finalTargetPort,
|
|
||||||
sni: serverName || undefined,
|
|
||||||
domain: !serverName && record.lockedDomain ? record.lockedDomain : undefined,
|
|
||||||
component: 'route-handler'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add TLS renegotiation handler if needed
|
|
||||||
if (serverName) {
|
|
||||||
// Create connection info object for the existing connection
|
|
||||||
const connInfo = {
|
|
||||||
sourceIp: record.remoteIP,
|
|
||||||
sourcePort: record.incoming.remotePort || 0,
|
|
||||||
destIp: record.incoming.localAddress || '',
|
|
||||||
destPort: record.incoming.localPort || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a renegotiation handler function
|
|
||||||
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
|
||||||
connectionId,
|
|
||||||
serverName,
|
|
||||||
connInfo,
|
|
||||||
(_connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store the handler in the connection record so we can remove it during cleanup
|
|
||||||
record.renegotiationHandler = renegotiationHandler;
|
|
||||||
|
|
||||||
// Add the handler to the socket
|
|
||||||
socket.on('data', renegotiationHandler);
|
|
||||||
|
|
||||||
if (this.settings.enableDetailedLogging) {
|
|
||||||
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
|
|
||||||
connectionId,
|
|
||||||
serverName,
|
|
||||||
component: 'route-handler'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set connection timeout
|
|
||||||
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
|
||||||
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
|
|
||||||
connectionId,
|
|
||||||
remoteIP: record.remoteIP,
|
|
||||||
component: 'route-handler'
|
|
||||||
});
|
|
||||||
this.connectionManager.initiateCleanupOnce(record, reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark TLS handshake as complete for TLS connections
|
|
||||||
if (record.isTLS) {
|
|
||||||
record.tlsHandshakeComplete = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} // End of if (!connectionFailed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user