smartproxy/readme.proxy-protocol-example.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

13 KiB

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

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

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

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

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

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

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

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