Implement PROXY protocol v1 support in SmartProxy
- Added ProxyProtocolParser class for parsing and generating PROXY protocol v1 headers. - Integrated PROXY protocol parsing into RouteConnectionHandler for handling incoming connections from trusted proxies. - Implemented WrappedSocket class to encapsulate real client information. - Configured SmartProxy to accept and send PROXY protocol headers in routing actions. - Developed comprehensive unit tests for PROXY protocol parsing and generation. - Documented usage patterns, configuration, and best practices for proxy chaining scenarios. - Added security and performance considerations for PROXY protocol implementation.
This commit is contained in:
parent
527cacb1a8
commit
b3714d583d
@ -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
|
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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -151,6 +151,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
|
||||
@ -295,17 +296,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 +373,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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1119,7 +1172,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 +1188,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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user