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 (
connectionIpBlockListonIVpnServerConfig). 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.rs — ServerConfig
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.rs — ClientInfo
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.rs — run_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.rs — run_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 correctlyparse_valid_ipv6_header— same for IPv6parse_local_command— health check probe returnsis_local: truereject_invalid_signature— random bytes rejectedreject_truncated_header— short reads fail gracefullyreject_v1_header— PROXY v1 text format rejected (we only support v2)
Rust unit tests in acl.rs:
is_connection_blockedwith 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
cargo test— all existing 121 tests + new PP parser tests passpnpm test— all 79 TS tests pass (no PP in test setup, just config validation)- Manual:
socator test harness to send a PP v2 header before WS upgrade, verify server logs real IP