feat(proxy): Implement WrappedSocket class for PROXY protocol support and update connection handling

This commit is contained in:
Juergen Kunz 2025-06-05 17:57:24 +00:00
parent 2a75e7c490
commit 18d79ac7e1
7 changed files with 813 additions and 51 deletions

View File

@ -1,5 +1,16 @@
# PROXY Protocol Implementation Plan # PROXY Protocol Implementation Plan
## ⚠️ CRITICAL: Implementation Order
**Phase 1 (ProxyProtocolSocket/WrappedSocket) MUST be completed first!**
The ProxyProtocolSocket class is the foundation that enables all PROXY protocol functionality. No protocol parsing or integration can happen until this wrapper class is fully implemented and tested.
1. **FIRST**: Implement ProxyProtocolSocket (the WrappedSocket)
2. **THEN**: Add PROXY protocol parser
3. **THEN**: Integrate with connection handlers
4. **FINALLY**: Add security and validation
## Overview ## Overview
Implement PROXY protocol support in SmartProxy to preserve client IP information through proxy chains, solving the connection limit accumulation issue where inner proxies see all connections as coming from the outer proxy's IP. Implement PROXY protocol support in SmartProxy to preserve client IP information through proxy chains, solving the connection limit accumulation issue where inner proxies see all connections as coming from the outer proxy's IP.
@ -56,31 +67,60 @@ interface IRouteAction {
### 3. Implementation Steps ### 3. Implementation Steps
#### Phase 1: WrappedSocket Foundation #### IMPORTANT: Phase 1 Must Be Completed First
1. Create minimal `ProxyProtocolSocket` class in `ts/core/models/proxy-protocol-socket.ts` The `ProxyProtocolSocket` (WrappedSocket) is the foundation for all PROXY protocol functionality. This wrapper class must be implemented and integrated BEFORE any PROXY protocol parsing can begin.
2. Implement basic socket wrapper with real IP getters
3. Update `ConnectionManager` to accept wrapped sockets #### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED
4. Create tests for wrapped socket functionality This phase creates the socket wrapper infrastructure that all subsequent phases depend on.
1. **Create WrappedSocket class** in `ts/core/models/wrapped-socket.ts`
- Basic socket wrapper that extends EventEmitter
- Properties for real client IP and port
- Transparent getters that return real or socket IP/port
- Pass-through methods for all socket operations
2. **Implement core wrapper functionality**
- Constructor accepts regular socket + optional metadata
- `remoteAddress` getter returns real IP or falls back to socket IP
- `remotePort` getter returns real port or falls back to socket port
- `isFromTrustedProxy` property to check if it has real client info
- `setProxyInfo()` method to update real client details
3. **Update ConnectionManager to handle wrapped sockets**
- Accept either `net.Socket` or `WrappedSocket`
- Use duck typing or instanceof checks
- Ensure all IP lookups use the getter methods
4. **Create comprehensive tests**
- Test wrapper with and without proxy info
- Verify getter fallback behavior
- Test event forwarding
- Test socket method pass-through
**Deliverables**: ✅ Working WrappedSocket that can wrap any socket and provide transparent access to client info.
#### Phase 2: PROXY Protocol Parser - DEPENDS ON PHASE 1
Only after WrappedSocket is working can we add protocol parsing.
#### Phase 2: PROXY Protocol Parser
1. Create `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts` 1. Create `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
2. Implement v1 text format parsing 2. Implement v1 text format parsing
3. Add validation and error handling 3. Add validation and error handling
4. Integrate parser into ProxyProtocolSocket 4. Integrate parser to work WITH WrappedSocket (not into it)
#### Phase 3: Connection Handler Integration #### Phase 3: Connection Handler Integration - DEPENDS ON PHASES 1 & 2
1. Modify `RouteConnectionHandler` to use ProxyProtocolSocket for trusted IPs 1. ✅ Modify `RouteConnectionHandler` to create WrappedSocket for all connections
2. Parse PROXY header before TLS detection 2. Check if connection is from trusted proxy IP
3. Use wrapped socket throughout connection lifecycle 3. If trusted, attempt to parse PROXY protocol header
4. Strip PROXY header before forwarding data 4. Update wrapped socket with real client info
5. Continue normal connection handling with wrapped socket
#### Phase 4: Outbound PROXY Protocol #### Phase 4: Outbound PROXY Protocol - DEPENDS ON PHASES 1-3
1. Add PROXY header generation in `setupDirectConnection` 1. Add PROXY header generation in `setupDirectConnection`
2. Make it configurable per route 2. Make it configurable per route
3. Send header immediately after TCP connection 3. Send header immediately after TCP connection
4. Use ProxyProtocolSocket for outbound connections too 4. Use ProxyProtocolSocket for outbound connections too
#### Phase 5: Security & Validation #### Phase 5: Security & Validation - FINAL PHASE
1. Validate PROXY headers strictly 1. Validate PROXY headers strictly
2. Reject malformed headers 2. Reject malformed headers
3. Only accept from trusted IPs 3. Only accept from trusted IPs
@ -112,37 +152,102 @@ Start with **Option A** (ProxyProtocolSocket) for immediate PROXY protocol suppo
- Code simplification benefits - Code simplification benefits
- Team comfort with architectural change - Team comfort with architectural change
### 5. Code Changes (Using Option A Initially) ### 5. Code Implementation Details
#### 5.1 ProxyProtocolSocket (WrappedSocket) - PHASE 1 IMPLEMENTATION
This is the foundational wrapper class that MUST be implemented first. It wraps a regular socket and provides transparent access to the real client IP/port.
#### 5.1 ProxyProtocolSocket (new file)
```typescript ```typescript
// ts/core/models/proxy-protocol-socket.ts // ts/core/models/proxy-protocol-socket.ts
export class ProxyProtocolSocket { import { EventEmitter } from 'events';
import * as plugins from '../../../plugins.js';
/**
* ProxyProtocolSocket wraps a regular net.Socket to provide transparent access
* to the real client IP and port when behind a proxy using PROXY protocol.
*
* This is the FOUNDATION for all PROXY protocol support and must be implemented
* before any protocol parsing can occur.
*/
export class ProxyProtocolSocket extends EventEmitter {
private realClientIP?: string;
private realClientPort?: number;
constructor( constructor(
public readonly socket: plugins.net.Socket, public readonly socket: plugins.net.Socket,
private realClientIP?: string, realClientIP?: string,
private realClientPort?: number realClientPort?: number
) {} ) {
super();
get remoteAddress(): string { this.realClientIP = realClientIP;
return this.realClientIP || this.socket.remoteAddress || ''; this.realClientPort = realClientPort;
// Forward all socket events
this.forwardSocketEvents();
} }
get remotePort(): number { /**
return this.realClientPort || this.socket.remotePort || 0; * Returns the real client IP if available, otherwise the socket's remote address
*/
get remoteAddress(): string | undefined {
return this.realClientIP || this.socket.remoteAddress;
} }
/**
* Returns the real client port if available, otherwise the socket's remote port
*/
get remotePort(): number | undefined {
return this.realClientPort || this.socket.remotePort;
}
/**
* Indicates if this connection came through a trusted proxy
*/
get isFromTrustedProxy(): boolean { get isFromTrustedProxy(): boolean {
return !!this.realClientIP; return !!this.realClientIP;
} }
/**
* Updates the real client information (called after parsing PROXY protocol)
*/
setProxyInfo(ip: string, port: number): void { setProxyInfo(ip: string, port: number): void {
this.realClientIP = ip; this.realClientIP = ip;
this.realClientPort = port; this.realClientPort = port;
} }
// Pass-through all socket methods
write(data: any, encoding?: any, callback?: any): boolean {
return this.socket.write(data, encoding, callback);
}
end(data?: any, encoding?: any, callback?: any): this {
this.socket.end(data, encoding, callback);
return this;
}
destroy(error?: Error): this {
this.socket.destroy(error);
return this;
}
// ... implement all other socket methods as pass-through
/**
* Forward all events from the underlying socket
*/
private forwardSocketEvents(): void {
const events = ['data', 'end', 'close', 'error', 'drain', 'timeout'];
events.forEach(event => {
this.socket.on(event, (...args) => {
this.emit(event, ...args);
});
});
}
} }
``` ```
**KEY POINT**: This wrapper must be fully functional and tested BEFORE moving to Phase 2.
#### 4.2 ProxyProtocolParser (new file) #### 4.2 ProxyProtocolParser (new file)
```typescript ```typescript
// ts/core/utils/proxy-protocol.ts // ts/core/utils/proxy-protocol.ts

366
test/test.wrapped-socket.ts Normal file
View File

@ -0,0 +1,366 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { WrappedSocket } from '../ts/core/models/wrapped-socket.js';
import * as net from 'net';
tap.test('WrappedSocket - should wrap a regular socket', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Wrap the socket
const wrappedSocket = new WrappedSocket(clientSocket);
// Test initial state - should use underlying socket values
expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress);
expect(wrappedSocket.remotePort).toEqual(clientSocket.remotePort);
expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress);
expect(wrappedSocket.localPort).toEqual(clientSocket.localPort);
expect(wrappedSocket.isFromTrustedProxy).toBeFalse();
// Clean up
clientSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should provide real client info when set', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Wrap the socket with initial proxy info
const wrappedSocket = new WrappedSocket(clientSocket, '192.168.1.100', 54321);
// Test that real client info is returned
expect(wrappedSocket.remoteAddress).toEqual('192.168.1.100');
expect(wrappedSocket.remotePort).toEqual(54321);
expect(wrappedSocket.isFromTrustedProxy).toBeTrue();
// Local info should still come from underlying socket
expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress);
expect(wrappedSocket.localPort).toEqual(clientSocket.localPort);
// Clean up
clientSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should update proxy info via setProxyInfo', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Wrap the socket without initial proxy info
const wrappedSocket = new WrappedSocket(clientSocket);
// Initially should use underlying socket
expect(wrappedSocket.isFromTrustedProxy).toBeFalse();
expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress);
// Update proxy info
wrappedSocket.setProxyInfo('10.0.0.5', 12345);
// Now should return proxy info
expect(wrappedSocket.remoteAddress).toEqual('10.0.0.5');
expect(wrappedSocket.remotePort).toEqual(12345);
expect(wrappedSocket.isFromTrustedProxy).toBeTrue();
// Clean up
clientSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should correctly determine IP family', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Test IPv4
const wrappedSocketIPv4 = new WrappedSocket(clientSocket, '192.168.1.1', 80);
expect(wrappedSocketIPv4.remoteFamily).toEqual('IPv4');
// Test IPv6
const wrappedSocketIPv6 = new WrappedSocket(clientSocket, '2001:0db8:85a3:0000:0000:8a2e:0370:7334', 443);
expect(wrappedSocketIPv6.remoteFamily).toEqual('IPv6');
// Test fallback to underlying socket
const wrappedSocketNoProxy = new WrappedSocket(clientSocket);
expect(wrappedSocketNoProxy.remoteFamily).toEqual(clientSocket.remoteFamily);
// Clean up
clientSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should forward events correctly', async () => {
// Create a simple echo server
let serverConnection: net.Socket;
const server = net.createServer((socket) => {
serverConnection = socket;
socket.on('data', (data) => {
socket.write(data); // Echo back
});
});
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Wrap the socket
const wrappedSocket = new WrappedSocket(clientSocket);
// Set up event tracking
let connectReceived = false;
let dataReceived = false;
let endReceived = false;
let closeReceived = false;
wrappedSocket.on('connect', () => {
connectReceived = true;
});
wrappedSocket.on('data', (chunk) => {
dataReceived = true;
expect(chunk.toString()).toEqual('test data');
});
wrappedSocket.on('end', () => {
endReceived = true;
});
wrappedSocket.on('close', () => {
closeReceived = true;
});
// Wait for connection
await new Promise<void>((resolve) => {
if (clientSocket.readyState === 'open') {
resolve();
} else {
clientSocket.once('connect', () => resolve());
}
});
// Send data
wrappedSocket.write('test data');
// Wait for echo
await new Promise(resolve => setTimeout(resolve, 100));
// Close the connection
serverConnection.end();
// Wait for events
await new Promise(resolve => setTimeout(resolve, 100));
// Verify all events were received
expect(dataReceived).toBeTrue();
expect(endReceived).toBeTrue();
expect(closeReceived).toBeTrue();
// Clean up
server.close();
});
tap.test('WrappedSocket - should pass through socket methods', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
await new Promise<void>((resolve) => {
clientSocket.once('connect', () => resolve());
});
// Wrap the socket
const wrappedSocket = new WrappedSocket(clientSocket);
// Test various pass-through methods
expect(wrappedSocket.readable).toEqual(clientSocket.readable);
expect(wrappedSocket.writable).toEqual(clientSocket.writable);
expect(wrappedSocket.destroyed).toEqual(clientSocket.destroyed);
expect(wrappedSocket.bytesRead).toEqual(clientSocket.bytesRead);
expect(wrappedSocket.bytesWritten).toEqual(clientSocket.bytesWritten);
// Test method calls
wrappedSocket.pause();
expect(clientSocket.isPaused()).toBeTrue();
wrappedSocket.resume();
expect(clientSocket.isPaused()).toBeFalse();
// Test setTimeout
let timeoutCalled = false;
wrappedSocket.setTimeout(100, () => {
timeoutCalled = true;
});
await new Promise(resolve => setTimeout(resolve, 150));
expect(timeoutCalled).toBeTrue();
// Clean up
wrappedSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should handle write and pipe operations', async () => {
// Create a simple echo server
const server = net.createServer((socket) => {
socket.pipe(socket); // Echo everything back
});
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
await new Promise<void>((resolve) => {
clientSocket.once('connect', () => resolve());
});
// Wrap the socket
const wrappedSocket = new WrappedSocket(clientSocket);
// Test write with callback
const writeResult = wrappedSocket.write('test', 'utf8', () => {
// Write completed
});
expect(typeof writeResult).toEqual('boolean');
// Test pipe
const { PassThrough } = await import('stream');
const passThrough = new PassThrough();
const piped = wrappedSocket.pipe(passThrough);
expect(piped).toEqual(passThrough);
// Clean up
wrappedSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should handle encoding and address methods', async () => {
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
await new Promise<void>((resolve) => {
clientSocket.once('connect', () => resolve());
});
// Wrap the socket
const wrappedSocket = new WrappedSocket(clientSocket);
// Test setEncoding
wrappedSocket.setEncoding('utf8');
// Test address method
const addr = wrappedSocket.address();
expect(addr).toEqual(clientSocket.address());
// Test cork/uncork (if available)
wrappedSocket.cork();
wrappedSocket.uncork();
// Clean up
wrappedSocket.destroy();
server.close();
});
tap.test('WrappedSocket - should work with ConnectionManager', async () => {
// This test verifies that WrappedSocket can be used seamlessly with ConnectionManager
const { ConnectionManager } = await import('../ts/proxies/smart-proxy/connection-manager.js');
const { SecurityManager } = await import('../ts/proxies/smart-proxy/security-manager.js');
const { TimeoutManager } = await import('../ts/proxies/smart-proxy/timeout-manager.js');
// Create minimal settings
const settings = {
routes: [],
defaults: {
security: {
maxConnections: 100
}
}
};
const securityManager = new SecurityManager(settings);
const timeoutManager = new TimeoutManager(settings);
const connectionManager = new ConnectionManager(settings, securityManager, timeoutManager);
// Create a simple test server
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, 'localhost', () => resolve());
});
const serverPort = (server.address() as net.AddressInfo).port;
// Create a client connection
const clientSocket = net.connect(serverPort, 'localhost');
// Wait for connection to establish
await new Promise<void>((resolve) => {
clientSocket.once('connect', () => resolve());
});
// Wrap with proxy info
const wrappedSocket = new WrappedSocket(clientSocket, '203.0.113.45', 65432);
// Create connection using wrapped socket
const record = connectionManager.createConnection(wrappedSocket);
expect(record).toBeTruthy();
expect(record!.remoteIP).toEqual('203.0.113.45'); // Should use the real client IP
expect(record!.localPort).toEqual(clientSocket.localPort);
// Clean up
connectionManager.cleanupConnection(record!, 'test-complete');
server.close();
});
export default tap.start();

View File

@ -5,3 +5,4 @@
export * from './common-types.js'; export * from './common-types.js';
export * from './socket-augmentation.js'; export * from './socket-augmentation.js';
export * from './route-context.js'; export * from './route-context.js';
export * from './wrapped-socket.js';

View File

@ -0,0 +1,259 @@
import { EventEmitter } from 'events';
import * as plugins from '../../plugins.js';
/**
* WrappedSocket wraps a regular net.Socket to provide transparent access
* to the real client IP and port when behind a proxy using PROXY protocol.
*
* This is the FOUNDATION for all PROXY protocol support and must be implemented
* before any protocol parsing can occur.
*/
export class WrappedSocket extends EventEmitter {
private realClientIP?: string;
private realClientPort?: number;
constructor(
public readonly socket: plugins.net.Socket,
realClientIP?: string,
realClientPort?: number
) {
super();
this.realClientIP = realClientIP;
this.realClientPort = realClientPort;
// Forward all socket events
this.forwardSocketEvents();
}
/**
* Returns the real client IP if available, otherwise the socket's remote address
*/
get remoteAddress(): string | undefined {
return this.realClientIP || this.socket.remoteAddress;
}
/**
* Returns the real client port if available, otherwise the socket's remote port
*/
get remotePort(): number | undefined {
return this.realClientPort || this.socket.remotePort;
}
/**
* Returns the remote family (IPv4 or IPv6)
*/
get remoteFamily(): string | undefined {
// If we have a real client IP, determine the family
if (this.realClientIP) {
if (this.realClientIP.includes(':')) {
return 'IPv6';
} else {
return 'IPv4';
}
}
return this.socket.remoteFamily;
}
/**
* Returns the local address of the socket
*/
get localAddress(): string | undefined {
return this.socket.localAddress;
}
/**
* Returns the local port of the socket
*/
get localPort(): number | undefined {
return this.socket.localPort;
}
/**
* Indicates if this connection came through a trusted proxy
*/
get isFromTrustedProxy(): boolean {
return !!this.realClientIP;
}
/**
* Updates the real client information (called after parsing PROXY protocol)
*/
setProxyInfo(ip: string, port: number): void {
this.realClientIP = ip;
this.realClientPort = port;
}
// Pass-through all socket methods
write(data: any, encoding?: any, callback?: any): boolean {
return this.socket.write(data, encoding, callback);
}
end(data?: any, encoding?: any, callback?: any): this {
this.socket.end(data, encoding, callback);
return this;
}
destroy(error?: Error): this {
this.socket.destroy(error);
return this;
}
pause(): this {
this.socket.pause();
return this;
}
resume(): this {
this.socket.resume();
return this;
}
setTimeout(timeout: number, callback?: () => void): this {
this.socket.setTimeout(timeout, callback);
return this;
}
setNoDelay(noDelay?: boolean): this {
this.socket.setNoDelay(noDelay);
return this;
}
setKeepAlive(enable?: boolean, initialDelay?: number): this {
this.socket.setKeepAlive(enable, initialDelay);
return this;
}
ref(): this {
this.socket.ref();
return this;
}
unref(): this {
this.socket.unref();
return this;
}
/**
* Pipe to another stream
*/
pipe<T extends NodeJS.WritableStream>(destination: T, options?: {
end?: boolean;
}): T {
return this.socket.pipe(destination, options);
}
/**
* Cork the stream
*/
cork(): void {
if ('cork' in this.socket && typeof this.socket.cork === 'function') {
this.socket.cork();
}
}
/**
* Uncork the stream
*/
uncork(): void {
if ('uncork' in this.socket && typeof this.socket.uncork === 'function') {
this.socket.uncork();
}
}
/**
* Get the number of bytes read
*/
get bytesRead(): number {
return this.socket.bytesRead;
}
/**
* Get the number of bytes written
*/
get bytesWritten(): number {
return this.socket.bytesWritten;
}
/**
* Check if the socket is connecting
*/
get connecting(): boolean {
return this.socket.connecting;
}
/**
* Check if the socket is destroyed
*/
get destroyed(): boolean {
return this.socket.destroyed;
}
/**
* Check if the socket is readable
*/
get readable(): boolean {
return this.socket.readable;
}
/**
* Check if the socket is writable
*/
get writable(): boolean {
return this.socket.writable;
}
/**
* Get pending status
*/
get pending(): boolean {
return this.socket.pending;
}
/**
* Get ready state
*/
get readyState(): string {
return this.socket.readyState;
}
/**
* Address info
*/
address(): plugins.net.AddressInfo | {} | null {
const addr = this.socket.address();
if (addr === null) return null;
if (typeof addr === 'string') return addr as any;
return addr;
}
/**
* Set socket encoding
*/
setEncoding(encoding?: BufferEncoding): this {
this.socket.setEncoding(encoding);
return this;
}
/**
* Connect method (for client sockets)
*/
connect(options: plugins.net.SocketConnectOpts, connectionListener?: () => void): this;
connect(port: number, host?: string, connectionListener?: () => void): this;
connect(path: string, connectionListener?: () => void): this;
connect(...args: any[]): this {
(this.socket as any).connect(...args);
return this;
}
/**
* Forward all events from the underlying socket
*/
private forwardSocketEvents(): void {
const events = ['data', 'end', 'close', 'error', 'drain', 'timeout', 'connect', 'ready', 'lookup'];
events.forEach(event => {
this.socket.on(event, (...args) => {
this.emit(event, ...args);
});
});
}
}

View File

@ -5,6 +5,7 @@ import { TimeoutManager } from './timeout-manager.js';
import { logger } from '../../core/utils/logger.js'; import { logger } from '../../core/utils/logger.js';
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js'; import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js'; import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
/** /**
* Manages connection lifecycle, tracking, and cleanup with performance optimizations * Manages connection lifecycle, tracking, and cleanup with performance optimizations
@ -53,8 +54,9 @@ export class ConnectionManager extends LifecycleComponent {
/** /**
* Create and track a new connection * Create and track a new connection
* Accepts either a regular net.Socket or a WrappedSocket for transparent PROXY protocol support
*/ */
public createConnection(socket: plugins.net.Socket): IConnectionRecord | null { public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null {
// Enforce connection limit // Enforce connection limit
if (this.connectionRecords.size >= this.maxConnections) { if (this.connectionRecords.size >= this.maxConnections) {
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, { logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
@ -282,22 +284,26 @@ export class ConnectionManager extends LifecycleComponent {
const cleanupPromises: Promise<void>[] = []; const cleanupPromises: Promise<void>[] = [];
if (record.incoming) { if (record.incoming) {
// Extract underlying socket if it's a WrappedSocket
const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
if (!record.incoming.writable || record.incoming.destroyed) { if (!record.incoming.writable || record.incoming.destroyed) {
// Socket is not active, clean up immediately // Socket is not active, clean up immediately
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { immediate: true })); cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { immediate: true }));
} else { } else {
// Socket is still active, allow graceful cleanup // Socket is still active, allow graceful cleanup
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 })); cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 }));
} }
} }
if (record.outgoing) { if (record.outgoing) {
// Extract underlying socket if it's a WrappedSocket
const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
if (!record.outgoing.writable || record.outgoing.destroyed) { if (!record.outgoing.writable || record.outgoing.destroyed) {
// Socket is not active, clean up immediately // Socket is not active, clean up immediately
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { immediate: true })); cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { immediate: true }));
} else { } else {
// Socket is still active, allow graceful cleanup // Socket is still active, allow graceful cleanup
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 })); cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 }));
} }
} }
@ -570,11 +576,13 @@ export class ConnectionManager extends LifecycleComponent {
const shutdownPromises: Promise<void>[] = []; const shutdownPromises: Promise<void>[] = [];
if (record.incoming) { if (record.incoming) {
shutdownPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming-shutdown`, { immediate: true })); const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
shutdownPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming-shutdown`, { immediate: true }));
} }
if (record.outgoing) { if (record.outgoing) {
shutdownPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing-shutdown`, { immediate: true })); const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
shutdownPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing-shutdown`, { immediate: true }));
} }
// Don't wait for shutdown cleanup in this batch processing // Don't wait for shutdown cleanup in this batch processing

View File

@ -1,4 +1,5 @@
import * as plugins from '../../../plugins.js'; import * as plugins from '../../../plugins.js';
import type { WrappedSocket } from '../../../core/models/wrapped-socket.js';
// Certificate types removed - define IAcmeOptions locally // Certificate types removed - define IAcmeOptions locally
export interface IAcmeOptions { export interface IAcmeOptions {
enabled?: boolean; enabled?: boolean;
@ -34,6 +35,11 @@ export interface ISmartProxyOptions {
// Port configuration // Port configuration
preserveSourceIP?: boolean; // Preserve client IP when forwarding preserveSourceIP?: boolean; // Preserve client IP when forwarding
// PROXY protocol configuration
proxyIPs?: string[]; // List of trusted proxy IPs that can send PROXY protocol
acceptProxyProtocol?: boolean; // Global option to accept PROXY protocol (defaults based on proxyIPs)
sendProxyProtocol?: boolean; // Global option to send PROXY protocol to all targets
// Global/default settings // Global/default settings
defaults?: { defaults?: {
target?: { target?: {
@ -128,8 +134,8 @@ export interface ISmartProxyOptions {
*/ */
export interface IConnectionRecord { export interface IConnectionRecord {
id: string; // Unique connection identifier id: string; // Unique connection identifier
incoming: plugins.net.Socket; incoming: plugins.net.Socket | WrappedSocket;
outgoing: plugins.net.Socket | null; outgoing: plugins.net.Socket | WrappedSocket | null;
incomingStartTime: number; incomingStartTime: number;
outgoingStartTime?: number; outgoingStartTime?: number;
outgoingClosedTime?: number; outgoingClosedTime?: number;

View File

@ -11,6 +11,7 @@ import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js'; import { TimeoutManager } from './timeout-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.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
@ -81,39 +82,52 @@ export class RouteConnectionHandler {
const remoteIP = socket.remoteAddress || ''; const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0; const localPort = socket.localPort || 0;
// 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`, {
remoteIP,
component: 'route-handler'
});
}
// Validate IP against rate limits and connection limits // Validate IP against rate limits and connection limits
const ipValidation = this.securityManager.validateIP(remoteIP); // Note: For wrapped sockets, this will use the underlying socket IP until PROXY protocol is parsed
const ipValidation = this.securityManager.validateIP(wrappedSocket.remoteAddress || '');
if (!ipValidation.allowed) { if (!ipValidation.allowed) {
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' }); logger.log('warn', `Connection rejected`, { remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' });
cleanupSocket(socket, `rejected-${ipValidation.reason}`, { immediate: true }); cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true });
return; return;
} }
// Create a new connection record // Create a new connection record with the wrapped socket
const record = this.connectionManager.createConnection(socket); const record = this.connectionManager.createConnection(wrappedSocket);
if (!record) { if (!record) {
// Connection was rejected due to limit - socket already destroyed by connection manager // Connection was rejected due to limit - socket already destroyed by connection manager
return; return;
} }
const connectionId = record.id; const connectionId = record.id;
// Apply socket optimizations // Apply socket optimizations (apply to underlying socket)
socket.setNoDelay(this.settings.noDelay); const underlyingSocket = wrappedSocket.socket;
underlyingSocket.setNoDelay(this.settings.noDelay);
// Apply keep-alive settings if enabled // Apply keep-alive settings if enabled
if (this.settings.keepAlive) { if (this.settings.keepAlive) {
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); underlyingSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
record.hasKeepAlive = true; record.hasKeepAlive = true;
// Apply enhanced TCP keep-alive options if enabled // Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) { if (this.settings.enableKeepAliveProbes) {
try { try {
// These are platform-specific and may not be available // These are platform-specific and may not be available
if ('setKeepAliveProbes' in socket) { if ('setKeepAliveProbes' in underlyingSocket) {
(socket as any).setKeepAliveProbes(10); (underlyingSocket as any).setKeepAliveProbes(10);
} }
if ('setKeepAliveInterval' in socket) { if ('setKeepAliveInterval' in underlyingSocket) {
(socket as any).setKeepAliveInterval(1000); (underlyingSocket as any).setKeepAliveInterval(1000);
} }
} catch (err) { } catch (err) {
// Ignore errors - these are optional enhancements // Ignore errors - these are optional enhancements
@ -151,13 +165,13 @@ export class RouteConnectionHandler {
} }
// Handle the connection - wait for initial data to determine if it's TLS // Handle the connection - wait for initial data to determine if it's TLS
this.handleInitialData(socket, record); this.handleInitialData(wrappedSocket, record);
} }
/** /**
* Handle initial data from a connection to determine routing * Handle initial data from a connection to determine routing
*/ */
private handleInitialData(socket: plugins.net.Socket, record: IConnectionRecord): void { private handleInitialData(socket: plugins.net.Socket | WrappedSocket, record: IConnectionRecord): void {
const connectionId = record.id; const connectionId = record.id;
const localPort = record.localPort; const localPort = record.localPort;
let initialDataReceived = false; let initialDataReceived = false;
@ -177,9 +191,11 @@ 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) {
// Extract underlying socket for socket-utils functions
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
// Set up proper socket handlers for immediate routing // Set up proper socket handlers for immediate routing
setupSocketHandlers( setupSocketHandlers(
socket, underlyingSocket,
(reason) => { (reason) => {
// Only cleanup if connection hasn't been fully established // Only cleanup if connection hasn't been fully established
// Check if outgoing connection exists and is connected // Check if outgoing connection exists and is connected
@ -206,7 +222,7 @@ export class RouteConnectionHandler {
); );
// Route immediately for non-TLS connections // Route immediately for non-TLS connections
this.routeConnection(socket, record, '', undefined); this.routeConnection(underlyingSocket, record, '', undefined);
return; return;
} }
@ -363,7 +379,8 @@ export class RouteConnectionHandler {
} }
// Find the appropriate route for this connection // Find the appropriate route for this connection
this.routeConnection(socket, record, serverName, chunk); const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
this.routeConnection(underlyingSocket, record, serverName, chunk);
}); });
} }