From 229db4be38f9d8c9c63e2d32b82762781798c176 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 29 Mar 2026 17:40:55 +0000 Subject: [PATCH] feat(server): add PROXY protocol v2 support for real client IP handling and connection ACLs --- changelog.md | 7 + readme.md | 34 ++- readme.plan.md | 582 ++++++++++++------------------------- rust/src/acl.rs | 24 ++ rust/src/lib.rs | 1 + rust/src/proxy_protocol.rs | 261 +++++++++++++++++ rust/src/server.rs | 76 ++++- ts/00_commitinfo_data.ts | 2 +- ts/smartvpn.interfaces.ts | 9 + 9 files changed, 592 insertions(+), 404 deletions(-) create mode 100644 rust/src/proxy_protocol.rs diff --git a/changelog.md b/changelog.md index 38cebd6..7ead27e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-29 - 1.9.0 - feat(server) +add PROXY protocol v2 support for real client IP handling and connection ACLs + +- add PROXY protocol v2 parsing for WebSocket connections, including IPv4/IPv6 support, LOCAL command handling, and header read timeout protection +- apply server-level connection IP block lists before the Noise handshake and enforce per-client source IP allow/block lists using the resolved remote address +- expose proxy protocol configuration and remote client address fields in Rust and TypeScript interfaces, and document reverse-proxy usage in the README + ## 2026-03-29 - 1.8.0 - feat(auth,client-registry) add Noise IK client authentication with managed client registry and per-client ACL controls diff --git a/readme.md b/readme.md index 3e210cb..1de2453 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust 🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry 🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol) 🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions +🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum) 📊 **Adaptive QoS**: per-client rate limiting, priority queues, connection quality tracking 🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs 📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs @@ -125,6 +126,32 @@ security: { Supports exact IPs, CIDR, wildcards (`192.168.1.*`), and ranges (`1.1.1.1-1.1.1.100`). +### 🔀 PROXY Protocol v2 + +When the VPN server sits behind a reverse proxy, enable PROXY protocol v2 to receive the **real client IP** instead of the proxy's address. This makes `ipAllowList` / `ipBlockList` ACLs work correctly through load balancers. + +```typescript +await server.start({ + // ... other config ... + proxyProtocol: true, // parse PP v2 headers on WS connections + connectionIpBlockList: ['198.51.100.0/24'], // server-wide block list (pre-handshake) +}); +``` + +**Two-phase ACL with real IPs:** + +| Phase | When | What Happens | +|-------|------|-------------| +| **Pre-handshake** | After TCP accept | Server-level `connectionIpBlockList` rejects known-bad IPs — zero crypto cost | +| **Post-handshake** | After Noise IK identifies client | Per-client `ipAllowList` / `ipBlockList` checked against real source IP | + +- Parses the PP v2 binary header from raw TCP before WebSocket upgrade +- 5-second timeout protects against stalling attacks +- LOCAL command (proxy health checks) handled gracefully +- IPv4 and IPv6 addresses supported +- `remoteAddr` field on `IVpnClientInfo` exposes the real client IP for monitoring +- **Security**: must be `false` (default) when accepting direct connections — only enable behind a trusted proxy + ### 📊 Telemetry & QoS - **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`) @@ -217,13 +244,13 @@ const unit = VpnInstaller.generateServiceUnit({ | Interface | Purpose | |-----------|---------| -| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients) | +| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients, proxy protocol) | | `IVpnClientConfig` | Client configuration (server URL, keys, transport, WG options) | | `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) | | `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) | | `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) | | `IClientConfigBundle` | Full config bundle returned by `createClient()` | -| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key) | +| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr) | | `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health | | `IVpnKeypair` | Base64-encoded public/private key pair | @@ -314,7 +341,7 @@ pnpm install # Build (TypeScript + Rust cross-compile) pnpm build -# Run all tests (79 TS + 121 Rust = 200 tests) +# Run all tests (79 TS + 129 Rust = 208 tests) pnpm test # Run Rust tests directly @@ -345,6 +372,7 @@ smartvpn/ │ ├── crypto.rs # Noise IK + XChaCha20 │ ├── client_registry.rs # Client database │ ├── acl.rs # ACL engine +│ ├── proxy_protocol.rs # PROXY protocol v2 parser │ ├── management.rs # JSON-lines IPC │ ├── transport.rs # WebSocket transport │ ├── quic_transport.rs # QUIC transport diff --git a/readme.plan.md b/readme.plan.md index f15d140..947521a 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,444 +1,236 @@ -# Enterprise Auth & Client Management for SmartVPN +# PROXY Protocol v2 Support for SmartVPN WebSocket Transport ## Context -SmartVPN's Noise NK mode currently allows **any client that knows the server's public key** to connect — no per-client identity or access control. The goal is to make SmartVPN enterprise-ready with: +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. -1. **Per-client cryptographic authentication** (Noise IK handshake) -2. **Rich client definitions** with ACLs, rate limits, and priority -3. **Hub-generated configs** — server generates typed SmartVPN client configs AND WireGuard .conf files from the same client definition -4. **Top-notch DX** — one `createClient()` call gives you everything - -**This is a breaking change.** No backward compatibility with the old NK anonymous mode. +PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade. --- -## Design Overview +## Design -### The Hub Model - -The server acts as a **hub** that manages client definitions. Each client definition is the **single source of truth** from which both SmartVPN native configs and WireGuard configs are generated. +### Two-Phase ACL with Real Client IP ``` -Hub (Server) - └── Client Registry - ├── "alice-laptop" → SmartVPN config OR WireGuard .conf - ├── "bob-phone" → SmartVPN config OR WireGuard .conf - └── "office-gw" → SmartVPN config OR WireGuard .conf +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 ``` -### Authentication: NK → IK (Breaking Change) +- **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. -**Old (removed):** `Noise_NK_25519_ChaChaPoly_BLAKE2s` — client is anonymous -**New (always):** `Noise_IK_25519_ChaChaPoly_BLAKE2s` — client presents its static key during handshake +### No New Dependencies -IK is a 2-message handshake (same count as NK), so **the frame protocol stays identical**. Changes: -- `create_initiator()` now requires `(client_private_key, server_public_key)` — always -- `create_responder()` remains `(server_private_key)` — but now uses IK pattern -- After handshake, server extracts client's public key via `get_remote_static()` and verifies against registry -- Old NK functions are replaced, not kept alongside +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. -**Every client must have a keypair. Every server must have a client registry.** +### 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 --- -## Core Interface: `IClientEntry` +## Implementation -This is the server-side client definition — the central config object. -Naming and structure are aligned with SmartProxy's `IRouteConfig` / `IRouteSecurity` patterns. +### Phase 1: New Rust module `proxy_protocol.rs` -```typescript -export interface IClientEntry { - /** Human-readable client ID (e.g. "alice-laptop") */ - clientId: string; +**New file: `rust/src/proxy_protocol.rs`** - /** Client's Noise IK public key (base64) — for SmartVPN native transport */ - publicKey: string; - /** Client's WireGuard public key (base64) — for WireGuard transport */ - wgPublicKey?: string; - - // ── Security (aligned with SmartProxy IRouteSecurity pattern) ───────── - - security?: IClientSecurity; - - // ── QoS ──────────────────────────────────────────────────────────────── - - /** Traffic priority (lower = higher priority, default: 100) */ - priority?: number; - - // ── Metadata (aligned with SmartProxy IRouteConfig pattern) ──────────── - - /** Whether this client is enabled (default: true) */ - enabled?: boolean; - /** Tags for grouping (e.g. ["engineering", "office"]) */ - tags?: string[]; - /** Optional description */ - description?: string; - /** Optional expiry (ISO 8601 timestamp, omit = never expires) */ - expiresAt?: string; -} - -/** - * Security settings per client — mirrors SmartProxy's IRouteSecurity structure. - * Uses the same ipAllowList/ipBlockList naming convention. - * Adds VPN-specific destination filtering (destinationAllowList/destinationBlockList). - */ -export interface IClientSecurity { - /** Source IPs/CIDRs the client may connect FROM (empty = any). - * Supports: exact IP, CIDR, wildcard (192.168.1.*), ranges (1.1.1.1-1.1.1.5). - * Same format as SmartProxy's ipAllowList. */ - ipAllowList?: string[]; - /** Source IPs blocked — overrides ipAllowList (deny wins). - * Same format as SmartProxy's ipBlockList. */ - ipBlockList?: string[]; - /** Destination IPs/CIDRs the client may reach through the VPN (empty = all) */ - destinationAllowList?: string[]; - /** Destination IPs blocked — overrides destinationAllowList (deny wins) */ - destinationBlockList?: string[]; - /** Max concurrent connections from this client */ - maxConnections?: number; - /** Per-client rate limiting */ - rateLimit?: IClientRateLimit; -} - -export interface IClientRateLimit { - /** Max throughput in bytes/sec */ - bytesPerSec: number; - /** Burst allowance in bytes */ - burstBytes: number; -} +PP v2 binary format: ``` - -### SmartProxy Alignment Notes - -| Pattern | SmartProxy | SmartVPN | -|---------|-----------|---------| -| ACL naming | `ipAllowList` / `ipBlockList` | Same — `ipAllowList` / `ipBlockList` | -| Security grouping | `security: IRouteSecurity` sub-object | Same — `security: IClientSecurity` sub-object | -| Rate limit structure | `rateLimit: IRouteRateLimit` object | Same pattern — `rateLimit: IClientRateLimit` object | -| IP format support | Exact, CIDR, wildcard, ranges | Same formats | -| Metadata fields | `priority`, `tags`, `enabled`, `description` | Same fields | -| ACL evaluation | Block-first, then allow-list | Same — deny overrides allow | - -### ACL Evaluation Order - +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) ``` -1. Check ipBlockList / destinationBlockList first (explicit deny wins) -2. If denied, DROP -3. Check ipAllowList / destinationAllowList (explicit allow) -4. If ipAllowList is empty → allow any source -5. If destinationAllowList is empty → allow all destinations -``` - ---- - -## Hub Config Generation - -### `createClient()` — The One-Call DX - -When the hub creates a client, it: -1. Generates a Noise IK keypair for the client -2. Generates a WireGuard keypair for the client -3. Allocates a VPN IP address -4. Stores the `IClientEntry` in the registry -5. Returns a **complete config bundle** with everything the client needs - -```typescript -export interface IClientConfigBundle { - /** The server-side client entry */ - entry: IClientEntry; - /** Ready-to-use SmartVPN client config (typed object) */ - smartvpnConfig: IVpnClientConfig; - /** Ready-to-use WireGuard .conf file content (string) */ - wireguardConfig: string; - /** Client's private keys (ONLY returned at creation time, not stored server-side) */ - secrets: { - noisePrivateKey: string; - wgPrivateKey: string; - }; -} -``` - -The `secrets` are returned **only at creation time** — the server stores only public keys. - -### `exportClientConfig()` — Re-export (without secrets) - -```typescript -exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): IVpnClientConfig | string -``` - ---- - -## Updated `IVpnServerConfig` - -```typescript -export interface IVpnServerConfig { - listenAddr: string; - tlsCert?: string; - tlsKey?: string; - privateKey: string; // Server's Noise static private key (base64) - publicKey: string; // Server's Noise static public key (base64) - subnet: string; - dns?: string[]; - mtu?: number; - keepaliveIntervalSecs?: number; - enableNat?: boolean; - defaultRateLimitBytesPerSec?: number; - defaultBurstBytes?: number; - transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard'; - quicListenAddr?: string; - quicIdleTimeoutSecs?: number; - wgListenPort?: number; - wgPeers?: IWgPeerConfig[]; // Keep for raw WG mode - - /** Pre-registered clients — REQUIRED for SmartVPN native transport */ - clients: IClientEntry[]; -} -``` - -Note: `clients` is now **required** (not optional), and there is no `authMode` field — IK is always used. - ---- - -## Updated `IVpnClientConfig` - -```typescript -export interface IVpnClientConfig { - serverUrl: string; - serverPublicKey: string; - /** Client's Noise IK private key (base64) — REQUIRED for SmartVPN native transport */ - clientPrivateKey: string; - /** Client's Noise IK public key (base64) — for reference/display */ - clientPublicKey: string; - dns?: string[]; - mtu?: number; - keepaliveIntervalSecs?: number; - transport?: 'auto' | 'websocket' | 'quic' | 'wireguard'; - serverCertHash?: string; - // WireGuard fields unchanged... - wgPrivateKey?: string; - wgAddress?: string; - wgAddressPrefix?: number; - wgPresharedKey?: string; - wgPersistentKeepalive?: number; - wgEndpoint?: string; - wgAllowedIps?: string[]; -} -``` - -Note: `clientPrivateKey` and `clientPublicKey` are now **required** (not optional) for non-WireGuard transports. - ---- - -## New IPC Commands - -Added to `TVpnServerCommands`: - -| Command | Params | Result | Description | -|---------|--------|--------|-------------| -| `createClient` | `{ client: Partial }` | `IClientConfigBundle` | Create client, generate keypairs, assign IP, return full config bundle | -| `removeClient` | `{ clientId: string }` | `void` | Remove from registry + disconnect if connected | -| `getClient` | `{ clientId: string }` | `IClientEntry` | Get a single client entry | -| `listRegisteredClients` | `{}` | `{ clients: IClientEntry[] }` | List all registered clients | -| `updateClient` | `{ clientId: string, update: Partial }` | `void` | Update ACLs, rate limits, tags, etc. | -| `enableClient` | `{ clientId: string }` | `void` | Enable a disabled client | -| `disableClient` | `{ clientId: string }` | `void` | Disable (but don't delete) | -| `rotateClientKey` | `{ clientId: string }` | `IClientConfigBundle` | New keypairs, return fresh config bundle | -| `exportClientConfig` | `{ clientId: string, format: 'smartvpn' \| 'wireguard' }` | `{ config: string }` | Re-export config (without secrets) | -| `generateClientKeypair` | `{}` | `IVpnKeypair` | Generate a standalone Noise IK keypair | - ---- - -## Implementation Plan - -### Phase 1: Rust — Crypto (Replace NK with IK) - -**File: `rust/src/crypto.rs`** - -- Change `NOISE_PATTERN` from NK to IK: `"Noise_IK_25519_ChaChaPoly_BLAKE2s"` -- Replace `create_initiator(server_public_key)` → `create_initiator(client_private_key, server_public_key)` -- `create_responder(private_key)` stays the same signature (IK responder only needs its own key) -- After handshake, `get_remote_static()` on the responder returns the client's public key -- Update `perform_handshake()` to pass client keypair -- Update all tests - -### Phase 2: Rust — Client Registry module - -**New file: `rust/src/client_registry.rs`** -**Modify: `rust/src/lib.rs`** — add `pub mod client_registry;` ```rust -pub struct ClientEntry { - pub client_id: String, - pub public_key: String, - pub wg_public_key: Option, - pub security: Option, - pub priority: Option, - pub enabled: Option, - pub tags: Option>, - pub description: Option, - pub expires_at: Option, - pub assigned_ip: Option, +pub struct ProxyHeader { + pub src_addr: SocketAddr, + pub dst_addr: SocketAddr, + pub is_local: bool, // LOCAL command = health check probe } -/// Mirrors IClientSecurity — aligned with SmartProxy's IRouteSecurity -pub struct ClientSecurity { - pub ip_allow_list: Option>, - pub ip_block_list: Option>, - pub destination_allow_list: Option>, - pub destination_block_list: Option>, - pub max_connections: Option, - pub rate_limit: Option, -} +/// 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 +``` -pub struct ClientRateLimit { - pub bytes_per_sec: u64, - pub burst_bytes: u64, -} +- 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 -pub struct ClientRegistry { - entries: HashMap, // keyed by clientId - key_index: HashMap, // publicKey → clientId (fast lookup) +**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) } ``` -Methods: `add`, `remove`, `get_by_id`, `get_by_key`, `update`, `list`, `is_authorized` (enabled + not expired + key exists), `rotate_key`. +(Keeps `ip_matches_any` private; exposes only the specific check needed.) -### Phase 3: Rust — ACL enforcement module +### Phase 4: WebSocket listener integration -**New file: `rust/src/acl.rs`** -**Modify: `rust/src/lib.rs`** — add `pub mod acl;` +**File: `rust/src/server.rs` — `run_ws_listener()`** + +Between `listener.accept()` and `transport::accept_connection()`: ```rust -/// IP matching supports: exact, CIDR, wildcard, ranges — same as SmartProxy's IpMatcher -pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult { - // 1. Check ip_block_list / destination_block_list (deny overrides) - // 2. Check ip_allow_list / destination_allow_list (explicit allow) - // 3. Empty list = allow all +// 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 ``` -Called in `server.rs` packet loop after decryption, before forwarding. +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 4: Rust — Server changes +### Phase 5: Update `handle_client_connection` signature **File: `rust/src/server.rs`** -- Add `clients: Option>` to `ServerConfig` -- Add `client_registry: RwLock` to `ServerState` (no `auth_mode` — always IK) -- Modify `handle_client_connection()`: - - Always use `create_responder()` (now IK pattern) - - Call `get_remote_static()` **before** `into_transport_mode()` to get client's public key - - Verify against registry — reject unauthorized clients with Disconnect frame - - Use registry entry for rate limits (overrides server defaults) - - In packet loop: call `acl::check_acl()` on decrypted packets -- Add `ClientInfo.authenticated_key: String` and `ClientInfo.registered_client_id: String` (no longer optional) -- Add methods: `create_client()`, `remove_client()`, `update_client()`, `list_registered_clients()`, `rotate_client_key()`, `export_client_config()` +Change signature: +```rust +async fn handle_client_connection( + state: Arc, + mut sink: Box, + mut stream: Box, + remote_addr: Option, // NEW +) -> Result<()> +``` -### Phase 5: Rust — Client changes +After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL: -**File: `rust/src/client.rs`** +```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); + } + } + } +} +``` -- Add `client_private_key: String` to `ClientConfig` (required, not optional) -- `connect()` always uses `create_initiator(client_private_key, server_public_key)` (IK) +Populate `remote_addr` when building `ClientInfo`: +```rust +remote_addr: remote_addr.map(|a| a.to_string()), +``` -### Phase 6: Rust — Management IPC handlers +### Phase 6: QUIC listener — pass remote addr through -**File: `rust/src/management.rs`** +**File: `rust/src/server.rs` — `run_quic_listener()`** -Add handlers for all 10 new IPC commands following existing patterns. +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 — Interfaces +### Phase 7: TypeScript interface updates **File: `ts/smartvpn.interfaces.ts`** -- Add `IClientEntry` interface -- Add `IClientConfigBundle` interface -- Update `IVpnServerConfig`: add required `clients: IClientEntry[]` -- Update `IVpnClientConfig`: add required `clientPrivateKey: string`, `clientPublicKey: string` -- Update `IVpnClientInfo`: add `authenticatedKey: string`, `registeredClientId: string` -- Add new commands to `TVpnServerCommands` +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[]; +``` -### Phase 8: TypeScript — VpnServer class methods +Add to `IVpnClientInfo`: +```typescript +/** Real client IP:port (from PROXY protocol or direct TCP). */ +remoteAddr?: string; +``` -**File: `ts/smartvpn.classes.vpnserver.ts`** +### Phase 8: Tests -Add methods: -- `createClient(opts)` → `IClientConfigBundle` -- `removeClient(clientId)` → `void` -- `getClient(clientId)` → `IClientEntry` -- `listRegisteredClients()` → `IClientEntry[]` -- `updateClient(clientId, update)` → `void` -- `enableClient(clientId)` / `disableClient(clientId)` -- `rotateClientKey(clientId)` → `IClientConfigBundle` -- `exportClientConfig(clientId, format)` → `string | IVpnClientConfig` +**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) -### Phase 9: TypeScript — Config validation +**Rust unit tests in `acl.rs`:** +- `is_connection_blocked` with various IP patterns -**File: `ts/smartvpn.classes.vpnconfig.ts`** - -- Server config: validate `clients` present, each entry has valid `clientId` + `publicKey` -- Client config: validate `clientPrivateKey` and `clientPublicKey` present for non-WG transports -- Validate CIDRs in ACL fields - -### Phase 10: TypeScript — Hub config generation - -**File: `ts/smartvpn.classes.wgconfig.ts`** (extend existing) - -Add `generateClientConfigFromEntry(entry, serverConfig)` — produces WireGuard .conf from `IClientEntry`. - -### Phase 11: Update existing tests - -All existing tests that use the old NK handshake or old config shapes need updating: -- Rust tests in `crypto.rs`, `server.rs`, `client.rs` -- TS tests in `test/test.vpnconfig.node.ts`, `test/test.flowcontrol.node.ts`, etc. -- Tests now must provide client keypairs and client registry entries - ---- - -## DX Highlights - -1. **One call to create a client:** - ```typescript - const bundle = await server.createClient({ clientId: 'alice-laptop', tags: ['engineering'] }); - // bundle.smartvpnConfig — typed SmartVPN client config - // bundle.wireguardConfig — standard WireGuard .conf string - // bundle.secrets — private keys, shown only at creation time - ``` - -2. **Typed config objects throughout** — no raw strings or JSON blobs - -3. **Dual transport from same definition** — register once, connect via SmartVPN or WireGuard - -4. **ACLs are deny-overrides-allow** — intuitive enterprise model - -5. **Hot management** — add/remove/update/disable clients at runtime - -6. **Key rotation** — `rotateClientKey()` generates new keys and returns a fresh config bundle - ---- - -## Verification Plan - -1. **Rust unit tests:** - - `crypto.rs`: IK handshake roundtrip, `get_remote_static()` returns correct key, wrong key fails - - `client_registry.rs`: CRUD, `is_authorized` with enabled/disabled/expired - - `acl.rs`: allow/deny logic, empty lists, deny-overrides-allow - -2. **Rust integration tests:** - - Server accepts authorized client - - Server rejects unknown client public key - - ACL filtering drops packets to blocked destinations - - Runtime `createClient` / `removeClient` works - - Disabled client rejected at handshake - -3. **TypeScript tests:** - - Config validation with required client fields - - `createClient()` returns valid bundle with both formats - - `exportClientConfig()` generates valid WireGuard .conf - - Full IPC roundtrip: create client → connect → traffic → disconnect - -4. **Build:** `pnpm build` (TS + Rust), `cargo test`, `pnpm test` +**TypeScript tests:** +- Config validation accepts `proxyProtocol: true` + `connectionIpBlockList` --- @@ -446,14 +238,16 @@ All existing tests that use the old NK handshake or old config shapes need updat | File | Changes | |------|---------| -| `rust/src/crypto.rs` | Replace NK with IK pattern, update initiator signature | -| `rust/src/client_registry.rs` | **NEW** — client registry module | -| `rust/src/acl.rs` | **NEW** — ACL evaluation module | -| `rust/src/server.rs` | Registry integration, IK auth in handshake, ACL in packet loop | -| `rust/src/client.rs` | Required `client_private_key`, IK initiator | -| `rust/src/management.rs` | 10 new IPC command handlers | -| `rust/src/lib.rs` | Register new modules | -| `ts/smartvpn.interfaces.ts` | `IClientEntry`, `IClientConfigBundle`, updated configs & commands | -| `ts/smartvpn.classes.vpnserver.ts` | New hub methods | -| `ts/smartvpn.classes.vpnconfig.ts` | Updated validation rules | -| `ts/smartvpn.classes.wgconfig.ts` | Config generation from client entries | +| `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 diff --git a/rust/src/acl.rs b/rust/src/acl.rs index afb4683..de9af86 100644 --- a/rust/src/acl.rs +++ b/rust/src/acl.rs @@ -11,6 +11,30 @@ pub enum AclResult { DenyDst, } +/// Check whether a connection source IP is in a server-level block list. +/// Used for pre-handshake rejection of known-bad IPs. +pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool { + ip_matches_any(ip, block_list) +} + +/// Check whether a source IP is allowed by allow/block lists. +/// Returns true if the IP is permitted (not blocked and passes allow check). +pub fn is_source_allowed(ip: Ipv4Addr, allow_list: Option<&[String]>, block_list: Option<&[String]>) -> bool { + // Deny overrides allow + if let Some(bl) = block_list { + if ip_matches_any(ip, bl) { + return false; + } + } + // If allow list exists and is non-empty, IP must match + if let Some(al) = allow_list { + if !al.is_empty() && !ip_matches_any(ip, al) { + return false; + } + } + true +} + /// Check whether a packet from `src_ip` to `dst_ip` is allowed by the client's security policy. /// /// Evaluation order (deny overrides allow): diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 99851e2..85acbad 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -20,3 +20,4 @@ pub mod mtu; pub mod wireguard; pub mod client_registry; pub mod acl; +pub mod proxy_protocol; diff --git a/rust/src/proxy_protocol.rs b/rust/src/proxy_protocol.rs new file mode 100644 index 0000000..325f3d2 --- /dev/null +++ b/rust/src/proxy_protocol.rs @@ -0,0 +1,261 @@ +//! PROXY protocol v2 parser for extracting real client addresses +//! when SmartVPN sits behind a reverse proxy (HAProxy, SmartProxy, etc.). +//! +//! Spec: + +use anyhow::Result; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::net::TcpStream; + +/// Timeout for reading the PROXY protocol header from a new connection. +const PROXY_HEADER_TIMEOUT: Duration = Duration::from_secs(5); + +/// The 12-byte PP v2 signature. +const PP_V2_SIGNATURE: [u8; 12] = [ + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, +]; + +/// Parsed PROXY protocol v2 header. +#[derive(Debug, Clone)] +pub struct ProxyHeader { + /// Real client source address. + pub src_addr: SocketAddr, + /// Proxy-to-server destination address. + pub dst_addr: SocketAddr, + /// True if this is a LOCAL command (health check probe from proxy). + pub is_local: bool, +} + +/// Read and parse a PROXY protocol v2 header from a TCP stream. +/// +/// Reads exactly the header bytes — the stream is in a clean state for +/// WebSocket upgrade afterward. Returns an error on timeout, invalid +/// signature, or malformed header. +pub async fn read_proxy_header(stream: &mut TcpStream) -> Result { + tokio::time::timeout(PROXY_HEADER_TIMEOUT, read_proxy_header_inner(stream)) + .await + .map_err(|_| anyhow::anyhow!("PROXY protocol header read timed out ({}s)", PROXY_HEADER_TIMEOUT.as_secs()))? +} + +async fn read_proxy_header_inner(stream: &mut TcpStream) -> Result { + // Read the 16-byte fixed prefix + let mut prefix = [0u8; 16]; + stream.read_exact(&mut prefix).await?; + + // Validate the 12-byte signature + if prefix[..12] != PP_V2_SIGNATURE { + anyhow::bail!("Invalid PROXY protocol v2 signature"); + } + + // Byte 12: version (high nibble) | command (low nibble) + let version = (prefix[12] & 0xF0) >> 4; + let command = prefix[12] & 0x0F; + + if version != 2 { + anyhow::bail!("Unsupported PROXY protocol version: {}", version); + } + + // Byte 13: address family (high nibble) | protocol (low nibble) + let addr_family = (prefix[13] & 0xF0) >> 4; + let _protocol = prefix[13] & 0x0F; // 1 = STREAM (TCP) + + // Bytes 14-15: address data length (big-endian) + let addr_len = u16::from_be_bytes([prefix[14], prefix[15]]) as usize; + + // Read the address data + let mut addr_data = vec![0u8; addr_len]; + if addr_len > 0 { + stream.read_exact(&mut addr_data).await?; + } + + // LOCAL command (0x00) = health check, no real address + if command == 0x00 { + return Ok(ProxyHeader { + src_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)), + dst_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)), + is_local: true, + }); + } + + // PROXY command (0x01) — parse address block + if command != 0x01 { + anyhow::bail!("Unknown PROXY protocol command: {}", command); + } + + match addr_family { + // AF_INET (IPv4): 4 src + 4 dst + 2 src_port + 2 dst_port = 12 bytes + 1 => { + if addr_data.len() < 12 { + anyhow::bail!("IPv4 address block too short: {} bytes", addr_data.len()); + } + let src_ip = Ipv4Addr::new(addr_data[0], addr_data[1], addr_data[2], addr_data[3]); + let dst_ip = Ipv4Addr::new(addr_data[4], addr_data[5], addr_data[6], addr_data[7]); + let src_port = u16::from_be_bytes([addr_data[8], addr_data[9]]); + let dst_port = u16::from_be_bytes([addr_data[10], addr_data[11]]); + Ok(ProxyHeader { + src_addr: SocketAddr::V4(SocketAddrV4::new(src_ip, src_port)), + dst_addr: SocketAddr::V4(SocketAddrV4::new(dst_ip, dst_port)), + is_local: false, + }) + } + // AF_INET6 (IPv6): 16 src + 16 dst + 2 src_port + 2 dst_port = 36 bytes + 2 => { + if addr_data.len() < 36 { + anyhow::bail!("IPv6 address block too short: {} bytes", addr_data.len()); + } + let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[0..16]).unwrap()); + let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[16..32]).unwrap()); + let src_port = u16::from_be_bytes([addr_data[32], addr_data[33]]); + let dst_port = u16::from_be_bytes([addr_data[34], addr_data[35]]); + Ok(ProxyHeader { + src_addr: SocketAddr::V6(SocketAddrV6::new(src_ip, src_port, 0, 0)), + dst_addr: SocketAddr::V6(SocketAddrV6::new(dst_ip, dst_port, 0, 0)), + is_local: false, + }) + } + // AF_UNSPEC or unknown + _ => { + anyhow::bail!("Unsupported address family: {}", addr_family); + } + } +} + +/// Build a PROXY protocol v2 header (for testing / proxy implementations). +pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&PP_V2_SIGNATURE); + + match (src, dst) { + (SocketAddr::V4(s), SocketAddr::V4(d)) => { + buf.push(0x21); // version 2 | PROXY command + buf.push(0x11); // AF_INET | STREAM + buf.extend_from_slice(&12u16.to_be_bytes()); // addr length + buf.extend_from_slice(&s.ip().octets()); + buf.extend_from_slice(&d.ip().octets()); + buf.extend_from_slice(&s.port().to_be_bytes()); + buf.extend_from_slice(&d.port().to_be_bytes()); + } + (SocketAddr::V6(s), SocketAddr::V6(d)) => { + buf.push(0x21); // version 2 | PROXY command + buf.push(0x21); // AF_INET6 | STREAM + buf.extend_from_slice(&36u16.to_be_bytes()); // addr length + buf.extend_from_slice(&s.ip().octets()); + buf.extend_from_slice(&d.ip().octets()); + buf.extend_from_slice(&s.port().to_be_bytes()); + buf.extend_from_slice(&d.port().to_be_bytes()); + } + _ => panic!("Mismatched address families"), + } + buf +} + +/// Build a PROXY protocol v2 LOCAL header (health check probe). +pub fn build_pp_v2_local() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&PP_V2_SIGNATURE); + buf.push(0x20); // version 2 | LOCAL command + buf.push(0x00); // AF_UNSPEC + buf.extend_from_slice(&0u16.to_be_bytes()); // no address data + buf +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + /// Helper: create a TCP pair and write data to the client side, then parse from server side. + async fn parse_header_from_bytes(header_bytes: &[u8]) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let data = header_bytes.to_vec(); + let client_task = tokio::spawn(async move { + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(&data).await.unwrap(); + client // keep alive + }); + + let (mut server_stream, _) = listener.accept().await.unwrap(); + let result = read_proxy_header(&mut server_stream).await; + let _client = client_task.await.unwrap(); + result + } + + #[tokio::test] + async fn parse_valid_ipv4_header() { + let src = "203.0.113.50:12345".parse::().unwrap(); + let dst = "10.0.0.1:443".parse::().unwrap(); + let header = build_pp_v2_header(src, dst); + + let parsed = parse_header_from_bytes(&header).await.unwrap(); + assert!(!parsed.is_local); + assert_eq!(parsed.src_addr, src); + assert_eq!(parsed.dst_addr, dst); + } + + #[tokio::test] + async fn parse_valid_ipv6_header() { + let src = "[2001:db8::1]:54321".parse::().unwrap(); + let dst = "[2001:db8::2]:443".parse::().unwrap(); + let header = build_pp_v2_header(src, dst); + + let parsed = parse_header_from_bytes(&header).await.unwrap(); + assert!(!parsed.is_local); + assert_eq!(parsed.src_addr, src); + assert_eq!(parsed.dst_addr, dst); + } + + #[tokio::test] + async fn parse_local_command() { + let header = build_pp_v2_local(); + let parsed = parse_header_from_bytes(&header).await.unwrap(); + assert!(parsed.is_local); + } + + #[tokio::test] + async fn reject_invalid_signature() { + let mut header = build_pp_v2_local(); + header[0] = 0xFF; // corrupt signature + let result = parse_header_from_bytes(&header).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("signature")); + } + + #[tokio::test] + async fn reject_wrong_version() { + let mut header = build_pp_v2_local(); + header[12] = 0x10; // version 1 instead of 2 + let result = parse_header_from_bytes(&header).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("version")); + } + + #[tokio::test] + async fn reject_truncated_header() { + // Only 10 bytes — not even the full signature + let result = parse_header_from_bytes(&[0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49]).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn ipv4_header_is_exactly_28_bytes() { + let src = "1.2.3.4:80".parse::().unwrap(); + let dst = "5.6.7.8:443".parse::().unwrap(); + let header = build_pp_v2_header(src, dst); + // 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 12 addrs = 28 + assert_eq!(header.len(), 28); + } + + #[tokio::test] + async fn ipv6_header_is_exactly_52_bytes() { + let src = "[::1]:80".parse::().unwrap(); + let dst = "[::2]:443".parse::().unwrap(); + let header = build_pp_v2_header(src, dst); + // 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 36 addrs = 52 + assert_eq!(header.len(), 52); + } +} diff --git a/rust/src/server.rs b/rust/src/server.rs index 7522b54..14e97ed 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -49,6 +49,11 @@ pub struct ServerConfig { pub quic_idle_timeout_secs: Option, /// Pre-registered clients for IK authentication. pub clients: Option>, + /// Enable PROXY protocol v2 parsing on incoming WebSocket connections. + /// SECURITY: Must be false when accepting direct client connections. + pub proxy_protocol: Option, + /// Server-level IP block list — applied at TCP accept, before Noise handshake. + pub connection_ip_block_list: Option>, } /// Information about a connected client. @@ -70,6 +75,8 @@ pub struct ClientInfo { pub authenticated_key: String, /// Registered client ID from the client registry. pub registered_client_id: String, + /// Real client IP:port (from PROXY protocol header or direct TCP connection). + pub remote_addr: Option, } /// Server statistics. @@ -562,8 +569,8 @@ impl VpnServer { } } -/// WebSocket listener — accepts TCP connections, upgrades to WS, then hands off -/// to the transport-agnostic `handle_client_connection`. +/// WebSocket listener — accepts TCP connections, optionally parses PROXY protocol v2, +/// upgrades to WS, then hands off to `handle_client_connection`. async fn run_ws_listener( state: Arc, listen_addr: String, @@ -576,17 +583,51 @@ async fn run_ws_listener( tokio::select! { accept = listener.accept() => { match accept { - Ok((stream, addr)) => { - info!("New connection from {}", addr); + Ok((mut tcp_stream, tcp_addr)) => { + info!("New connection from {}", tcp_addr); let state = state.clone(); tokio::spawn(async move { - match transport::accept_connection(stream).await { + // Phase 0: Parse PROXY protocol v2 header if enabled + let remote_addr = if state.config.proxy_protocol.unwrap_or(false) { + match crate::proxy_protocol::read_proxy_header(&mut tcp_stream).await { + Ok(header) if header.is_local => { + info!("PP v2 LOCAL probe from {}", tcp_addr); + return; // Health check — close gracefully + } + Ok(header) => { + info!("PP v2: real client {} (via {})", header.src_addr, tcp_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 + }; + + // Phase 1: Server-level connection IP block list (pre-handshake) + if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) { + if !block_list.is_empty() { + 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; + } + } + } + } + + // Phase 2: WebSocket upgrade + VPN handshake + match transport::accept_connection(tcp_stream).await { Ok(ws) => { let (sink, stream) = transport_trait::split_ws(ws); if let Err(e) = handle_client_connection( state, Box::new(sink), Box::new(stream), + remote_addr, ).await { warn!("Client connection error: {}", e); } @@ -662,6 +703,7 @@ async fn run_quic_listener( state, Box::new(sink), Box::new(stream), + Some(remote), ).await { warn!("QUIC client error: {}", e); } @@ -700,6 +742,7 @@ async fn handle_client_connection( state: Arc, mut sink: Box, mut stream: Box, + remote_addr: Option, ) -> Result<()> { let server_private_key = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, @@ -779,6 +822,24 @@ async fn handle_client_connection( let mut noise_transport = responder.into_transport_mode()?; + // Connection-level ACL: check real client IP against per-client ipAllowList/ipBlockList + if let (Some(ref sec), Some(ref addr)) = (&client_security, &remote_addr) { + if let std::net::IpAddr::V4(v4) = addr.ip() { + if !acl::is_source_allowed( + v4, + sec.ip_allow_list.as_deref(), + sec.ip_block_list.as_deref(), + ) { + warn!("Connection-level ACL denied client {} from IP {}", registered_client_id, addr); + let disconnect_frame = Frame { packet_type: PacketType::Disconnect, payload: Vec::new() }; + let mut frame_bytes = BytesMut::new(); + >::encode(&mut FrameCodec, disconnect_frame, &mut frame_bytes)?; + let _ = sink.send_reliable(frame_bytes.to_vec()).await; + anyhow::bail!("Connection denied: source IP {} not allowed for client {}", addr, registered_client_id); + } + } + } + // Use the registered client ID as the connection ID let client_id = registered_client_id.clone(); @@ -811,6 +872,7 @@ async fn handle_client_connection( burst_bytes: burst, authenticated_key: client_pub_key_b64.clone(), registered_client_id: registered_client_id.clone(), + remote_addr: remote_addr.map(|a| a.to_string()), }; state.clients.write().await.insert(client_id.clone(), client_info); @@ -845,7 +907,9 @@ async fn handle_client_connection( >::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?; sink.send_reliable(frame_bytes.to_vec()).await?; - info!("Client {} ({}) connected with IP {}", registered_client_id, &client_pub_key_b64[..8], assigned_ip); + info!("Client {} ({}) connected with IP {} from {}", + registered_client_id, &client_pub_key_b64[..8], assigned_ip, + remote_addr.map(|a| a.to_string()).unwrap_or_else(|| "unknown".to_string())); // Main packet loop with dead-peer detection let mut last_activity = tokio::time::Instant::now(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index eb15d73..6de5ed9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartvpn', - version: '1.8.0', + version: '1.9.0', description: 'A VPN solution with TypeScript control plane and Rust data plane daemon' } diff --git a/ts/smartvpn.interfaces.ts b/ts/smartvpn.interfaces.ts index 05bfded..3d87ae5 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -102,6 +102,13 @@ export interface IVpnServerConfig { wgPeers?: IWgPeerConfig[]; /** Pre-registered clients for Noise IK authentication */ clients?: IClientEntry[]; + /** Enable PROXY protocol v2 on incoming WebSocket connections. + * Required when behind a reverse proxy that sends PP v2 headers (HAProxy, SmartProxy). + * SECURITY: Must be false when accepting direct client connections. */ + proxyProtocol?: boolean; + /** Server-level IP block list — applied at TCP accept, before Noise handshake. + * Supports exact IPs, CIDR, wildcards, ranges. */ + connectionIpBlockList?: string[]; } export interface IVpnServerOptions { @@ -156,6 +163,8 @@ export interface IVpnClientInfo { authenticatedKey: string; /** Registered client ID from the client registry */ registeredClientId: string; + /** Real client IP:port (from PROXY protocol or direct TCP connection) */ + remoteAddr?: string; } export interface IVpnServerStatistics extends IVpnStatistics {