# 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) ``` ```rust 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 ``` - 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.rs` — `ServerConfig`** Add: ```rust /// Enable PROXY protocol v2 parsing on WebSocket connections. /// SECURITY: Must be false when accepting direct client connections. pub proxy_protocol: Option, /// Server-level IP block list — applied at TCP accept time, before Noise handshake. pub connection_ip_block_list: Option>, ``` **File: `rust/src/server.rs` — `ClientInfo`** Add: ```rust /// Real client IP:port (from PROXY protocol header or direct TCP connection). pub remote_addr: Option, ``` ### Phase 3: ACL helper **File: `rust/src/acl.rs`** Add a public function for the server-level pre-handshake check: ```rust /// 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.rs` — `run_ws_listener()`** Between `listener.accept()` and `transport::accept_connection()`: ```rust // 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: ```rust async fn handle_client_connection( state: Arc, mut sink: Box, mut stream: Box, remote_addr: Option, // NEW ) -> Result<()> ``` After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL: ```rust 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`: ```rust remote_addr: remote_addr.map(|a| a.to_string()), ``` ### Phase 6: QUIC listener — pass remote addr through **File: `rust/src/server.rs` — `run_quic_listener()`** QUIC doesn't use PROXY protocol. Just pass `conn.remote_address()` through: ```rust 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`: ```typescript /** 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`: ```typescript /** 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