2026-03-29 17:40:55 +00:00
# PROXY Protocol v2 Support for SmartVPN WebSocket Transport
2026-03-29 15:54:39 +00:00
## Context
2026-03-29 17:40:55 +00:00
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.
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade.
2026-03-29 15:54:39 +00:00
---
2026-03-29 17:40:55 +00:00
## Design
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Two-Phase ACL with Real Client IP
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
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
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
- **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.
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### No New Dependencies
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
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.
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Scope: WebSocket Only
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
- **WebSocket**: Needs PP v2 (sits behind reverse proxies)
- **QUIC**: Direct UDP, just use `conn.remote_address()`
- **WireGuard**: Direct UDP, uses boringtun peer tracking
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
---
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
## Implementation
2026-03-29 17:04:27 +00:00
2026-03-29 17:40:55 +00:00
### Phase 1: New Rust module `proxy_protocol.rs`
2026-03-29 17:04:27 +00:00
2026-03-29 17:40:55 +00:00
**New file: `rust/src/proxy_protocol.rs` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
PP v2 binary format:
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
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)
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
``` rust
pub struct ProxyHeader {
pub src_addr : SocketAddr ,
pub dst_addr : SocketAddr ,
pub is_local : bool , // LOCAL command = health check probe
}
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
/// 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 >
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
- 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
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**Modify: `rust/src/lib.rs` ** — add `pub mod proxy_protocol;`
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 2: Server config + client info fields
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**File: `rust/src/server.rs` — `ServerConfig` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
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 > > ,
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
**File: `rust/src/server.rs` — `ClientInfo` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
Add:
``` rust
/// Real client IP:port (from PROXY protocol header or direct TCP connection).
pub remote_addr : Option < String > ,
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
### Phase 3: ACL helper
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**File: `rust/src/acl.rs` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
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 )
2026-03-29 15:54:39 +00:00
}
```
2026-03-29 17:40:55 +00:00
(Keeps `ip_matches_any` private; exposes only the specific check needed.)
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 4: WebSocket listener integration
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**File: `rust/src/server.rs` — `run_ws_listener()` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
Between `listener.accept()` and `transport::accept_connection()` :
2026-03-29 15:54:39 +00:00
``` rust
2026-03-29 17:40:55 +00:00
// 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 ;
}
}
2026-03-29 17:04:27 +00:00
}
2026-03-29 17:40:55 +00:00
// Then proceed with WS upgrade + handle_client_connection as before
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
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.
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 5: Update `handle_client_connection` signature
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**File: `rust/src/server.rs` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
Change signature:
2026-03-29 15:54:39 +00:00
``` rust
2026-03-29 17:40:55 +00:00
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 < ( ) >
2026-03-29 15:54:39 +00:00
```
2026-03-29 17:40:55 +00:00
After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL:
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
``` 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 ) ;
}
}
}
}
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
Populate `remote_addr` when building `ClientInfo` :
``` rust
remote_addr : remote_addr . map ( | a | a . to_string ( ) ) ,
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 6: QUIC listener — pass remote addr through
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**File: `rust/src/server.rs` — `run_quic_listener()` **
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
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
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 7: TypeScript interface updates
2026-03-29 15:54:39 +00:00
**File: `ts/smartvpn.interfaces.ts` **
2026-03-29 17:40:55 +00:00
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 [ ] ;
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
Add to `IVpnClientInfo` :
``` typescript
/** Real client IP:port (from PROXY protocol or direct TCP). */
remoteAddr? : string ;
```
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
### Phase 8: Tests
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**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)
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**Rust unit tests in `acl.rs`: **
- `is_connection_blocked` with various IP patterns
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
**TypeScript tests: **
- Config validation accepts `proxyProtocol: true` + `connectionIpBlockList`
2026-03-29 15:54:39 +00:00
---
2026-03-29 17:40:55 +00:00
## Key Files to Modify
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
| 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` |
2026-03-29 15:54:39 +00:00
---
2026-03-29 17:40:55 +00:00
## Verification
2026-03-29 15:54:39 +00:00
2026-03-29 17:40:55 +00:00
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