feat(proxy): Implement WrappedSocket class for PROXY protocol support and update connection handling
This commit is contained in:
parent
2a75e7c490
commit
18d79ac7e1
155
readme.plan.md
155
readme.plan.md
@ -1,5 +1,16 @@
|
||||
# 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
|
||||
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
|
||||
|
||||
#### Phase 1: WrappedSocket Foundation
|
||||
1. Create minimal `ProxyProtocolSocket` class in `ts/core/models/proxy-protocol-socket.ts`
|
||||
2. Implement basic socket wrapper with real IP getters
|
||||
3. Update `ConnectionManager` to accept wrapped sockets
|
||||
4. Create tests for wrapped socket functionality
|
||||
#### IMPORTANT: Phase 1 Must Be Completed First
|
||||
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.
|
||||
|
||||
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED
|
||||
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`
|
||||
2. Implement v1 text format parsing
|
||||
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
|
||||
1. Modify `RouteConnectionHandler` to use ProxyProtocolSocket for trusted IPs
|
||||
2. Parse PROXY header before TLS detection
|
||||
3. Use wrapped socket throughout connection lifecycle
|
||||
4. Strip PROXY header before forwarding data
|
||||
#### Phase 3: Connection Handler Integration - DEPENDS ON PHASES 1 & 2
|
||||
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
|
||||
|
||||
#### Phase 4: Outbound PROXY Protocol
|
||||
#### 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
|
||||
|
||||
#### Phase 5: Security & Validation
|
||||
#### Phase 5: Security & Validation - FINAL PHASE
|
||||
1. Validate PROXY headers strictly
|
||||
2. Reject malformed headers
|
||||
3. Only accept from trusted IPs
|
||||
@ -112,37 +152,102 @@ Start with **Option A** (ProxyProtocolSocket) for immediate PROXY protocol suppo
|
||||
- Code simplification benefits
|
||||
- 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
|
||||
// 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(
|
||||
public readonly socket: plugins.net.Socket,
|
||||
private realClientIP?: string,
|
||||
private realClientPort?: number
|
||||
) {}
|
||||
|
||||
get remoteAddress(): string {
|
||||
return this.realClientIP || this.socket.remoteAddress || '';
|
||||
realClientIP?: string,
|
||||
realClientPort?: number
|
||||
) {
|
||||
super();
|
||||
this.realClientIP = realClientIP;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
// ... 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)
|
||||
```typescript
|
||||
// ts/core/utils/proxy-protocol.ts
|
||||
|
366
test/test.wrapped-socket.ts
Normal file
366
test/test.wrapped-socket.ts
Normal 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();
|
@ -5,3 +5,4 @@
|
||||
export * from './common-types.js';
|
||||
export * from './socket-augmentation.js';
|
||||
export * from './route-context.js';
|
||||
export * from './wrapped-socket.js';
|
||||
|
259
ts/core/models/wrapped-socket.ts
Normal file
259
ts/core/models/wrapped-socket.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import { TimeoutManager } from './timeout-manager.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { LifecycleComponent } from '../../core/utils/lifecycle-component.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
|
||||
@ -53,8 +54,9 @@ export class ConnectionManager extends LifecycleComponent {
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (this.connectionRecords.size >= this.maxConnections) {
|
||||
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
|
||||
@ -282,22 +284,26 @@ export class ConnectionManager extends LifecycleComponent {
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
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) {
|
||||
// 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 {
|
||||
// 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) {
|
||||
// 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) {
|
||||
// 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 {
|
||||
// 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>[] = [];
|
||||
|
||||
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) {
|
||||
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
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { WrappedSocket } from '../../../core/models/wrapped-socket.js';
|
||||
// Certificate types removed - define IAcmeOptions locally
|
||||
export interface IAcmeOptions {
|
||||
enabled?: boolean;
|
||||
@ -34,6 +35,11 @@ export interface ISmartProxyOptions {
|
||||
// Port configuration
|
||||
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
|
||||
defaults?: {
|
||||
target?: {
|
||||
@ -128,8 +134,8 @@ export interface ISmartProxyOptions {
|
||||
*/
|
||||
export interface IConnectionRecord {
|
||||
id: string; // Unique connection identifier
|
||||
incoming: plugins.net.Socket;
|
||||
outgoing: plugins.net.Socket | null;
|
||||
incoming: plugins.net.Socket | WrappedSocket;
|
||||
outgoing: plugins.net.Socket | WrappedSocket | null;
|
||||
incomingStartTime: number;
|
||||
outgoingStartTime?: number;
|
||||
outgoingClosedTime?: number;
|
||||
|
@ -11,6 +11,7 @@ import { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import { TimeoutManager } from './timeout-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 { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
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) {
|
||||
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
||||
cleanupSocket(socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
||||
logger.log('warn', `Connection rejected`, { remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' });
|
||||
cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new connection record
|
||||
const record = this.connectionManager.createConnection(socket);
|
||||
// Create a new connection record with the wrapped socket
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
if (!record) {
|
||||
// Connection was rejected due to limit - socket already destroyed by connection manager
|
||||
return;
|
||||
}
|
||||
const connectionId = record.id;
|
||||
|
||||
// Apply socket optimizations
|
||||
socket.setNoDelay(this.settings.noDelay);
|
||||
// Apply socket optimizations (apply to underlying socket)
|
||||
const underlyingSocket = wrappedSocket.socket;
|
||||
underlyingSocket.setNoDelay(this.settings.noDelay);
|
||||
|
||||
// Apply keep-alive settings if enabled
|
||||
if (this.settings.keepAlive) {
|
||||
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
underlyingSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
record.hasKeepAlive = true;
|
||||
|
||||
// Apply enhanced TCP keep-alive options if enabled
|
||||
if (this.settings.enableKeepAliveProbes) {
|
||||
try {
|
||||
// These are platform-specific and may not be available
|
||||
if ('setKeepAliveProbes' in socket) {
|
||||
(socket as any).setKeepAliveProbes(10);
|
||||
if ('setKeepAliveProbes' in underlyingSocket) {
|
||||
(underlyingSocket as any).setKeepAliveProbes(10);
|
||||
}
|
||||
if ('setKeepAliveInterval' in socket) {
|
||||
(socket as any).setKeepAliveInterval(1000);
|
||||
if ('setKeepAliveInterval' in underlyingSocket) {
|
||||
(underlyingSocket as any).setKeepAliveInterval(1000);
|
||||
}
|
||||
} catch (err) {
|
||||
// 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
|
||||
this.handleInitialData(socket, record);
|
||||
this.handleInitialData(wrappedSocket, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 localPort = record.localPort;
|
||||
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 (!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
|
||||
setupSocketHandlers(
|
||||
socket,
|
||||
underlyingSocket,
|
||||
(reason) => {
|
||||
// Only cleanup if connection hasn't been fully established
|
||||
// Check if outgoing connection exists and is connected
|
||||
@ -206,7 +222,7 @@ export class RouteConnectionHandler {
|
||||
);
|
||||
|
||||
// Route immediately for non-TLS connections
|
||||
this.routeConnection(socket, record, '', undefined);
|
||||
this.routeConnection(underlyingSocket, record, '', undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -363,7 +379,8 @@ export class RouteConnectionHandler {
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user