Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
9094b76b1b | |||
9aebcd488d | |||
311691c2cc | |||
578d1ba2f7 | |||
233c98e5ff | |||
b3714d583d |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "19.5.20",
|
||||
"version": "19.5.23",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
375
readme.connections.md
Normal file
375
readme.connections.md
Normal file
@ -0,0 +1,375 @@
|
||||
# Connection Management in SmartProxy
|
||||
|
||||
This document describes connection handling, cleanup mechanisms, and known issues in SmartProxy, particularly focusing on proxy chain configurations.
|
||||
|
||||
## Connection Accumulation Investigation (January 2025)
|
||||
|
||||
### Problem Statement
|
||||
Connections may accumulate on the outer proxy in proxy chain configurations, despite implemented fixes.
|
||||
|
||||
### Historical Context
|
||||
- **v19.5.12-v19.5.15**: Major connection cleanup improvements
|
||||
- **v19.5.19+**: PROXY protocol support with WrappedSocket implementation
|
||||
- **v19.5.20**: Fixed race condition in immediate routing cleanup
|
||||
|
||||
### Current Architecture
|
||||
|
||||
#### Connection Flow in Proxy Chains
|
||||
```
|
||||
Client → Outer Proxy (8001) → Inner Proxy (8002) → Backend (httpbin.org:443)
|
||||
```
|
||||
|
||||
1. **Outer Proxy**:
|
||||
- Accepts client connection
|
||||
- Sends PROXY protocol header to inner proxy
|
||||
- Tracks connection in ConnectionManager
|
||||
- Immediate routing for non-TLS ports
|
||||
|
||||
2. **Inner Proxy**:
|
||||
- Parses PROXY protocol to get real client IP
|
||||
- Establishes connection to backend
|
||||
- Tracks its own connections separately
|
||||
|
||||
### Potential Causes of Connection Accumulation
|
||||
|
||||
#### 1. Race Condition in Immediate Routing
|
||||
When a connection is immediately routed (non-TLS ports), there's a timing window:
|
||||
```typescript
|
||||
// route-connection-handler.ts, line ~231
|
||||
this.routeConnection(socket, record, '', undefined);
|
||||
// Connection is routed before all setup is complete
|
||||
```
|
||||
|
||||
**Issue**: If client disconnects during backend connection setup, cleanup may not trigger properly.
|
||||
|
||||
#### 2. Outgoing Socket Assignment Timing
|
||||
Despite the fix in v19.5.20:
|
||||
```typescript
|
||||
// Line 1362 in setupDirectConnection
|
||||
record.outgoing = targetSocket;
|
||||
```
|
||||
There's still a window between socket creation and the `connect` event where cleanup might miss the outgoing socket.
|
||||
|
||||
#### 3. Batch Cleanup Delays
|
||||
ConnectionManager uses queued cleanup:
|
||||
- Batch size: 100 connections
|
||||
- Batch interval: 100ms
|
||||
- Under rapid connection/disconnection, queue might lag
|
||||
|
||||
#### 4. Different Cleanup Paths
|
||||
Multiple cleanup triggers exist:
|
||||
- Socket 'close' event
|
||||
- Socket 'error' event
|
||||
- Inactivity timeout
|
||||
- Connection timeout
|
||||
- Manual cleanup
|
||||
|
||||
Not all paths may properly handle proxy chain scenarios.
|
||||
|
||||
#### 5. Keep-Alive Connection Handling
|
||||
Keep-alive connections have special treatment:
|
||||
- Extended inactivity timeout (6x normal)
|
||||
- Warning before closure
|
||||
- May accumulate if backend is unresponsive
|
||||
|
||||
### Observed Symptoms
|
||||
|
||||
1. **Outer proxy connection count grows over time**
|
||||
2. **Inner proxy maintains zero or low connection count**
|
||||
3. **Connections show as closed in logs but remain in tracking**
|
||||
4. **Memory usage gradually increases**
|
||||
|
||||
### Debug Strategies
|
||||
|
||||
#### 1. Enhanced Logging
|
||||
Add connection state logging at key points:
|
||||
```typescript
|
||||
// When outgoing socket is created
|
||||
logger.log('debug', `Outgoing socket created for ${connectionId}`, {
|
||||
hasOutgoing: !!record.outgoing,
|
||||
outgoingState: record.outgoing?.readyState
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. Connection State Inspection
|
||||
Periodically log detailed connection state:
|
||||
```typescript
|
||||
for (const [id, record] of connectionManager.getConnections()) {
|
||||
console.log({
|
||||
id,
|
||||
age: Date.now() - record.incomingStartTime,
|
||||
incomingDestroyed: record.incoming.destroyed,
|
||||
outgoingDestroyed: record.outgoing?.destroyed,
|
||||
hasCleanupTimer: !!record.cleanupTimer
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Cleanup Verification
|
||||
Track cleanup completion:
|
||||
```typescript
|
||||
// In cleanupConnection
|
||||
logger.log('debug', `Cleanup completed for ${record.id}`, {
|
||||
recordsRemaining: this.connectionRecords.size
|
||||
});
|
||||
```
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Immediate Cleanup for Proxy Chains**
|
||||
- Skip batch queue for proxy chain connections
|
||||
- Use synchronous cleanup when PROXY protocol is detected
|
||||
|
||||
2. **Socket State Validation**
|
||||
- Check both `destroyed` and `readyState` before cleanup decisions
|
||||
- Handle 'opening' state sockets explicitly
|
||||
|
||||
3. **Timeout Adjustments**
|
||||
- Shorter timeouts for proxy chain connections
|
||||
- More aggressive cleanup for connections without data transfer
|
||||
|
||||
4. **Connection Limits**
|
||||
- Per-route connection limits
|
||||
- Backpressure when approaching limits
|
||||
|
||||
5. **Monitoring**
|
||||
- Export connection metrics
|
||||
- Alert on connection count thresholds
|
||||
- Track connection age distribution
|
||||
|
||||
### Test Scenarios to Reproduce
|
||||
|
||||
1. **Rapid Connect/Disconnect**
|
||||
```bash
|
||||
# Create many short-lived connections
|
||||
for i in {1..1000}; do
|
||||
(echo -n | nc localhost 8001) &
|
||||
done
|
||||
```
|
||||
|
||||
2. **Slow Backend**
|
||||
- Configure inner proxy to connect to unresponsive backend
|
||||
- Monitor outer proxy connection count
|
||||
|
||||
3. **Mixed Traffic**
|
||||
- Combine TLS and non-TLS connections
|
||||
- Add keep-alive connections
|
||||
- Observe accumulation patterns
|
||||
|
||||
### Future Improvements
|
||||
|
||||
1. **Connection Pool Isolation**
|
||||
- Separate pools for proxy chain vs direct connections
|
||||
- Different cleanup strategies per pool
|
||||
|
||||
2. **Circuit Breaker**
|
||||
- Detect accumulation and trigger aggressive cleanup
|
||||
- Temporary refuse new connections when near limit
|
||||
|
||||
3. **Connection State Machine**
|
||||
- Explicit states: CONNECTING, ESTABLISHED, CLOSING, CLOSED
|
||||
- State transition validation
|
||||
- Timeout per state
|
||||
|
||||
4. **Metrics Collection**
|
||||
- Connection lifecycle events
|
||||
- Cleanup success/failure rates
|
||||
- Time spent in each state
|
||||
|
||||
### Root Cause Identified (January 2025)
|
||||
|
||||
**The primary issue is on the inner proxy when backends are unreachable:**
|
||||
|
||||
When the backend is unreachable (e.g., non-routable IP like 10.255.255.1):
|
||||
1. The outgoing socket gets stuck in "opening" state indefinitely
|
||||
2. The `createSocketWithErrorHandler` in socket-utils.ts doesn't implement connection timeout
|
||||
3. `socket.setTimeout()` only handles inactivity AFTER connection, not during connect phase
|
||||
4. Connections accumulate because they never transition to error state
|
||||
5. Socket timeout warnings fire but connections are preserved as keep-alive
|
||||
|
||||
**Code Issue:**
|
||||
```typescript
|
||||
// socket-utils.ts line 275
|
||||
if (timeout) {
|
||||
socket.setTimeout(timeout); // This only handles inactivity, not connection!
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fix:**
|
||||
|
||||
1. Add `connectionTimeout` to ISmartProxyOptions interface:
|
||||
```typescript
|
||||
// In interfaces.ts
|
||||
connectionTimeout?: number; // Timeout for establishing connection (ms), default: 30000 (30s)
|
||||
```
|
||||
|
||||
2. Update `createSocketWithErrorHandler` in socket-utils.ts:
|
||||
```typescript
|
||||
export function createSocketWithErrorHandler(options: SafeSocketOptions): plugins.net.Socket {
|
||||
const { port, host, onError, onConnect, timeout } = options;
|
||||
|
||||
const socket = new plugins.net.Socket();
|
||||
let connected = false;
|
||||
let connectionTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
socket.on('error', (error) => {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
if (onError) onError(error);
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
connected = true;
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
if (timeout) socket.setTimeout(timeout); // Set inactivity timeout
|
||||
if (onConnect) onConnect();
|
||||
});
|
||||
|
||||
// Implement connection establishment timeout
|
||||
if (timeout) {
|
||||
connectionTimeout = setTimeout(() => {
|
||||
if (!connected && !socket.destroyed) {
|
||||
const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`);
|
||||
(error as any).code = 'ETIMEDOUT';
|
||||
socket.destroy();
|
||||
if (onError) onError(error);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
socket.connect(port, host);
|
||||
return socket;
|
||||
}
|
||||
```
|
||||
|
||||
3. Pass connectionTimeout in route-connection-handler.ts:
|
||||
```typescript
|
||||
const targetSocket = createSocketWithErrorHandler({
|
||||
port: finalTargetPort,
|
||||
host: finalTargetHost,
|
||||
timeout: this.settings.connectionTimeout || 30000, // Connection timeout
|
||||
onError: (error) => { /* existing */ },
|
||||
onConnect: async () => { /* existing */ }
|
||||
});
|
||||
```
|
||||
|
||||
### Investigation Results (January 2025)
|
||||
|
||||
Based on extensive testing with debug scripts:
|
||||
|
||||
1. **Normal Operation**: In controlled tests, connections are properly cleaned up:
|
||||
- Immediate routing cleanup handler properly destroys outgoing connections
|
||||
- Both outer and inner proxies maintain 0 connections after clients disconnect
|
||||
- Keep-alive connections are tracked and cleaned up correctly
|
||||
|
||||
2. **Potential Edge Cases Not Covered by Tests**:
|
||||
- **HTTP/2 Connections**: May have different lifecycle than HTTP/1.1
|
||||
- **WebSocket Connections**: Long-lived upgrade connections might persist
|
||||
- **Partial TLS Handshakes**: Connections that start TLS but don't complete
|
||||
- **PROXY Protocol Parse Failures**: Malformed headers from untrusted sources
|
||||
- **Connection Pool Reuse**: HttpProxy component may maintain its own pools
|
||||
|
||||
3. **Timing-Sensitive Scenarios**:
|
||||
- Client disconnects exactly when `record.outgoing` is being assigned
|
||||
- Backend connects but immediately RSTs
|
||||
- Proxy chain where middle proxy restarts
|
||||
- Multiple rapid reconnects with same source IP/port
|
||||
|
||||
4. **Configuration-Specific Issues**:
|
||||
- Mixed `sendProxyProtocol` settings in chain
|
||||
- Different `keepAlive` settings between proxies
|
||||
- Mismatched timeout values
|
||||
- Routes with `forwardingEngine: 'nftables'`
|
||||
|
||||
### Additional Debug Points
|
||||
|
||||
Add these debug logs to identify the specific scenario:
|
||||
|
||||
```typescript
|
||||
// In route-connection-handler.ts setupDirectConnection
|
||||
logger.log('debug', `Setting outgoing socket for ${connectionId}`, {
|
||||
timestamp: Date.now(),
|
||||
hasOutgoing: !!record.outgoing,
|
||||
socketState: targetSocket.readyState
|
||||
});
|
||||
|
||||
// In connection-manager.ts cleanupConnection
|
||||
logger.log('debug', `Cleanup attempt for ${record.id}`, {
|
||||
alreadyClosed: record.connectionClosed,
|
||||
hasIncoming: !!record.incoming,
|
||||
hasOutgoing: !!record.outgoing,
|
||||
incomingDestroyed: record.incoming?.destroyed,
|
||||
outgoingDestroyed: record.outgoing?.destroyed
|
||||
});
|
||||
```
|
||||
|
||||
### Workarounds
|
||||
|
||||
Until root cause is identified:
|
||||
|
||||
1. **Periodic Force Cleanup**:
|
||||
```typescript
|
||||
setInterval(() => {
|
||||
const connections = connectionManager.getConnections();
|
||||
for (const [id, record] of connections) {
|
||||
if (record.incoming?.destroyed && !record.connectionClosed) {
|
||||
connectionManager.cleanupConnection(record, 'force_cleanup');
|
||||
}
|
||||
}
|
||||
}, 60000); // Every minute
|
||||
```
|
||||
|
||||
2. **Connection Age Limit**:
|
||||
```typescript
|
||||
// Add max connection age check
|
||||
const maxAge = 3600000; // 1 hour
|
||||
if (Date.now() - record.incomingStartTime > maxAge) {
|
||||
connectionManager.cleanupConnection(record, 'max_age');
|
||||
}
|
||||
```
|
||||
|
||||
3. **Aggressive Timeout Settings**:
|
||||
```typescript
|
||||
{
|
||||
socketTimeout: 60000, // 1 minute
|
||||
inactivityTimeout: 300000, // 5 minutes
|
||||
connectionCleanupInterval: 30000 // 30 seconds
|
||||
}
|
||||
```
|
||||
|
||||
### Related Files
|
||||
- `/ts/proxies/smart-proxy/route-connection-handler.ts` - Main connection handling
|
||||
- `/ts/proxies/smart-proxy/connection-manager.ts` - Connection tracking and cleanup
|
||||
- `/ts/core/utils/socket-utils.ts` - Socket cleanup utilities
|
||||
- `/test/test.proxy-chain-cleanup.node.ts` - Test for connection cleanup
|
||||
- `/test/test.proxy-chaining-accumulation.node.ts` - Test for accumulation prevention
|
||||
- `/.nogit/debug/connection-accumulation-debug.ts` - Debug script for connection states
|
||||
- `/.nogit/debug/connection-accumulation-keepalive.ts` - Keep-alive specific tests
|
||||
- `/.nogit/debug/connection-accumulation-http.ts` - HTTP traffic through proxy chains
|
||||
|
||||
### Summary
|
||||
|
||||
**Issue Identified**: Connection accumulation occurs on the **inner proxy** (not outer) when backends are unreachable.
|
||||
|
||||
**Root Cause**: The `createSocketWithErrorHandler` function in socket-utils.ts doesn't implement connection establishment timeout. It only sets `socket.setTimeout()` which handles inactivity AFTER connection is established, not during the connect phase.
|
||||
|
||||
**Impact**: When connecting to unreachable IPs (e.g., 10.255.255.1), outgoing sockets remain in "opening" state indefinitely, causing connections to accumulate.
|
||||
|
||||
**Fix Required**:
|
||||
1. Add `connectionTimeout` setting to ISmartProxyOptions
|
||||
2. Implement proper connection timeout in `createSocketWithErrorHandler`
|
||||
3. Pass the timeout value from route-connection-handler
|
||||
|
||||
**Workaround Until Fixed**: Configure shorter socket timeouts and use the periodic force cleanup suggested above.
|
||||
|
||||
The connection cleanup mechanisms have been significantly improved in v19.5.20:
|
||||
1. Race condition fixed by setting `record.outgoing` before connecting
|
||||
2. Immediate routing cleanup handler always destroys outgoing connections
|
||||
3. Tests confirm no accumulation in standard scenarios with reachable backends
|
||||
|
||||
However, the missing connection establishment timeout causes accumulation when backends are unreachable or very slow to connect.
|
@ -849,4 +849,11 @@ The WrappedSocket class has been implemented as the foundation for PROXY protoco
|
||||
- Phase 2: Parse PROXY protocol header from trusted proxies
|
||||
- Phase 3: Update real client IP/port after parsing
|
||||
- Phase 4: Test with HAProxy and AWS ELB
|
||||
- Phase 5: Documentation and configuration
|
||||
- Phase 5: Documentation and configuration
|
||||
|
||||
## Proxy Protocol Documentation
|
||||
|
||||
For detailed information about proxy protocol implementation and proxy chaining:
|
||||
- **[Proxy Protocol Guide](./readme.proxy-protocol.md)** - Complete implementation details and configuration
|
||||
- **[Proxy Protocol Examples](./readme.proxy-protocol-example.md)** - Code examples and conceptual implementation
|
||||
- **[Proxy Chain Summary](./readme.proxy-chain-summary.md)** - Quick reference for proxy chaining setup
|
@ -98,26 +98,32 @@ This phase creates the socket wrapper infrastructure that all subsequent phases
|
||||
|
||||
**Deliverables**: ✅ Working WrappedSocket that can wrap any socket and provide transparent access to client info.
|
||||
|
||||
#### Phase 2: PROXY Protocol Parser - DEPENDS ON PHASE 1
|
||||
#### Phase 2: PROXY Protocol Parser - ✅ COMPLETED (v19.5.21)
|
||||
Only after WrappedSocket is working can we add protocol parsing.
|
||||
|
||||
1. Create `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
|
||||
2. Implement v1 text format parsing
|
||||
3. Add validation and error handling
|
||||
4. Integrate parser to work WITH WrappedSocket (not into it)
|
||||
1. ✅ Created `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
|
||||
2. ✅ Implemented v1 text format parsing with full validation
|
||||
3. ✅ Added comprehensive error handling and IP validation
|
||||
4. ✅ Integrated parser to work WITH WrappedSocket in RouteConnectionHandler
|
||||
|
||||
#### Phase 3: Connection Handler Integration - DEPENDS ON PHASES 1 & 2
|
||||
**Deliverables**: ✅ Working PROXY protocol v1 parser that validates headers, extracts client info, and handles both TCP4 and TCP6 protocols.
|
||||
|
||||
#### Phase 3: Connection Handler Integration - ✅ COMPLETED (v19.5.21)
|
||||
1. ✅ Modify `RouteConnectionHandler` to create WrappedSocket for all connections
|
||||
2. Check if connection is from trusted proxy IP
|
||||
3. If trusted, attempt to parse PROXY protocol header
|
||||
4. Update wrapped socket with real client info
|
||||
5. Continue normal connection handling with wrapped socket
|
||||
2. ✅ Check if connection is from trusted proxy IP
|
||||
3. ✅ If trusted, attempt to parse PROXY protocol header
|
||||
4. ✅ Update wrapped socket with real client info
|
||||
5. ✅ Continue normal connection handling with wrapped socket
|
||||
|
||||
#### Phase 4: Outbound PROXY Protocol - DEPENDS ON PHASES 1-3
|
||||
1. Add PROXY header generation in `setupDirectConnection`
|
||||
2. Make it configurable per route
|
||||
3. Send header immediately after TCP connection
|
||||
4. Use ProxyProtocolSocket for outbound connections too
|
||||
**Deliverables**: ✅ RouteConnectionHandler now parses PROXY protocol from trusted proxies and updates connection records with real client info.
|
||||
|
||||
#### Phase 4: Outbound PROXY Protocol - ✅ COMPLETED (v19.5.21)
|
||||
1. ✅ Add PROXY header generation in `setupDirectConnection`
|
||||
2. ✅ Make it configurable per route via `sendProxyProtocol` option
|
||||
3. ✅ Send header immediately after TCP connection
|
||||
4. ✅ Added remotePort tracking to connection records
|
||||
|
||||
**Deliverables**: ✅ SmartProxy can now send PROXY protocol headers to backend servers when configured, preserving client IP through proxy chains.
|
||||
|
||||
#### Phase 5: Security & Validation - FINAL PHASE
|
||||
1. Validate PROXY headers strictly
|
||||
@ -293,11 +299,10 @@ if (wrappedSocket instanceof ProxyProtocolSocket) {
|
||||
|
||||
### 5. Configuration Examples
|
||||
|
||||
#### Basic Setup
|
||||
#### Basic Setup (IMPLEMENTED ✅)
|
||||
```typescript
|
||||
// Outer proxy - sends PROXY protocol
|
||||
const outerProxy = new SmartProxy({
|
||||
ports: [443],
|
||||
routes: [{
|
||||
name: 'to-inner-proxy',
|
||||
match: { ports: 443 },
|
||||
@ -311,9 +316,8 @@ const outerProxy = new SmartProxy({
|
||||
|
||||
// Inner proxy - accepts PROXY protocol from outer proxy
|
||||
const innerProxy = new SmartProxy({
|
||||
ports: [443],
|
||||
proxyIPs: ['212.95.99.130'], // Outer proxy IP
|
||||
// acceptProxyProtocol: true is automatic for proxyIPs
|
||||
acceptProxyProtocol: true, // Optional - defaults to true when proxyIPs is set
|
||||
routes: [{
|
||||
name: 'to-backend',
|
||||
match: { ports: 443 },
|
||||
|
112
readme.proxy-chain-summary.md
Normal file
112
readme.proxy-chain-summary.md
Normal file
@ -0,0 +1,112 @@
|
||||
# SmartProxy: Proxy Protocol and Proxy Chaining Summary
|
||||
|
||||
## Quick Summary
|
||||
|
||||
SmartProxy supports proxy chaining through the **WrappedSocket** infrastructure, which is designed to handle PROXY protocol for preserving real client IP addresses across multiple proxy layers. While the infrastructure is in place (v19.5.19+), the actual PROXY protocol parsing is not yet implemented.
|
||||
|
||||
## Current State
|
||||
|
||||
### ✅ What's Implemented
|
||||
- **WrappedSocket class** - Foundation for proxy protocol support
|
||||
- **Proxy IP configuration** - `proxyIPs` setting to define trusted proxies
|
||||
- **Socket wrapping** - All incoming connections wrapped automatically
|
||||
- **Connection tracking** - Real client IP tracking in connection records
|
||||
- **Test infrastructure** - Tests for proxy chaining scenarios
|
||||
|
||||
### ❌ What's Missing
|
||||
- **PROXY protocol v1 parsing** - Header parsing not implemented
|
||||
- **PROXY protocol v2 support** - Binary format not supported
|
||||
- **Automatic header generation** - Must be manually implemented
|
||||
- **Production testing** - No HAProxy/AWS ELB compatibility tests
|
||||
|
||||
## Key Files
|
||||
|
||||
### Core Implementation
|
||||
- `ts/core/models/wrapped-socket.ts` - WrappedSocket class
|
||||
- `ts/core/models/socket-types.ts` - Helper functions
|
||||
- `ts/proxies/smart-proxy/route-connection-handler.ts` - Connection handling
|
||||
- `ts/proxies/smart-proxy/models/interfaces.ts` - Configuration interfaces
|
||||
|
||||
### Tests
|
||||
- `test/test.wrapped-socket.ts` - WrappedSocket unit tests
|
||||
- `test/test.proxy-chain-simple.node.ts` - Basic proxy chain test
|
||||
- `test/test.proxy-chaining-accumulation.node.ts` - Connection leak tests
|
||||
|
||||
### Documentation
|
||||
- `readme.proxy-protocol.md` - Detailed implementation guide
|
||||
- `readme.proxy-protocol-example.md` - Code examples and future implementation
|
||||
- `readme.hints.md` - Project overview with WrappedSocket notes
|
||||
|
||||
## Quick Configuration Example
|
||||
|
||||
```typescript
|
||||
// Outer proxy (internet-facing)
|
||||
const outerProxy = new SmartProxy({
|
||||
sendProxyProtocol: true, // Will send PROXY protocol (when implemented)
|
||||
routes: [{
|
||||
name: 'forward-to-inner',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'inner-proxy.local', port: 443 },
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Inner proxy (backend-facing)
|
||||
const innerProxy = new SmartProxy({
|
||||
proxyIPs: ['outer-proxy.local'], // Trust the outer proxy
|
||||
acceptProxyProtocol: true, // Will parse PROXY protocol (when implemented)
|
||||
routes: [{
|
||||
name: 'forward-to-backend',
|
||||
match: { ports: 443, domains: 'api.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend.local', port: 8080 },
|
||||
tls: { mode: 'terminate' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## How It Works (Conceptually)
|
||||
|
||||
1. **Client** connects to **Outer Proxy**
|
||||
2. **Outer Proxy** wraps socket in WrappedSocket
|
||||
3. **Outer Proxy** forwards to **Inner Proxy**
|
||||
- Would prepend: `PROXY TCP4 <client-ip> <proxy-ip> <client-port> <proxy-port>\r\n`
|
||||
4. **Inner Proxy** receives connection from trusted proxy
|
||||
5. **Inner Proxy** would parse PROXY protocol header
|
||||
6. **Inner Proxy** updates WrappedSocket with real client IP
|
||||
7. **Backend** receives connection with preserved client information
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Connection Cleanup
|
||||
The fix for proxy chain connection accumulation (v19.5.14+) changed the default socket behavior:
|
||||
- **Before**: Half-open connections supported by default (caused accumulation)
|
||||
- **After**: Both sockets close when one closes (prevents accumulation)
|
||||
- **Override**: Set `enableHalfOpen: true` if half-open needed
|
||||
|
||||
### Security
|
||||
- Only parse PROXY protocol from IPs listed in `proxyIPs`
|
||||
- Never use `0.0.0.0/0` as a trusted proxy range
|
||||
- Each proxy in chain must explicitly trust the previous proxy
|
||||
|
||||
### Testing
|
||||
Use the test files as reference implementations:
|
||||
- Simple chains: `test.proxy-chain-simple.node.ts`
|
||||
- Connection leaks: `test.proxy-chaining-accumulation.node.ts`
|
||||
- Rapid reconnects: `test.rapid-retry-cleanup.node.ts`
|
||||
|
||||
## Next Steps
|
||||
|
||||
To fully implement PROXY protocol support:
|
||||
1. Implement the parser in `ProxyProtocolParser` class
|
||||
2. Integrate parser into `handleConnection` method
|
||||
3. Add header generation to `setupDirectConnection`
|
||||
4. Test with real proxies (HAProxy, nginx, AWS ELB)
|
||||
5. Add PROXY protocol v2 support for better performance
|
||||
|
||||
See `readme.proxy-protocol-example.md` for detailed implementation examples.
|
462
readme.proxy-protocol-example.md
Normal file
462
readme.proxy-protocol-example.md
Normal file
@ -0,0 +1,462 @@
|
||||
# SmartProxy PROXY Protocol Implementation Example
|
||||
|
||||
This document shows how PROXY protocol parsing could be implemented in SmartProxy. Note that this is a conceptual implementation guide - the actual parsing is not yet implemented in the current version.
|
||||
|
||||
## Conceptual PROXY Protocol v1 Parser Implementation
|
||||
|
||||
### Parser Class
|
||||
|
||||
```typescript
|
||||
// This would go in ts/core/utils/proxy-protocol-parser.ts
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface IProxyProtocolInfo {
|
||||
version: 1 | 2;
|
||||
command: 'PROXY' | 'LOCAL';
|
||||
family: 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
sourceIP: string;
|
||||
destIP: string;
|
||||
sourcePort: number;
|
||||
destPort: number;
|
||||
headerLength: number;
|
||||
}
|
||||
|
||||
export class ProxyProtocolParser {
|
||||
private static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
||||
private static readonly MAX_V1_HEADER_LENGTH = 108; // Max possible v1 header
|
||||
|
||||
/**
|
||||
* Parse PROXY protocol v1 header from buffer
|
||||
* Returns null if not a valid PROXY protocol header
|
||||
*/
|
||||
static parseV1(buffer: Buffer): IProxyProtocolInfo | null {
|
||||
// Need at least 8 bytes for "PROXY " + newline
|
||||
if (buffer.length < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for v1 signature
|
||||
const possibleHeader = buffer.toString('ascii', 0, 6);
|
||||
if (possibleHeader !== this.PROXY_V1_SIGNATURE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of the header (CRLF)
|
||||
let headerEnd = -1;
|
||||
for (let i = 6; i < Math.min(buffer.length, this.MAX_V1_HEADER_LENGTH); i++) {
|
||||
if (buffer[i] === 0x0D && buffer[i + 1] === 0x0A) { // \r\n
|
||||
headerEnd = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerEnd === -1) {
|
||||
// No complete header found
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the header line
|
||||
const headerLine = buffer.toString('ascii', 0, headerEnd - 2);
|
||||
const parts = headerLine.split(' ');
|
||||
|
||||
if (parts.length !== 6) {
|
||||
logger.log('warn', 'Invalid PROXY v1 header format', {
|
||||
headerLine,
|
||||
partCount: parts.length
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const [proxy, family, srcIP, dstIP, srcPort, dstPort] = parts;
|
||||
|
||||
// Validate family
|
||||
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(family)) {
|
||||
logger.log('warn', 'Invalid PROXY protocol family', { family });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate ports
|
||||
const sourcePort = parseInt(srcPort);
|
||||
const destPort = parseInt(dstPort);
|
||||
|
||||
if (isNaN(sourcePort) || sourcePort < 1 || sourcePort > 65535 ||
|
||||
isNaN(destPort) || destPort < 1 || destPort > 65535) {
|
||||
logger.log('warn', 'Invalid PROXY protocol ports', { srcPort, dstPort });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
command: 'PROXY',
|
||||
family: family as 'TCP4' | 'TCP6' | 'UNKNOWN',
|
||||
sourceIP: srcIP,
|
||||
destIP: dstIP,
|
||||
sourcePort,
|
||||
destPort,
|
||||
headerLength: headerEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer potentially contains PROXY protocol
|
||||
*/
|
||||
static mightBeProxyProtocol(buffer: Buffer): boolean {
|
||||
if (buffer.length < 6) return false;
|
||||
|
||||
// Check for v1 signature
|
||||
const start = buffer.toString('ascii', 0, 6);
|
||||
if (start === this.PROXY_V1_SIGNATURE) return true;
|
||||
|
||||
// Check for v2 signature (12 bytes: \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A)
|
||||
if (buffer.length >= 12) {
|
||||
const v2Sig = Buffer.from([0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A]);
|
||||
if (buffer.compare(v2Sig, 0, 12, 0, 12) === 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with RouteConnectionHandler
|
||||
|
||||
```typescript
|
||||
// This shows how it would be integrated into route-connection-handler.ts
|
||||
|
||||
private async handleProxyProtocol(
|
||||
socket: plugins.net.Socket,
|
||||
wrappedSocket: WrappedSocket,
|
||||
record: IConnectionRecord
|
||||
): Promise<Buffer | null> {
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
|
||||
// Only parse PROXY protocol from trusted IPs
|
||||
if (!this.settings.proxyIPs?.includes(remoteIP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let headerParsed = false;
|
||||
|
||||
const parseHandler = (chunk: Buffer) => {
|
||||
// Accumulate data
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
// Try to parse PROXY protocol
|
||||
const proxyInfo = ProxyProtocolParser.parseV1(buffer);
|
||||
|
||||
if (proxyInfo) {
|
||||
// Update wrapped socket with real client info
|
||||
wrappedSocket.setProxyInfo(proxyInfo.sourceIP, proxyInfo.sourcePort);
|
||||
|
||||
// Update connection record
|
||||
record.remoteIP = proxyInfo.sourceIP;
|
||||
|
||||
logger.log('info', 'PROXY protocol parsed', {
|
||||
connectionId: record.id,
|
||||
realIP: proxyInfo.sourceIP,
|
||||
realPort: proxyInfo.sourcePort,
|
||||
proxyIP: remoteIP
|
||||
});
|
||||
|
||||
// Remove this handler
|
||||
socket.removeListener('data', parseHandler);
|
||||
headerParsed = true;
|
||||
|
||||
// Return remaining data after header
|
||||
const remaining = buffer.slice(proxyInfo.headerLength);
|
||||
resolve(remaining.length > 0 ? remaining : null);
|
||||
} else if (buffer.length > 108) {
|
||||
// Max v1 header length exceeded, not PROXY protocol
|
||||
socket.removeListener('data', parseHandler);
|
||||
headerParsed = true;
|
||||
resolve(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
// Set timeout for PROXY protocol parsing
|
||||
const timeout = setTimeout(() => {
|
||||
if (!headerParsed) {
|
||||
socket.removeListener('data', parseHandler);
|
||||
logger.log('warn', 'PROXY protocol parsing timeout', {
|
||||
connectionId: record.id,
|
||||
bufferLength: buffer.length
|
||||
});
|
||||
resolve(buffer.length > 0 ? buffer : null);
|
||||
}
|
||||
}, 1000); // 1 second timeout
|
||||
|
||||
socket.on('data', parseHandler);
|
||||
|
||||
// Clean up on early close
|
||||
socket.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
if (!headerParsed) {
|
||||
socket.removeListener('data', parseHandler);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Modified handleConnection to include PROXY protocol parsing
|
||||
public async handleConnection(socket: plugins.net.Socket): void {
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const localPort = socket.localPort || 0;
|
||||
|
||||
// Always wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(socket);
|
||||
|
||||
// Create connection record
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
if (!record) return;
|
||||
|
||||
// If from trusted proxy, parse PROXY protocol
|
||||
if (this.settings.proxyIPs?.includes(remoteIP)) {
|
||||
const remainingData = await this.handleProxyProtocol(socket, wrappedSocket, record);
|
||||
|
||||
if (remainingData) {
|
||||
// Process remaining data as normal
|
||||
this.handleInitialData(wrappedSocket, record, remainingData);
|
||||
} else {
|
||||
// Wait for more data
|
||||
this.handleInitialData(wrappedSocket, record);
|
||||
}
|
||||
} else {
|
||||
// Not from trusted proxy, handle normally
|
||||
this.handleInitialData(wrappedSocket, record);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sending PROXY Protocol When Forwarding
|
||||
|
||||
```typescript
|
||||
// This would be added to setupDirectConnection method
|
||||
|
||||
private setupDirectConnection(
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
serverName?: string,
|
||||
initialChunk?: Buffer,
|
||||
overridePort?: number,
|
||||
targetHost?: string,
|
||||
targetPort?: number
|
||||
): void {
|
||||
// ... existing code ...
|
||||
|
||||
// Create target socket
|
||||
const targetSocket = createSocketWithErrorHandler({
|
||||
port: finalTargetPort,
|
||||
host: finalTargetHost,
|
||||
onConnect: () => {
|
||||
// If sendProxyProtocol is enabled, send PROXY header first
|
||||
if (this.settings.sendProxyProtocol) {
|
||||
const proxyHeader = this.buildProxyProtocolHeader(wrappedSocket, targetSocket);
|
||||
targetSocket.write(proxyHeader);
|
||||
}
|
||||
|
||||
// Then send any pending data
|
||||
if (record.pendingData.length > 0) {
|
||||
const combinedData = Buffer.concat(record.pendingData);
|
||||
targetSocket.write(combinedData);
|
||||
}
|
||||
|
||||
// ... rest of connection setup ...
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildProxyProtocolHeader(
|
||||
clientSocket: WrappedSocket,
|
||||
serverSocket: net.Socket
|
||||
): Buffer {
|
||||
const family = clientSocket.remoteFamily === 'IPv6' ? 'TCP6' : 'TCP4';
|
||||
const srcIP = clientSocket.remoteAddress || '0.0.0.0';
|
||||
const srcPort = clientSocket.remotePort || 0;
|
||||
const dstIP = serverSocket.localAddress || '0.0.0.0';
|
||||
const dstPort = serverSocket.localPort || 0;
|
||||
|
||||
const header = `PROXY ${family} ${srcIP} ${dstIP} ${srcPort} ${dstPort}\r\n`;
|
||||
return Buffer.from(header, 'ascii');
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example: HAProxy Compatible Setup
|
||||
|
||||
```typescript
|
||||
// Example showing a complete HAProxy-compatible SmartProxy setup
|
||||
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
// Configuration matching HAProxy's proxy protocol behavior
|
||||
const proxy = new SmartProxy({
|
||||
// Accept PROXY protocol from these sources (like HAProxy's 'accept-proxy')
|
||||
proxyIPs: [
|
||||
'10.0.0.0/8', // Private network load balancers
|
||||
'172.16.0.0/12', // Docker networks
|
||||
'192.168.0.0/16' // Local networks
|
||||
],
|
||||
|
||||
// Send PROXY protocol to backends (like HAProxy's 'send-proxy')
|
||||
sendProxyProtocol: true,
|
||||
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: ['app.example.com', 'www.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'backend-pool.internal',
|
||||
port: 8080
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
acme: {
|
||||
email: 'ssl@example.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
|
||||
// The proxy will now:
|
||||
// 1. Accept connections on port 443
|
||||
// 2. Parse PROXY protocol from trusted IPs
|
||||
// 3. Terminate TLS
|
||||
// 4. Forward to backend with PROXY protocol header
|
||||
// 5. Backend sees real client IP
|
||||
```
|
||||
|
||||
## Testing PROXY Protocol
|
||||
|
||||
```typescript
|
||||
// Test client that sends PROXY protocol
|
||||
import * as net from 'net';
|
||||
|
||||
function createProxyProtocolClient(
|
||||
realClientIP: string,
|
||||
realClientPort: number,
|
||||
proxyHost: string,
|
||||
proxyPort: number
|
||||
): net.Socket {
|
||||
const client = net.connect(proxyPort, proxyHost);
|
||||
|
||||
client.on('connect', () => {
|
||||
// Send PROXY protocol header
|
||||
const header = `PROXY TCP4 ${realClientIP} ${proxyHost} ${realClientPort} ${proxyPort}\r\n`;
|
||||
client.write(header);
|
||||
|
||||
// Then send actual request
|
||||
client.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const client = createProxyProtocolClient(
|
||||
'203.0.113.45', // Real client IP
|
||||
54321, // Real client port
|
||||
'localhost', // Proxy host
|
||||
8080 // Proxy port
|
||||
);
|
||||
```
|
||||
|
||||
## AWS Network Load Balancer Example
|
||||
|
||||
```typescript
|
||||
// Configuration for AWS NLB with PROXY protocol v2
|
||||
const proxy = new SmartProxy({
|
||||
// AWS NLB IP ranges (get current list from AWS)
|
||||
proxyIPs: [
|
||||
'10.0.0.0/8', // VPC CIDR
|
||||
// Add specific NLB IPs or use AWS IP ranges
|
||||
],
|
||||
|
||||
// AWS NLB uses PROXY protocol v2 by default
|
||||
acceptProxyProtocolV2: true, // Future feature
|
||||
|
||||
routes: [{
|
||||
name: 'aws-app',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'app-cluster.internal',
|
||||
port: 8443
|
||||
},
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// The proxy will:
|
||||
// 1. Accept PROXY protocol v2 from AWS NLB
|
||||
// 2. Preserve VPC endpoint IDs and other metadata
|
||||
// 3. Forward to backend with real client information
|
||||
```
|
||||
|
||||
## Debugging PROXY Protocol
|
||||
|
||||
```typescript
|
||||
// Enable detailed logging to debug PROXY protocol parsing
|
||||
const proxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
proxyIPs: ['10.0.0.1'],
|
||||
|
||||
// Add custom logging for debugging
|
||||
routes: [{
|
||||
name: 'debug-route',
|
||||
match: { ports: 8080 },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket, context) => {
|
||||
console.log('Socket handler called with context:', {
|
||||
clientIp: context.clientIp, // Real IP from PROXY protocol
|
||||
port: context.port,
|
||||
connectionId: context.connectionId,
|
||||
timestamp: context.timestamp
|
||||
});
|
||||
|
||||
// Handle the socket...
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Always validate trusted proxy IPs** - Never accept PROXY protocol from untrusted sources
|
||||
2. **Use specific IP ranges** - Avoid wildcards like `0.0.0.0/0`
|
||||
3. **Implement rate limiting** - PROXY protocol parsing has a computational cost
|
||||
4. **Validate header format** - Reject malformed headers immediately
|
||||
5. **Set parsing timeouts** - Prevent slow loris attacks via PROXY headers
|
||||
6. **Log parsing failures** - Monitor for potential attacks or misconfigurations
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Header parsing overhead** - Minimal, one-time cost per connection
|
||||
2. **Memory usage** - Small buffer for header accumulation (max 108 bytes for v1)
|
||||
3. **Connection establishment** - Slight delay for PROXY protocol parsing
|
||||
4. **Throughput impact** - None after initial header parsing
|
||||
5. **CPU usage** - Negligible for well-formed headers
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **PROXY Protocol v2** - Binary format for better performance
|
||||
2. **TLS information preservation** - Pass TLS version, cipher, SNI via PP2
|
||||
3. **Custom type-length-value (TLV) fields** - Extended metadata support
|
||||
4. **Connection pooling** - Reuse backend connections with different client IPs
|
||||
5. **Health checks** - Skip PROXY protocol for health check connections
|
415
readme.proxy-protocol.md
Normal file
415
readme.proxy-protocol.md
Normal file
@ -0,0 +1,415 @@
|
||||
# SmartProxy PROXY Protocol and Proxy Chaining Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
SmartProxy implements support for the PROXY protocol v1 to enable proxy chaining and preserve real client IP addresses across multiple proxy layers. This documentation covers the implementation details, configuration, and usage patterns for proxy chaining scenarios.
|
||||
|
||||
## Architecture
|
||||
|
||||
### WrappedSocket Implementation
|
||||
|
||||
The foundation of PROXY protocol support is the `WrappedSocket` class, which wraps regular `net.Socket` instances to provide transparent access to real client information when behind a proxy.
|
||||
|
||||
```typescript
|
||||
// ts/core/models/wrapped-socket.ts
|
||||
export class WrappedSocket {
|
||||
public readonly socket: plugins.net.Socket;
|
||||
private realClientIP?: string;
|
||||
private realClientPort?: number;
|
||||
|
||||
constructor(
|
||||
socket: plugins.net.Socket,
|
||||
realClientIP?: string,
|
||||
realClientPort?: number
|
||||
) {
|
||||
this.socket = socket;
|
||||
this.realClientIP = realClientIP;
|
||||
this.realClientPort = realClientPort;
|
||||
|
||||
// Uses JavaScript Proxy to delegate all methods to underlying socket
|
||||
return new Proxy(this, {
|
||||
get(target, prop, receiver) {
|
||||
// Override specific properties
|
||||
if (prop === 'remoteAddress') {
|
||||
return target.remoteAddress;
|
||||
}
|
||||
if (prop === 'remotePort') {
|
||||
return target.remotePort;
|
||||
}
|
||||
// ... delegate other properties to underlying socket
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get remoteAddress(): string | undefined {
|
||||
return this.realClientIP || this.socket.remoteAddress;
|
||||
}
|
||||
|
||||
get remotePort(): number | undefined {
|
||||
return this.realClientPort || this.socket.remotePort;
|
||||
}
|
||||
|
||||
get isFromTrustedProxy(): boolean {
|
||||
return !!this.realClientIP;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **All sockets are wrapped** - Every incoming connection is wrapped in a WrappedSocket, not just those from trusted proxies
|
||||
2. **Proxy pattern for delegation** - Uses JavaScript Proxy to transparently delegate all Socket methods while allowing property overrides
|
||||
3. **Not a Duplex stream** - Simple wrapper approach avoids complexity and infinite loops
|
||||
4. **Trust-based parsing** - PROXY protocol parsing only occurs for connections from trusted proxy IPs
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic PROXY Protocol Configuration
|
||||
|
||||
```typescript
|
||||
const proxy = new SmartProxy({
|
||||
// List of trusted proxy IPs that can send PROXY protocol
|
||||
proxyIPs: ['10.0.0.1', '10.0.0.2', '192.168.1.0/24'],
|
||||
|
||||
// Global option to accept PROXY protocol (defaults based on proxyIPs)
|
||||
acceptProxyProtocol: true,
|
||||
|
||||
// Global option to send PROXY protocol to all targets
|
||||
sendProxyProtocol: false,
|
||||
|
||||
routes: [
|
||||
{
|
||||
name: 'backend-app',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend.internal', port: 8443 },
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Proxy Chain Configuration
|
||||
|
||||
Setting up two SmartProxies in a chain:
|
||||
|
||||
```typescript
|
||||
// Outer Proxy (Internet-facing)
|
||||
const outerProxy = new SmartProxy({
|
||||
proxyIPs: [], // No trusted proxies for outer proxy
|
||||
sendProxyProtocol: true, // Send PROXY protocol to inner proxy
|
||||
|
||||
routes: [{
|
||||
name: 'to-inner-proxy',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'inner-proxy.internal',
|
||||
port: 443
|
||||
},
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Inner Proxy (Backend-facing)
|
||||
const innerProxy = new SmartProxy({
|
||||
proxyIPs: ['outer-proxy.internal'], // Trust the outer proxy
|
||||
acceptProxyProtocol: true,
|
||||
|
||||
routes: [{
|
||||
name: 'to-backend',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'backend.internal',
|
||||
port: 8080
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## How Two SmartProxies Communicate
|
||||
|
||||
### Connection Flow
|
||||
|
||||
1. **Client connects to Outer Proxy**
|
||||
```
|
||||
Client (203.0.113.45:54321) → Outer Proxy (1.2.3.4:443)
|
||||
```
|
||||
|
||||
2. **Outer Proxy wraps the socket**
|
||||
```typescript
|
||||
// In RouteConnectionHandler.handleConnection()
|
||||
const wrappedSocket = new WrappedSocket(socket);
|
||||
// At this point:
|
||||
// wrappedSocket.remoteAddress = '203.0.113.45'
|
||||
// wrappedSocket.remotePort = 54321
|
||||
```
|
||||
|
||||
3. **Outer Proxy forwards to Inner Proxy**
|
||||
- Creates new connection to inner proxy
|
||||
- If `sendProxyProtocol` is enabled, prepends PROXY protocol header:
|
||||
```
|
||||
PROXY TCP4 203.0.113.45 1.2.3.4 54321 443\r\n
|
||||
[Original TLS/HTTP data follows]
|
||||
```
|
||||
|
||||
4. **Inner Proxy receives connection**
|
||||
- Sees connection from outer proxy IP
|
||||
- Checks if IP is in `proxyIPs` list
|
||||
- If trusted, parses PROXY protocol header
|
||||
- Updates WrappedSocket with real client info:
|
||||
```typescript
|
||||
wrappedSocket.setProxyInfo('203.0.113.45', 54321);
|
||||
```
|
||||
|
||||
5. **Inner Proxy routes based on real client IP**
|
||||
- Security checks use real client IP
|
||||
- Connection records track real client IP
|
||||
- Backend sees requests from the original client IP
|
||||
|
||||
### Connection Record Tracking
|
||||
|
||||
```typescript
|
||||
// In ConnectionManager
|
||||
interface IConnectionRecord {
|
||||
id: string;
|
||||
incoming: WrappedSocket; // Wrapped socket with real client info
|
||||
outgoing: net.Socket | null;
|
||||
remoteIP: string; // Real client IP from PROXY protocol or direct connection
|
||||
localPort: number;
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Socket Wrapping in Route Handler
|
||||
|
||||
```typescript
|
||||
// ts/proxies/smart-proxy/route-connection-handler.ts
|
||||
public handleConnection(socket: plugins.net.Socket): void {
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
|
||||
// Always wrap the socket to prepare for potential PROXY protocol
|
||||
const wrappedSocket = new WrappedSocket(socket);
|
||||
|
||||
// If this is from a trusted proxy, log it
|
||||
if (this.settings.proxyIPs?.includes(remoteIP)) {
|
||||
logger.log('debug', `Connection from trusted proxy ${remoteIP}, PROXY protocol parsing will be enabled`);
|
||||
}
|
||||
|
||||
// Create connection record with wrapped socket
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
|
||||
// Continue with normal connection handling...
|
||||
}
|
||||
```
|
||||
|
||||
### Socket Utility Integration
|
||||
|
||||
When passing wrapped sockets to socket utility functions, the underlying socket must be extracted:
|
||||
|
||||
```typescript
|
||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||
|
||||
// In setupDirectConnection()
|
||||
const incomingSocket = getUnderlyingSocket(socket); // Extract raw socket
|
||||
|
||||
setupBidirectionalForwarding(incomingSocket, targetSocket, {
|
||||
onClientData: (chunk) => {
|
||||
record.bytesReceived += chunk.length;
|
||||
},
|
||||
onServerData: (chunk) => {
|
||||
record.bytesSent += chunk.length;
|
||||
},
|
||||
onCleanup: (reason) => {
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
},
|
||||
enableHalfOpen: false // Required for proxy chains
|
||||
});
|
||||
```
|
||||
|
||||
## Current Status and Limitations
|
||||
|
||||
### Implemented (v19.5.19+)
|
||||
- ✅ WrappedSocket foundation class
|
||||
- ✅ Socket wrapping in connection handler
|
||||
- ✅ Connection manager support for wrapped sockets
|
||||
- ✅ Socket utility integration helpers
|
||||
- ✅ Proxy IP configuration options
|
||||
|
||||
### Not Yet Implemented
|
||||
- ❌ PROXY protocol v1 header parsing
|
||||
- ❌ PROXY protocol v2 binary format support
|
||||
- ❌ Automatic PROXY protocol header generation when forwarding
|
||||
- ❌ HAProxy compatibility testing
|
||||
- ❌ AWS ELB/NLB compatibility testing
|
||||
|
||||
### Known Issues
|
||||
1. **No actual PROXY protocol parsing** - The infrastructure is in place but the protocol parsing is not yet implemented
|
||||
2. **Manual configuration required** - No automatic detection of PROXY protocol support
|
||||
3. **Limited to TCP connections** - WebSocket connections through proxy chains may not preserve client IPs
|
||||
|
||||
## Testing Proxy Chains
|
||||
|
||||
### Basic Proxy Chain Test
|
||||
|
||||
```typescript
|
||||
// test/test.proxy-chain-simple.node.ts
|
||||
tap.test('simple proxy chain test', async () => {
|
||||
// Create backend server
|
||||
const backend = net.createServer((socket) => {
|
||||
console.log('Backend: Connection received');
|
||||
socket.write('HTTP/1.1 200 OK\r\n\r\nHello from backend');
|
||||
socket.end();
|
||||
});
|
||||
|
||||
// Create inner proxy (downstream)
|
||||
const innerProxy = new SmartProxy({
|
||||
proxyIPs: ['127.0.0.1'], // Trust localhost for testing
|
||||
routes: [{
|
||||
name: 'to-backend',
|
||||
match: { ports: 8591 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9999 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Create outer proxy (upstream)
|
||||
const outerProxy = new SmartProxy({
|
||||
sendProxyProtocol: true, // Send PROXY to inner
|
||||
routes: [{
|
||||
name: 'to-inner',
|
||||
match: { ports: 8590 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8591 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Test connection through chain
|
||||
const client = net.connect(8590, 'localhost');
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
|
||||
// Verify no connection accumulation
|
||||
const counts = getConnectionCounts();
|
||||
expect(counts.proxy1).toEqual(0);
|
||||
expect(counts.proxy2).toEqual(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Configure Trusted Proxies
|
||||
```typescript
|
||||
// Be specific about which IPs can send PROXY protocol
|
||||
proxyIPs: ['10.0.0.1', '10.0.0.2'], // Good
|
||||
proxyIPs: ['0.0.0.0/0'], // Bad - trusts everyone
|
||||
```
|
||||
|
||||
### 2. Use CIDR Notation for Subnets
|
||||
```typescript
|
||||
proxyIPs: [
|
||||
'10.0.0.0/24', // Trust entire subnet
|
||||
'192.168.1.5', // Trust specific IP
|
||||
'172.16.0.0/16' // Trust private network
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Enable Half-Open Only When Needed
|
||||
```typescript
|
||||
// For proxy chains, always disable half-open
|
||||
setupBidirectionalForwarding(client, server, {
|
||||
enableHalfOpen: false // Ensures proper cascade cleanup
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Monitor Connection Counts
|
||||
```typescript
|
||||
// Regular monitoring prevents connection leaks
|
||||
setInterval(() => {
|
||||
const stats = proxy.getStatistics();
|
||||
console.log(`Active connections: ${stats.activeConnections}`);
|
||||
if (stats.activeConnections > 1000) {
|
||||
console.warn('High connection count detected');
|
||||
}
|
||||
}, 60000);
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2: PROXY Protocol v1 Parser
|
||||
```typescript
|
||||
// Planned implementation
|
||||
class ProxyProtocolParser {
|
||||
static parse(buffer: Buffer): ProxyInfo | null {
|
||||
// Parse "PROXY TCP4 <src-ip> <dst-ip> <src-port> <dst-port>\r\n"
|
||||
const header = buffer.toString('ascii', 0, 108);
|
||||
const match = header.match(/^PROXY (TCP4|TCP6) (\S+) (\S+) (\d+) (\d+)\r\n/);
|
||||
if (match) {
|
||||
return {
|
||||
protocol: match[1],
|
||||
sourceIP: match[2],
|
||||
destIP: match[3],
|
||||
sourcePort: parseInt(match[4]),
|
||||
destPort: parseInt(match[5]),
|
||||
headerLength: match[0].length
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Automatic PROXY Protocol Detection
|
||||
- Peek at first bytes to detect PROXY protocol signature
|
||||
- Automatic fallback to direct connection if not present
|
||||
- Configurable timeout for protocol detection
|
||||
|
||||
### Phase 4: PROXY Protocol v2 Support
|
||||
- Binary protocol format for better performance
|
||||
- Additional metadata support (TLS info, ALPN, etc.)
|
||||
- AWS VPC endpoint ID preservation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Accumulation in Proxy Chains
|
||||
If connections accumulate when chaining proxies:
|
||||
1. Verify `enableHalfOpen: false` in socket forwarding
|
||||
2. Check that both proxies have proper cleanup handlers
|
||||
3. Monitor with connection count logging
|
||||
4. Use `test.proxy-chain-simple.node.ts` as reference
|
||||
|
||||
### Real Client IP Not Preserved
|
||||
If the backend sees proxy IP instead of client IP:
|
||||
1. Verify outer proxy has `sendProxyProtocol: true`
|
||||
2. Verify inner proxy has outer proxy IP in `proxyIPs` list
|
||||
3. Check logs for "Connection from trusted proxy" message
|
||||
4. Ensure PROXY protocol parsing is implemented (currently pending)
|
||||
|
||||
### Performance Impact
|
||||
PROXY protocol adds minimal overhead:
|
||||
- One-time parsing cost per connection
|
||||
- Small memory overhead for real client info storage
|
||||
- No impact on data transfer performance
|
||||
- Negligible CPU impact for header generation
|
||||
|
||||
## Related Documentation
|
||||
- [Socket Utilities](./ts/core/utils/socket-utils.ts) - Low-level socket handling
|
||||
- [Connection Manager](./ts/proxies/smart-proxy/connection-manager.ts) - Connection lifecycle
|
||||
- [Route Handler](./ts/proxies/smart-proxy/route-connection-handler.ts) - Request routing
|
||||
- [Test Suite](./test/test.wrapped-socket.ts) - WrappedSocket unit tests
|
182
test/test.proxy-chain-cleanup.node.ts
Normal file
182
test/test.proxy-chain-cleanup.node.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
let outerProxy: SmartProxy;
|
||||
let innerProxy: SmartProxy;
|
||||
|
||||
tap.test('setup two smartproxies in a chain configuration', async () => {
|
||||
// Setup inner proxy (backend proxy)
|
||||
innerProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
match: {
|
||||
ports: 8002
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'httpbin.org',
|
||||
port: 443
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'httpbin.org',
|
||||
port: 443
|
||||
}
|
||||
},
|
||||
acceptProxyProtocol: true,
|
||||
sendProxyProtocol: false,
|
||||
enableDetailedLogging: true,
|
||||
connectionCleanupInterval: 5000, // More frequent cleanup for testing
|
||||
inactivityTimeout: 10000 // Shorter timeout for testing
|
||||
});
|
||||
await innerProxy.start();
|
||||
|
||||
// Setup outer proxy (frontend proxy)
|
||||
outerProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
match: {
|
||||
ports: 8001
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8002
|
||||
},
|
||||
sendProxyProtocol: true
|
||||
}
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8002
|
||||
}
|
||||
},
|
||||
sendProxyProtocol: true,
|
||||
enableDetailedLogging: true,
|
||||
connectionCleanupInterval: 5000, // More frequent cleanup for testing
|
||||
inactivityTimeout: 10000 // Shorter timeout for testing
|
||||
});
|
||||
await outerProxy.start();
|
||||
});
|
||||
|
||||
tap.test('should properly cleanup connections in proxy chain', async (tools) => {
|
||||
const testDuration = 30000; // 30 seconds
|
||||
const connectionInterval = 500; // Create new connection every 500ms
|
||||
const connectionDuration = 2000; // Each connection lasts 2 seconds
|
||||
|
||||
let connectionsCreated = 0;
|
||||
let connectionsCompleted = 0;
|
||||
|
||||
// Function to create a test connection
|
||||
const createTestConnection = async () => {
|
||||
connectionsCreated++;
|
||||
const connectionId = connectionsCreated;
|
||||
|
||||
try {
|
||||
const socket = plugins.net.connect({
|
||||
port: 8001,
|
||||
host: 'localhost'
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.on('connect', () => {
|
||||
console.log(`Connection ${connectionId} established`);
|
||||
|
||||
// Send TLS Client Hello for httpbin.org
|
||||
const clientHello = Buffer.from([
|
||||
0x16, 0x03, 0x01, 0x00, 0xc8, // TLS handshake header
|
||||
0x01, 0x00, 0x00, 0xc4, // Client Hello
|
||||
0x03, 0x03, // TLS 1.2
|
||||
...Array(32).fill(0), // Random bytes
|
||||
0x00, // Session ID length
|
||||
0x00, 0x02, 0x13, 0x01, // Cipher suites
|
||||
0x01, 0x00, // Compression methods
|
||||
0x00, 0x97, // Extensions length
|
||||
0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, // SNI extension
|
||||
0x00, 0x00, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x62, 0x69, 0x6e, 0x2e, 0x6f, 0x72, 0x67 // "httpbin.org"
|
||||
]);
|
||||
|
||||
socket.write(clientHello);
|
||||
|
||||
// Keep connection alive for specified duration
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
connectionsCompleted++;
|
||||
console.log(`Connection ${connectionId} closed (completed: ${connectionsCompleted}/${connectionsCreated})`);
|
||||
resolve();
|
||||
}, connectionDuration);
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.log(`Connection ${connectionId} error: ${err.message}`);
|
||||
connectionsCompleted++;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`Failed to create connection ${connectionId}: ${err.message}`);
|
||||
connectionsCompleted++;
|
||||
}
|
||||
};
|
||||
|
||||
// Start creating connections
|
||||
const startTime = Date.now();
|
||||
const connectionTimer = setInterval(() => {
|
||||
if (Date.now() - startTime < testDuration) {
|
||||
createTestConnection().catch(() => {});
|
||||
} else {
|
||||
clearInterval(connectionTimer);
|
||||
}
|
||||
}, connectionInterval);
|
||||
|
||||
// Monitor connection counts
|
||||
const monitorInterval = setInterval(() => {
|
||||
const outerConnections = (outerProxy as any).connectionManager.getConnectionCount();
|
||||
const innerConnections = (innerProxy as any).connectionManager.getConnectionCount();
|
||||
|
||||
console.log(`Active connections - Outer: ${outerConnections}, Inner: ${innerConnections}, Created: ${connectionsCreated}, Completed: ${connectionsCompleted}`);
|
||||
}, 2000);
|
||||
|
||||
// Wait for test duration + cleanup time
|
||||
await tools.delayFor(testDuration + 10000);
|
||||
|
||||
clearInterval(connectionTimer);
|
||||
clearInterval(monitorInterval);
|
||||
|
||||
// Wait for all connections to complete
|
||||
while (connectionsCompleted < connectionsCreated) {
|
||||
await tools.delayFor(100);
|
||||
}
|
||||
|
||||
// Give some time for cleanup
|
||||
await tools.delayFor(5000);
|
||||
|
||||
// Check final connection counts
|
||||
const finalOuterConnections = (outerProxy as any).connectionManager.getConnectionCount();
|
||||
const finalInnerConnections = (innerProxy as any).connectionManager.getConnectionCount();
|
||||
|
||||
console.log(`\nFinal connection counts:`);
|
||||
console.log(`Outer proxy: ${finalOuterConnections}`);
|
||||
console.log(`Inner proxy: ${finalInnerConnections}`);
|
||||
console.log(`Total created: ${connectionsCreated}`);
|
||||
console.log(`Total completed: ${connectionsCompleted}`);
|
||||
|
||||
// Both proxies should have cleaned up all connections
|
||||
expect(finalOuterConnections).toEqual(0);
|
||||
expect(finalInnerConnections).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('cleanup proxies', async () => {
|
||||
await outerProxy.stop();
|
||||
await innerProxy.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
133
test/test.proxy-protocol.ts
Normal file
133
test/test.proxy-protocol.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
import { ProxyProtocolParser } from '../ts/core/utils/proxy-protocol.js';
|
||||
|
||||
tap.test('PROXY protocol v1 parser - valid headers', async () => {
|
||||
// Test TCP4 format
|
||||
const tcp4Header = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii');
|
||||
const tcp4Result = ProxyProtocolParser.parse(tcp4Header);
|
||||
|
||||
expect(tcp4Result.proxyInfo).property('protocol').toEqual('TCP4');
|
||||
expect(tcp4Result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
|
||||
expect(tcp4Result.proxyInfo).property('sourcePort').toEqual(56324);
|
||||
expect(tcp4Result.proxyInfo).property('destinationIP').toEqual('10.0.0.1');
|
||||
expect(tcp4Result.proxyInfo).property('destinationPort').toEqual(443);
|
||||
expect(tcp4Result.remainingData.length).toEqual(0);
|
||||
|
||||
// Test TCP6 format
|
||||
const tcp6Header = Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n', 'ascii');
|
||||
const tcp6Result = ProxyProtocolParser.parse(tcp6Header);
|
||||
|
||||
expect(tcp6Result.proxyInfo).property('protocol').toEqual('TCP6');
|
||||
expect(tcp6Result.proxyInfo).property('sourceIP').toEqual('2001:db8::1');
|
||||
expect(tcp6Result.proxyInfo).property('sourcePort').toEqual(56324);
|
||||
expect(tcp6Result.proxyInfo).property('destinationIP').toEqual('2001:db8::2');
|
||||
expect(tcp6Result.proxyInfo).property('destinationPort').toEqual(443);
|
||||
|
||||
// Test UNKNOWN protocol
|
||||
const unknownHeader = Buffer.from('PROXY UNKNOWN\r\n', 'ascii');
|
||||
const unknownResult = ProxyProtocolParser.parse(unknownHeader);
|
||||
|
||||
expect(unknownResult.proxyInfo).property('protocol').toEqual('UNKNOWN');
|
||||
expect(unknownResult.proxyInfo).property('sourceIP').toEqual('');
|
||||
expect(unknownResult.proxyInfo).property('sourcePort').toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('PROXY protocol v1 parser - with remaining data', async () => {
|
||||
const headerWithData = Buffer.concat([
|
||||
Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'),
|
||||
Buffer.from('GET / HTTP/1.1\r\n', 'ascii')
|
||||
]);
|
||||
|
||||
const result = ProxyProtocolParser.parse(headerWithData);
|
||||
|
||||
expect(result.proxyInfo).property('protocol').toEqual('TCP4');
|
||||
expect(result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
|
||||
expect(result.remainingData.toString()).toEqual('GET / HTTP/1.1\r\n');
|
||||
});
|
||||
|
||||
tap.test('PROXY protocol v1 parser - invalid headers', async () => {
|
||||
// Not a PROXY protocol header
|
||||
const notProxy = Buffer.from('GET / HTTP/1.1\r\n', 'ascii');
|
||||
const notProxyResult = ProxyProtocolParser.parse(notProxy);
|
||||
expect(notProxyResult.proxyInfo).toBeNull();
|
||||
expect(notProxyResult.remainingData).toEqual(notProxy);
|
||||
|
||||
// Invalid protocol
|
||||
expect(() => {
|
||||
ProxyProtocolParser.parse(Buffer.from('PROXY INVALID 1.1.1.1 2.2.2.2 80 443\r\n', 'ascii'));
|
||||
}).toThrow();
|
||||
|
||||
// Wrong number of fields
|
||||
expect(() => {
|
||||
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324\r\n', 'ascii'));
|
||||
}).toThrow();
|
||||
|
||||
// Invalid port
|
||||
expect(() => {
|
||||
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n', 'ascii'));
|
||||
}).toThrow();
|
||||
|
||||
// Invalid IP for protocol
|
||||
expect(() => {
|
||||
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 2001:db8::1 10.0.0.1 56324 443\r\n', 'ascii'));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
tap.test('PROXY protocol v1 parser - incomplete headers', async () => {
|
||||
// Header without terminator
|
||||
const incomplete = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443', 'ascii');
|
||||
const result = ProxyProtocolParser.parse(incomplete);
|
||||
|
||||
expect(result.proxyInfo).toBeNull();
|
||||
expect(result.remainingData).toEqual(incomplete);
|
||||
|
||||
// Header exceeding max length - create a buffer that actually starts with PROXY
|
||||
const longHeader = Buffer.from('PROXY TCP4 ' + '1'.repeat(100), 'ascii');
|
||||
expect(() => {
|
||||
ProxyProtocolParser.parse(longHeader);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
tap.test('PROXY protocol v1 generator', async () => {
|
||||
// Generate TCP4 header
|
||||
const tcp4Info = {
|
||||
protocol: 'TCP4' as const,
|
||||
sourceIP: '192.168.1.1',
|
||||
sourcePort: 56324,
|
||||
destinationIP: '10.0.0.1',
|
||||
destinationPort: 443
|
||||
};
|
||||
|
||||
const tcp4Header = ProxyProtocolParser.generate(tcp4Info);
|
||||
expect(tcp4Header.toString('ascii')).toEqual('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n');
|
||||
|
||||
// Generate TCP6 header
|
||||
const tcp6Info = {
|
||||
protocol: 'TCP6' as const,
|
||||
sourceIP: '2001:db8::1',
|
||||
sourcePort: 56324,
|
||||
destinationIP: '2001:db8::2',
|
||||
destinationPort: 443
|
||||
};
|
||||
|
||||
const tcp6Header = ProxyProtocolParser.generate(tcp6Info);
|
||||
expect(tcp6Header.toString('ascii')).toEqual('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n');
|
||||
|
||||
// Generate UNKNOWN header
|
||||
const unknownInfo = {
|
||||
protocol: 'UNKNOWN' as const,
|
||||
sourceIP: '',
|
||||
sourcePort: 0,
|
||||
destinationIP: '',
|
||||
destinationPort: 0
|
||||
};
|
||||
|
||||
const unknownHeader = ProxyProtocolParser.generate(unknownInfo);
|
||||
expect(unknownHeader.toString('ascii')).toEqual('PROXY UNKNOWN\r\n');
|
||||
});
|
||||
|
||||
// Skipping integration tests for now - focus on unit tests
|
||||
// Integration tests would require more complex setup and teardown
|
||||
|
||||
tap.start();
|
@ -15,3 +15,4 @@ export * from './lifecycle-component.js';
|
||||
export * from './binary-heap.js';
|
||||
export * from './enhanced-connection-pool.js';
|
||||
export * from './socket-utils.js';
|
||||
export * from './proxy-protocol.js';
|
||||
|
246
ts/core/utils/proxy-protocol.ts
Normal file
246
ts/core/utils/proxy-protocol.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Interface representing parsed PROXY protocol information
|
||||
*/
|
||||
export interface IProxyInfo {
|
||||
protocol: 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
sourceIP: string;
|
||||
sourcePort: number;
|
||||
destinationIP: string;
|
||||
destinationPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parse result including remaining data
|
||||
*/
|
||||
export interface IProxyParseResult {
|
||||
proxyInfo: IProxyInfo | null;
|
||||
remainingData: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser for PROXY protocol v1 (text format)
|
||||
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
||||
*/
|
||||
export class ProxyProtocolParser {
|
||||
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
||||
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
|
||||
static readonly HEADER_TERMINATOR = '\r\n';
|
||||
|
||||
/**
|
||||
* Parse PROXY protocol v1 header from buffer
|
||||
* Returns proxy info and remaining data after header
|
||||
*/
|
||||
static parse(data: Buffer): IProxyParseResult {
|
||||
// Check if buffer starts with PROXY signature
|
||||
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Find header terminator
|
||||
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
|
||||
if (headerEndIndex === -1) {
|
||||
// Header incomplete, need more data
|
||||
if (data.length > this.MAX_HEADER_LENGTH) {
|
||||
// Header too long, invalid
|
||||
throw new Error('PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Extract header line
|
||||
const headerLine = data.toString('ascii', 0, headerEndIndex);
|
||||
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
|
||||
|
||||
// Parse header
|
||||
const parts = headerLine.split(' ');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [signature, protocol] = parts;
|
||||
|
||||
// Validate protocol
|
||||
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
|
||||
throw new Error(`Invalid PROXY protocol: ${protocol}`);
|
||||
}
|
||||
|
||||
// For UNKNOWN protocol, ignore addresses
|
||||
if (protocol === 'UNKNOWN') {
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: 'UNKNOWN',
|
||||
sourceIP: '',
|
||||
sourcePort: 0,
|
||||
destinationIP: '',
|
||||
destinationPort: 0
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
}
|
||||
|
||||
// For TCP4/TCP6, we need all 6 parts
|
||||
if (parts.length !== 6) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
|
||||
|
||||
// Validate and parse ports
|
||||
const sourcePort = parseInt(srcPort, 10);
|
||||
const destinationPort = parseInt(dstPort, 10);
|
||||
|
||||
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
|
||||
throw new Error(`Invalid source port: ${srcPort}`);
|
||||
}
|
||||
|
||||
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
|
||||
throw new Error(`Invalid destination port: ${dstPort}`);
|
||||
}
|
||||
|
||||
// Validate IP addresses
|
||||
const protocolType = protocol as 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
if (!this.isValidIP(srcIP, protocolType)) {
|
||||
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
|
||||
}
|
||||
|
||||
if (!this.isValidIP(dstIP, protocolType)) {
|
||||
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
|
||||
}
|
||||
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: protocol as 'TCP4' | 'TCP6',
|
||||
sourceIP: srcIP,
|
||||
sourcePort,
|
||||
destinationIP: dstIP,
|
||||
destinationPort
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PROXY protocol v1 header
|
||||
*/
|
||||
static generate(info: IProxyInfo): Buffer {
|
||||
if (info.protocol === 'UNKNOWN') {
|
||||
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
|
||||
}
|
||||
|
||||
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
|
||||
|
||||
if (header.length > this.MAX_HEADER_LENGTH) {
|
||||
throw new Error('Generated PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
|
||||
return Buffer.from(header, 'ascii');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
*/
|
||||
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
|
||||
if (protocol === 'TCP4') {
|
||||
return plugins.net.isIPv4(ip);
|
||||
} else if (protocol === 'TCP6') {
|
||||
return plugins.net.isIPv6(ip);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to read a complete PROXY protocol header from a socket
|
||||
* Returns null if no PROXY protocol detected or incomplete
|
||||
*/
|
||||
static async readFromSocket(socket: plugins.net.Socket, timeout: number = 5000): Promise<IProxyParseResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
proxyInfo: null,
|
||||
remainingData: buffer
|
||||
});
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
// Check if we have enough data
|
||||
if (!buffer.toString('ascii', 0, Math.min(6, buffer.length)).startsWith(this.PROXY_V1_SIGNATURE)) {
|
||||
// Not PROXY protocol
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
proxyInfo: null,
|
||||
remainingData: buffer
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse
|
||||
try {
|
||||
const result = this.parse(buffer);
|
||||
if (result.proxyInfo) {
|
||||
// Successfully parsed
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
} else if (buffer.length > this.MAX_HEADER_LENGTH) {
|
||||
// Header too long
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
proxyInfo: null,
|
||||
remainingData: buffer
|
||||
});
|
||||
}
|
||||
// Otherwise continue reading
|
||||
} catch (error) {
|
||||
// Parse error
|
||||
logger.log('error', `PROXY protocol parse error: ${error.message}`);
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
proxyInfo: null,
|
||||
remainingData: buffer
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
logger.log('error', `Socket error while reading PROXY protocol: ${error.message}`);
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
proxyInfo: null,
|
||||
remainingData: buffer
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', onError);
|
||||
});
|
||||
}
|
||||
}
|
@ -258,22 +258,61 @@ export function createSocketWithErrorHandler(options: SafeSocketOptions): plugin
|
||||
// Create socket with immediate error handler attachment
|
||||
const socket = new plugins.net.Socket();
|
||||
|
||||
// Track if connected
|
||||
let connected = false;
|
||||
let connectionTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Attach error handler BEFORE connecting to catch immediate errors
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
|
||||
// Clear the connection timeout if it exists
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach connect handler if provided
|
||||
if (onConnect) {
|
||||
socket.on('connect', onConnect);
|
||||
}
|
||||
// Attach connect handler
|
||||
const handleConnect = () => {
|
||||
connected = true;
|
||||
// Clear the connection timeout
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
// Set inactivity timeout if provided (after connection is established)
|
||||
if (timeout) {
|
||||
socket.setTimeout(timeout);
|
||||
}
|
||||
if (onConnect) {
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
|
||||
// Set timeout if provided
|
||||
socket.on('connect', handleConnect);
|
||||
|
||||
// Implement connection establishment timeout
|
||||
if (timeout) {
|
||||
socket.setTimeout(timeout);
|
||||
connectionTimeout = setTimeout(() => {
|
||||
if (!connected && !socket.destroyed) {
|
||||
// Connection timed out - destroy the socket
|
||||
const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`);
|
||||
(error as any).code = 'ETIMEDOUT';
|
||||
|
||||
console.error(`Socket connection timeout to ${host}:${port} after ${timeout}ms`);
|
||||
|
||||
// Destroy the socket
|
||||
socket.destroy();
|
||||
|
||||
// Call error handler
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
// Now attempt to connect - any immediate errors will be caught
|
||||
|
@ -70,6 +70,7 @@ export class ConnectionManager extends LifecycleComponent {
|
||||
|
||||
const connectionId = this.generateConnectionId();
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const remotePort = socket.remotePort || 0;
|
||||
const localPort = socket.localPort || 0;
|
||||
const now = Date.now();
|
||||
|
||||
@ -85,6 +86,7 @@ export class ConnectionManager extends LifecycleComponent {
|
||||
bytesReceived: 0,
|
||||
bytesSent: 0,
|
||||
remoteIP,
|
||||
remotePort,
|
||||
localPort,
|
||||
isTLS: false,
|
||||
tlsHandshakeComplete: false,
|
||||
|
@ -69,6 +69,7 @@ export interface ISmartProxyOptions {
|
||||
maxVersion?: string;
|
||||
|
||||
// Timeout settings
|
||||
connectionTimeout?: number; // Timeout for establishing connection to backend (ms), default: 30000 (30s)
|
||||
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
|
||||
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
|
||||
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
|
||||
@ -151,6 +152,7 @@ export interface IConnectionRecord {
|
||||
bytesReceived: number; // Total bytes received
|
||||
bytesSent: number; // Total bytes sent
|
||||
remoteIP: string; // Remote IP (cached for logging after socket close)
|
||||
remotePort: number; // Remote port (cached for logging after socket close)
|
||||
localPort: number; // Local port (cached for logging)
|
||||
isTLS: boolean; // Whether this connection is a TLS connection
|
||||
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
|
||||
|
@ -250,6 +250,9 @@ export interface IRouteAction {
|
||||
|
||||
// Socket handler function (when type is 'socket-handler')
|
||||
socketHandler?: TSocketHandler;
|
||||
|
||||
// PROXY protocol support
|
||||
sendProxyProtocol?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -13,6 +13,7 @@ import { SharedRouteManager as RouteManager } from '../../core/routing/route-man
|
||||
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
|
||||
|
||||
/**
|
||||
* Handles new connection processing and setup logic with support for route-based configuration
|
||||
@ -198,25 +199,29 @@ export class RouteConnectionHandler {
|
||||
setupSocketHandlers(
|
||||
underlyingSocket,
|
||||
(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}`, {
|
||||
// Always cleanup when incoming socket closes
|
||||
// This prevents connection accumulation in proxy chains
|
||||
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 or established outgoing connection, destroy it
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
logger.log('debug', `Destroying outgoing connection for ${connectionId}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
hasOutgoing: !!record.outgoing,
|
||||
outgoingState: record.outgoing?.readyState,
|
||||
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);
|
||||
record.outgoing.destroy();
|
||||
}
|
||||
|
||||
// Always cleanup the connection record
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
},
|
||||
undefined, // Use default timeout handler
|
||||
'immediate-route-client'
|
||||
@ -295,17 +300,8 @@ export class RouteConnectionHandler {
|
||||
}
|
||||
});
|
||||
|
||||
// First data handler to capture initial TLS handshake
|
||||
socket.once('data', (chunk: Buffer) => {
|
||||
// Clear the initial timeout since we've received data
|
||||
if (initialTimeout) {
|
||||
clearTimeout(initialTimeout);
|
||||
initialTimeout = null;
|
||||
}
|
||||
|
||||
initialDataReceived = true;
|
||||
record.hasReceivedInitialData = true;
|
||||
|
||||
// Handler for processing initial data (after potential PROXY protocol)
|
||||
const processInitialData = (chunk: Buffer) => {
|
||||
// Block non-TLS connections on port 443
|
||||
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
||||
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||
@ -381,6 +377,67 @@ export class RouteConnectionHandler {
|
||||
|
||||
// Find the appropriate route for this connection
|
||||
this.routeConnection(socket, record, serverName, chunk);
|
||||
};
|
||||
|
||||
// First data handler to capture initial TLS handshake or PROXY protocol
|
||||
socket.once('data', async (chunk: Buffer) => {
|
||||
// Clear the initial timeout since we've received data
|
||||
if (initialTimeout) {
|
||||
clearTimeout(initialTimeout);
|
||||
initialTimeout = null;
|
||||
}
|
||||
|
||||
initialDataReceived = true;
|
||||
record.hasReceivedInitialData = true;
|
||||
|
||||
// Check if this is from a trusted proxy and might have PROXY protocol
|
||||
if (this.settings.proxyIPs?.includes(socket.remoteAddress || '') && this.settings.acceptProxyProtocol !== false) {
|
||||
// Check if this starts with PROXY protocol
|
||||
if (chunk.toString('ascii', 0, Math.min(6, chunk.length)).startsWith('PROXY ')) {
|
||||
try {
|
||||
const parseResult = ProxyProtocolParser.parse(chunk);
|
||||
|
||||
if (parseResult.proxyInfo) {
|
||||
// Update the wrapped socket with real client info (if it's a WrappedSocket)
|
||||
if (socket instanceof WrappedSocket) {
|
||||
socket.setProxyInfo(parseResult.proxyInfo.sourceIP, parseResult.proxyInfo.sourcePort);
|
||||
}
|
||||
|
||||
// Update connection record with real client info
|
||||
record.remoteIP = parseResult.proxyInfo.sourceIP;
|
||||
record.remotePort = parseResult.proxyInfo.sourcePort;
|
||||
|
||||
logger.log('info', `PROXY protocol parsed successfully`, {
|
||||
connectionId,
|
||||
realClientIP: parseResult.proxyInfo.sourceIP,
|
||||
realClientPort: parseResult.proxyInfo.sourcePort,
|
||||
proxyIP: socket.remoteAddress,
|
||||
component: 'route-handler'
|
||||
});
|
||||
|
||||
// Process remaining data if any
|
||||
if (parseResult.remainingData.length > 0) {
|
||||
processInitialData(parseResult.remainingData);
|
||||
} else {
|
||||
// Wait for more data
|
||||
socket.once('data', processInitialData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to parse PROXY protocol from trusted proxy`, {
|
||||
connectionId,
|
||||
error: error.message,
|
||||
proxyIP: socket.remoteAddress,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Continue processing as normal data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process as normal data (no PROXY protocol)
|
||||
processInitialData(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1068,6 +1125,7 @@ export class RouteConnectionHandler {
|
||||
const targetSocket = createSocketWithErrorHandler({
|
||||
port: finalTargetPort,
|
||||
host: finalTargetHost,
|
||||
timeout: this.settings.connectionTimeout || 30000, // Connection timeout (default: 30s)
|
||||
onError: (error) => {
|
||||
// Connection failed - clean up everything immediately
|
||||
// Check if connection record is still valid (client might have disconnected)
|
||||
@ -1119,7 +1177,7 @@ export class RouteConnectionHandler {
|
||||
// Clean up the connection record - this is critical!
|
||||
this.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
||||
},
|
||||
onConnect: () => {
|
||||
onConnect: async () => {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
||||
connectionId,
|
||||
@ -1135,6 +1193,56 @@ export class RouteConnectionHandler {
|
||||
// Add the normal error handler for established connections
|
||||
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
||||
|
||||
// Check if we should send PROXY protocol header
|
||||
const shouldSendProxyProtocol = record.routeConfig?.action?.sendProxyProtocol ||
|
||||
this.settings.sendProxyProtocol;
|
||||
|
||||
if (shouldSendProxyProtocol) {
|
||||
try {
|
||||
// Generate PROXY protocol header
|
||||
const proxyInfo = {
|
||||
protocol: (record.remoteIP.includes(':') ? 'TCP6' : 'TCP4') as 'TCP4' | 'TCP6',
|
||||
sourceIP: record.remoteIP,
|
||||
sourcePort: record.remotePort || socket.remotePort || 0,
|
||||
destinationIP: socket.localAddress || '',
|
||||
destinationPort: socket.localPort || 0
|
||||
};
|
||||
|
||||
const proxyHeader = ProxyProtocolParser.generate(proxyInfo);
|
||||
|
||||
// Send PROXY protocol header first
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
targetSocket.write(proxyHeader, (err) => {
|
||||
if (err) {
|
||||
logger.log('error', `Failed to send PROXY protocol header`, {
|
||||
connectionId,
|
||||
error: err.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
reject(err);
|
||||
} else {
|
||||
logger.log('info', `PROXY protocol header sent to backend`, {
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
sourceIP: proxyInfo.sourceIP,
|
||||
sourcePort: proxyInfo.sourcePort,
|
||||
component: 'route-handler'
|
||||
});
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('error', `Error sending PROXY protocol header`, {
|
||||
connectionId,
|
||||
error: error.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Continue anyway - don't break the connection
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any pending data to target
|
||||
if (record.pendingData.length > 0) {
|
||||
const combinedData = Buffer.concat(record.pendingData);
|
||||
|
Reference in New Issue
Block a user