smartproxy/readme.proxy-protocol.md
Juergen Kunz b3714d583d 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.
2025-06-06 13:45:44 +00:00

12 KiB

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.

// 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

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:

// 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

    // 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:
    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

// 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

// 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:

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

// 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

// 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

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

// For proxy chains, always disable half-open
setupBidirectionalForwarding(client, server, {
  enableHalfOpen: false // Ensures proper cascade cleanup
});

4. Monitor Connection Counts

// 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

// 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