Files
smartvpn/readme.plan.md

8.8 KiB

PROXY Protocol v2 Support for SmartVPN WebSocket Transport

Context

SmartVPN's WebSocket transport is designed to sit behind reverse proxies (Cloudflare, HAProxy, SmartProxy). The recently added ACL engine has ipAllowList/ipBlockList per client, but without PROXY protocol support the server only sees the proxy's IP — not the real client's. This makes source-IP ACLs useless behind a proxy.

PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade.


Design

Two-Phase ACL with Real Client IP

TCP accept → Read PP v2 header → Extract real IP
  │
  ├─ Phase 1 (pre-handshake): Check server-level connectionIpBlockList → reject early
  │
  ├─ WebSocket upgrade → Noise IK handshake → Client identity known
  │
  └─ Phase 2 (post-handshake): Check per-client ipAllowList/ipBlockList → reject if denied
  • Phase 1: Server-wide block list (connectionIpBlockList on IVpnServerConfig). Rejects before any crypto work. Protects server resources.
  • Phase 2: Per-client ACL from IClientSecurity.ipAllowList/ipBlockList. Applied after the Noise IK handshake identifies the client.

No New Dependencies

PROXY protocol v2 is a fixed-format binary header (16-byte signature + variable address block). Manual parsing (~80 lines) follows the same pattern as codec.rs. No crate needed.

Scope: WebSocket Only

  • WebSocket: Needs PP v2 (sits behind reverse proxies)
  • QUIC: Direct UDP, just use conn.remote_address()
  • WireGuard: Direct UDP, uses boringtun peer tracking

Implementation

Phase 1: New Rust module proxy_protocol.rs

New file: rust/src/proxy_protocol.rs

PP v2 binary format:

Bytes 0-11:   Signature  \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
Byte 12:      Version (high nibble = 0x2) | Command (low nibble: 0x0=LOCAL, 0x1=PROXY)
Byte 13:      Address family | Protocol (0x11 = IPv4/TCP, 0x21 = IPv6/TCP)
Bytes 14-15:  Address data length (big-endian u16)
Bytes 16+:    IPv4: 4 src_ip + 4 dst_ip + 2 src_port + 2 dst_port (12 bytes)
              IPv6: 16 src_ip + 16 dst_ip + 2 src_port + 2 dst_port (36 bytes)
pub struct ProxyHeader {
    pub src_addr: SocketAddr,
    pub dst_addr: SocketAddr,
    pub is_local: bool,  // LOCAL command = health check probe
}

/// Read and parse a PROXY protocol v2 header from a TCP stream.
/// Reads exactly the header bytes — the stream is clean for WS upgrade after.
pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader>
  • 5-second timeout on header read (constant PROXY_HEADER_TIMEOUT)
  • Validates 12-byte signature, version nibble, command type
  • Parses IPv4 and IPv6 address blocks
  • LOCAL command returns is_local: true (caller closes connection gracefully)
  • Unit tests: valid IPv4/IPv6 headers, LOCAL command, invalid signature, truncated data

Modify: rust/src/lib.rs — add pub mod proxy_protocol;

Phase 2: Server config + client info fields

File: rust/src/server.rsServerConfig

Add:

/// Enable PROXY protocol v2 parsing on WebSocket connections.
/// SECURITY: Must be false when accepting direct client connections.
pub proxy_protocol: Option<bool>,
/// Server-level IP block list — applied at TCP accept time, before Noise handshake.
pub connection_ip_block_list: Option<Vec<String>>,

File: rust/src/server.rsClientInfo

Add:

/// Real client IP:port (from PROXY protocol header or direct TCP connection).
pub remote_addr: Option<String>,

Phase 3: ACL helper

File: rust/src/acl.rs

Add a public function for the server-level pre-handshake check:

/// Check whether a connection source IP is in a block list.
pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
    ip_matches_any(ip, block_list)
}

(Keeps ip_matches_any private; exposes only the specific check needed.)

Phase 4: WebSocket listener integration

File: rust/src/server.rsrun_ws_listener()

Between listener.accept() and transport::accept_connection():

// Determine real client address
let remote_addr = if state.config.proxy_protocol.unwrap_or(false) {
    match proxy_protocol::read_proxy_header(&mut tcp_stream).await {
        Ok(header) if header.is_local => {
            // Health check probe — close gracefully
            return;
        }
        Ok(header) => {
            info!("PP v2: real client {} -> {}", header.src_addr, header.dst_addr);
            Some(header.src_addr)
        }
        Err(e) => {
            warn!("PP v2 parse failed from {}: {}", tcp_addr, e);
            return; // Drop connection
        }
    }
} else {
    Some(tcp_addr) // Direct connection — use TCP SocketAddr
};

// Pre-handshake server-level block list check
if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) {
    if let std::net::IpAddr::V4(v4) = addr.ip() {
        if acl::is_connection_blocked(v4, block_list) {
            warn!("Connection blocked by server IP block list: {}", addr);
            return;
        }
    }
}

// Then proceed with WS upgrade + handle_client_connection as before

Key correctness note: read_proxy_header reads exactly the PP header bytes via read_exact. The TcpStream is then in a clean state for the WS HTTP upgrade. No buffered wrapper needed.

Phase 5: Update handle_client_connection signature

File: rust/src/server.rs

Change signature:

async fn handle_client_connection(
    state: Arc<ServerState>,
    mut sink: Box<dyn TransportSink>,
    mut stream: Box<dyn TransportStream>,
    remote_addr: Option<std::net::SocketAddr>,  // NEW
) -> Result<()>

After Noise IK handshake + registry lookup (where client_security is available), add connection-level per-client ACL:

if let (Some(ref sec), Some(addr)) = (&client_security, &remote_addr) {
    if let std::net::IpAddr::V4(v4) = addr.ip() {
        if acl::is_connection_blocked(v4, sec.ip_block_list.as_deref().unwrap_or(&[])) {
            anyhow::bail!("Client {} connection denied: source IP {} blocked", registered_client_id, addr);
        }
        if let Some(ref allow) = sec.ip_allow_list {
            if !allow.is_empty() && !acl::is_ip_allowed(v4, allow) {
                anyhow::bail!("Client {} connection denied: source IP {} not in allow list", registered_client_id, addr);
            }
        }
    }
}

Populate remote_addr when building ClientInfo:

remote_addr: remote_addr.map(|a| a.to_string()),

Phase 6: QUIC listener — pass remote addr through

File: rust/src/server.rsrun_quic_listener()

QUIC doesn't use PROXY protocol. Just pass conn.remote_address() through:

let remote = conn.remote_address();
// ...
handle_client_connection(state, Box::new(sink), Box::new(stream), Some(remote)).await

Phase 7: TypeScript interface updates

File: ts/smartvpn.interfaces.ts

Add to IVpnServerConfig:

/** Enable PROXY protocol v2 on incoming WebSocket connections.
 *  Required when behind a reverse proxy that sends PP v2 headers. */
proxyProtocol?: boolean;
/** Server-level IP block list — applied at TCP accept time, before Noise handshake. */
connectionIpBlockList?: string[];

Add to IVpnClientInfo:

/** Real client IP:port (from PROXY protocol or direct TCP). */
remoteAddr?: string;

Phase 8: Tests

Rust unit tests in proxy_protocol.rs:

  • parse_valid_ipv4_header — construct a valid PP v2 header with known IPs, verify parsed correctly
  • parse_valid_ipv6_header — same for IPv6
  • parse_local_command — health check probe returns is_local: true
  • reject_invalid_signature — random bytes rejected
  • reject_truncated_header — short reads fail gracefully
  • reject_v1_header — PROXY v1 text format rejected (we only support v2)

Rust unit tests in acl.rs:

  • is_connection_blocked with various IP patterns

TypeScript tests:

  • Config validation accepts proxyProtocol: true + connectionIpBlockList

Key Files to Modify

File Changes
rust/src/proxy_protocol.rs NEW — PP v2 parser + tests
rust/src/lib.rs Add pub mod proxy_protocol;
rust/src/server.rs ServerConfig + ClientInfo fields, run_ws_listener PP integration, handle_client_connection signature + connection ACL, run_quic_listener pass-through
rust/src/acl.rs Add is_connection_blocked public function
ts/smartvpn.interfaces.ts proxyProtocol, connectionIpBlockList, remoteAddr

Verification

  1. cargo test — all existing 121 tests + new PP parser tests pass
  2. pnpm test — all 79 TS tests pass (no PP in test setup, just config validation)
  3. Manual: socat or test harness to send a PP v2 header before WS upgrade, verify server logs real IP