npm package: @push.rocks/smartvpn
Description: A VPN solution with TypeScript control plane and Rust data plane daemon
Latest version: 1.19.2

# 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<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.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<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.rs` — `ClientInfo`**

Add:
```rust
/// 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:
```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<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:

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