@push.rocks/smartvpn
v1.19.2
Published
A VPN solution with TypeScript control plane and Rust data plane daemon
Downloads
877
Maintainers
Readme
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 beforeKey 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)).awaitPhase 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
