6 Commits

17 changed files with 1771 additions and 440 deletions

View File

@@ -1,5 +1,29 @@
# Changelog
## 2026-03-29 - 1.10.1 - fix(test, docs, scripts)
correct test command verbosity, shorten load test timings, and document forwarding modes
- Fixes the test script by removing the duplicated verbose flag in package.json.
- Reduces load test delays and burst sizes to keep keepalive and connection tests faster and more stable.
- Updates the README to describe forwardingMode options, userspace NAT support, and related configuration examples.
## 2026-03-29 - 1.10.0 - feat(rust-server, rust-client, ts-interfaces)
add configurable packet forwarding with TUN and userspace NAT modes
- introduce forwardingMode options for client and server configuration interfaces
- add server-side forwarding engines for kernel TUN, userspace socket NAT, and testing mode
- add a smoltcp-based userspace NAT implementation for packet forwarding without root-only TUN routing
- enable client-side TUN forwarding support with route setup, packet I/O, and cleanup
- centralize raw packet destination IP extraction in tunnel utilities for shared routing logic
- update test command timeout and logging flags
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvpn",
"version": "1.8.0",
"version": "1.10.1",
"private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module",
@@ -12,7 +12,7 @@
"scripts": {
"build": "(tsbuild tsfolders) && (tsrust)",
"test:before": "(tsrust)",
"test": "tstest test/ --verbose",
"test": "tstest test/ --verbose --logfile --timeout 60",
"buildDocs": "tsdoc"
},
"repository": {

View File

@@ -5,9 +5,11 @@ 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
🌐 **Flexible forwarding**: TUN device (kernel), userspace NAT (no root), or testing mode
## Issue Reporting and Security
@@ -53,6 +55,7 @@ await server.start({
publicKey: '<server-noise-public-key-base64>',
subnet: '10.8.0.0/24',
transportMode: 'both', // WebSocket + QUIC simultaneously
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing'
enableNat: true,
dns: ['1.1.1.1', '8.8.8.8'],
});
@@ -125,6 +128,59 @@ 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
### 📦 Packet Forwarding Modes
SmartVPN supports three forwarding modes, configurable per-server and per-client:
| Mode | Flag | Description | Root Required |
|------|------|-------------|---------------|
| **TUN** | `'tun'` | Kernel TUN device — real packet forwarding with system routing | ✅ Yes |
| **Userspace NAT** | `'socket'` | Userspace TCP/UDP proxy via `connect(2)` — no TUN, no root needed | ❌ No |
| **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No |
```typescript
// Server with userspace NAT (no root required)
await server.start({
// ...
forwardingMode: 'socket',
enableNat: true,
});
// Client with TUN device
const { assignedIp } = await client.connect({
// ...
forwardingMode: 'tun',
});
```
The userspace NAT mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring `CAP_NET_ADMIN` or root privileges.
### 📊 Telemetry & QoS
- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`)
@@ -217,13 +273,13 @@ const unit = VpnInstaller.generateServiceUnit({
| Interface | Purpose |
|-----------|---------|
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients) |
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, WG options) |
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode, clients, proxy protocol) |
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, 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 +370,7 @@ pnpm install
# Build (TypeScript + Rust cross-compile)
pnpm build
# Run all tests (79 TS + 121 Rust = 200 tests)
# Run all tests (79 TS + 132 Rust = 211 tests)
pnpm test
# Run Rust tests directly
@@ -345,6 +401,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
@@ -352,6 +409,7 @@ smartvpn/
│ ├── codec.rs # Binary frame protocol
│ ├── keepalive.rs # Adaptive keepalives
│ ├── ratelimit.rs # Token bucket
│ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy
│ └── ... # tunnel, network, telemetry, qos, mtu, reconnect
├── test/ # 9 test files (79 tests)
├── dist_ts/ # Compiled TypeScript
@@ -360,7 +418,7 @@ smartvpn/
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license.md) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -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<IClientEntry> }` | `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<IClientEntry> }` | `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<String>,
pub security: Option<ClientSecurity>,
pub priority: Option<u32>,
pub enabled: Option<bool>,
pub tags: Option<Vec<String>>,
pub description: Option<String>,
pub expires_at: Option<String>,
pub assigned_ip: Option<String>,
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<Vec<String>>,
pub ip_block_list: Option<Vec<String>>,
pub destination_allow_list: Option<Vec<String>>,
pub destination_block_list: Option<Vec<String>>,
pub max_connections: Option<u32>,
pub rate_limit: Option<ClientRateLimit>,
}
/// 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>
```
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<String, ClientEntry>, // keyed by clientId
key_index: HashMap<String, String>, // 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<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)
}
```
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<Vec<ClientEntry>>` to `ServerConfig`
- Add `client_registry: RwLock<ClientRegistry>` 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<ServerState>,
mut sink: Box<dyn TransportSink>,
mut stream: Box<dyn TransportStream>,
remote_addr: Option<std::net::SocketAddr>, // 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

115
rust/Cargo.lock generated
View File

@@ -237,6 +237,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -488,6 +494,47 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "defmt"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -714,6 +761,25 @@ dependencies = [
"polyval",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -915,6 +981,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1116,6 +1188,28 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1598,6 +1692,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"smoltcp",
"snow",
"thiserror 2.0.18",
"tokio",
@@ -1609,6 +1704,20 @@ dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "smoltcp"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"cfg-if",
"defmt 0.3.100",
"heapless",
"managed",
]
[[package]]
name = "snow"
version = "0.9.6"
@@ -1635,6 +1744,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"

View File

@@ -35,6 +35,7 @@ rustls-pemfile = "2"
webpki-roots = "1"
mimalloc = "0.1"
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["medium-ip", "proto-ipv4", "socket-tcp", "socket-udp", "alloc"] }
chrono = { version = "0.4", features = ["serde"] }
ipnet = "2"

View File

@@ -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):

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use bytes::BytesMut;
use serde::Deserialize;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, error, warn, debug};
@@ -12,6 +13,7 @@ use crate::telemetry::ConnectionQuality;
use crate::transport;
use crate::transport_trait::{self, TransportSink, TransportStream};
use crate::quic_transport;
use crate::tunnel::{self, TunConfig};
/// Client configuration (matches TS IVpnClientConfig).
#[derive(Debug, Clone, Deserialize)]
@@ -30,6 +32,9 @@ pub struct ClientConfig {
pub transport: Option<String>,
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
pub server_cert_hash: Option<String>,
/// Forwarding mode: "tun" (TUN device, requires root) or "testing" (no TUN).
/// Default: "testing".
pub forwarding_mode: Option<String>,
}
/// Client statistics.
@@ -234,6 +239,31 @@ impl VpnClient {
info!("Connected to VPN, assigned IP: {}", assigned_ip);
// Optionally create TUN device for IP packet forwarding (requires root)
let tun_enabled = config.forwarding_mode.as_deref() == Some("tun");
let (tun_reader, tun_writer, tun_subnet) = if tun_enabled {
let client_tun_ip: Ipv4Addr = assigned_ip.parse()?;
let mtu = ip_info["mtu"].as_u64().unwrap_or(1420) as u16;
let tun_config = TunConfig {
name: "svpn-client0".to_string(),
address: client_tun_ip,
netmask: Ipv4Addr::new(255, 255, 255, 0),
mtu,
};
let tun_device = tunnel::create_tun(&tun_config)?;
// Add route for VPN subnet through the TUN device
let gateway_str = ip_info["gateway"].as_str().unwrap_or("10.8.0.1");
let gateway: Ipv4Addr = gateway_str.parse().unwrap_or(Ipv4Addr::new(10, 8, 0, 1));
let subnet = format!("{}/24", Ipv4Addr::from(u32::from(gateway) & 0xFFFFFF00));
tunnel::add_route(&subnet, &tun_config.name).await?;
let (reader, writer) = tokio::io::split(tun_device);
(Some(reader), Some(writer), Some(subnet))
} else {
(None, None, None)
};
// Create adaptive keepalive monitor (use custom interval if configured)
let ka_config = config.keepalive_interval_secs.map(|secs| {
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
@@ -260,6 +290,9 @@ impl VpnClient {
handle.signal_rx,
handle.ack_tx,
link_health,
tun_reader,
tun_writer,
tun_subnet,
));
Ok(assigned_ip_clone)
@@ -356,8 +389,14 @@ async fn client_loop(
mut signal_rx: mpsc::Receiver<KeepaliveSignal>,
ack_tx: mpsc::Sender<()>,
link_health: Arc<RwLock<LinkHealth>>,
mut tun_reader: Option<tokio::io::ReadHalf<tun::AsyncDevice>>,
mut tun_writer: Option<tokio::io::WriteHalf<tun::AsyncDevice>>,
tun_subnet: Option<String>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 65535];
let mut tun_buf = vec![0u8; 65536];
loop {
tokio::select! {
@@ -373,6 +412,14 @@ async fn client_loop(
let mut s = stats.write().await;
s.bytes_received += len as u64;
s.packets_received += 1;
drop(s);
// Write decrypted packet to TUN device (if enabled)
if let Some(ref mut writer) = tun_writer {
if let Err(e) = writer.write_all(&buf[..len]).await {
warn!("TUN write error: {}", e);
}
}
}
Err(e) => {
warn!("Decrypt error: {}", e);
@@ -407,6 +454,50 @@ async fn client_loop(
}
}
}
// Read outbound packets from TUN and send to server (only when TUN enabled)
result = async {
match tun_reader {
Some(ref mut reader) => reader.read(&mut tun_buf).await,
None => std::future::pending::<std::io::Result<usize>>().await,
}
} => {
match result {
Ok(0) => {
info!("TUN device closed");
break;
}
Ok(n) => {
match noise_transport.write_message(&tun_buf[..n], &mut buf) {
Ok(len) => {
let frame = Frame {
packet_type: PacketType::IpPacket,
payload: buf[..len].to_vec(),
};
let mut frame_bytes = BytesMut::new();
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(
&mut FrameCodec, frame, &mut frame_bytes
).is_ok() {
if sink.send_reliable(frame_bytes.to_vec()).await.is_err() {
warn!("Failed to send TUN packet to server");
break;
}
let mut s = stats.write().await;
s.bytes_sent += n as u64;
s.packets_sent += 1;
}
}
Err(e) => {
warn!("Noise encrypt error: {}", e);
break;
}
}
}
Err(e) => {
warn!("TUN read error: {}", e);
break;
}
}
}
signal = signal_rx.recv() => {
match signal {
Some(KeepaliveSignal::SendPing(timestamp_ms)) => {
@@ -456,6 +547,13 @@ async fn client_loop(
}
}
}
// Cleanup: remove TUN route if enabled
if let Some(ref subnet) = tun_subnet {
if let Err(e) = tunnel::remove_route(subnet, "svpn-client0").await {
warn!("Failed to remove client TUN route: {}", e);
}
}
}
/// Try to connect via QUIC. Returns transport halves on success.

View File

@@ -20,3 +20,5 @@ pub mod mtu;
pub mod wireguard;
pub mod client_registry;
pub mod acl;
pub mod proxy_protocol;
pub mod userspace_nat;

261
rust/src/proxy_protocol.rs Normal file
View File

@@ -0,0 +1,261 @@
//! PROXY protocol v2 parser for extracting real client addresses
//! when SmartVPN sits behind a reverse proxy (HAProxy, SmartProxy, etc.).
//!
//! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt>
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<ProxyHeader> {
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<ProxyHeader> {
// 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<u8> {
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<u8> {
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<ProxyHeader> {
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::<SocketAddr>().unwrap();
let dst = "10.0.0.1:443".parse::<SocketAddr>().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::<SocketAddr>().unwrap();
let dst = "[2001:db8::2]:443".parse::<SocketAddr>().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::<SocketAddr>().unwrap();
let dst = "5.6.7.8:443".parse::<SocketAddr>().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::<SocketAddr>().unwrap();
let dst = "[::2]:443".parse::<SocketAddr>().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);
}
}

View File

@@ -19,6 +19,7 @@ use crate::ratelimit::TokenBucket;
use crate::transport;
use crate::transport_trait::{self, TransportSink, TransportStream};
use crate::quic_transport;
use crate::tunnel::{self, TunConfig};
/// Dead-peer timeout: 3x max keepalive interval (Healthy=60s).
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
@@ -37,6 +38,9 @@ pub struct ServerConfig {
pub mtu: Option<u16>,
pub keepalive_interval_secs: Option<u64>,
pub enable_nat: Option<bool>,
/// Forwarding mode: "tun" (kernel TUN, requires root), "socket" (userspace NAT),
/// or "testing" (monitoring only, no forwarding). Default: "testing".
pub forwarding_mode: Option<String>,
/// Default rate limit for new clients (bytes/sec). None = unlimited.
pub default_rate_limit_bytes_per_sec: Option<u64>,
/// Default burst size for new clients (bytes). None = unlimited.
@@ -49,6 +53,11 @@ pub struct ServerConfig {
pub quic_idle_timeout_secs: Option<u64>,
/// Pre-registered clients for IK authentication.
pub clients: Option<Vec<ClientEntry>>,
/// Enable PROXY protocol v2 parsing on incoming 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, before Noise handshake.
pub connection_ip_block_list: Option<Vec<String>>,
}
/// Information about a connected client.
@@ -70,6 +79,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<String>,
}
/// Server statistics.
@@ -87,6 +98,16 @@ pub struct ServerStatistics {
pub total_connections: u64,
}
/// The forwarding engine determines how decrypted IP packets are routed.
pub enum ForwardingEngine {
/// Kernel TUN device — packets written to the TUN, kernel handles routing.
Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
Socket(mpsc::Sender<Vec<u8>>),
/// Testing/monitoring — packets are counted but not forwarded.
Testing,
}
/// Shared server state.
pub struct ServerState {
pub config: ServerConfig,
@@ -97,6 +118,12 @@ pub struct ServerState {
pub mtu_config: MtuConfig,
pub started_at: std::time::Instant,
pub client_registry: RwLock<ClientRegistry>,
/// The forwarding engine for decrypted IP packets.
pub forwarding_engine: Mutex<ForwardingEngine>,
/// Routing table: assigned VPN IP → channel sender for return packets.
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
pub tun_shutdown: mpsc::Sender<()>,
}
/// The VPN server.
@@ -132,6 +159,51 @@ impl VpnServer {
}
let link_mtu = config.mtu.unwrap_or(1420);
let mode = config.forwarding_mode.as_deref().unwrap_or("testing");
let gateway_ip = ip_pool.gateway_addr();
// Create forwarding engine based on mode
enum ForwardingSetup {
Tun {
writer: tokio::io::WriteHalf<tun::AsyncDevice>,
reader: tokio::io::ReadHalf<tun::AsyncDevice>,
shutdown_rx: mpsc::Receiver<()>,
},
Socket {
packet_tx: mpsc::Sender<Vec<u8>>,
packet_rx: mpsc::Receiver<Vec<u8>>,
shutdown_rx: mpsc::Receiver<()>,
},
Testing,
}
let (setup, fwd_shutdown_tx) = match mode {
"tun" => {
let tun_config = TunConfig {
name: "svpn0".to_string(),
address: gateway_ip,
netmask: Ipv4Addr::new(255, 255, 255, 0),
mtu: link_mtu,
};
let tun_device = tunnel::create_tun(&tun_config)?;
tunnel::add_route(&config.subnet, &tun_config.name).await?;
let (reader, writer) = tokio::io::split(tun_device);
let (tx, rx) = mpsc::channel::<()>(1);
(ForwardingSetup::Tun { writer, reader, shutdown_rx: rx }, tx)
}
"socket" => {
info!("Starting userspace NAT forwarding (no root required)");
let (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
let (tx, rx) = mpsc::channel::<()>(1);
(ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx)
}
_ => {
info!("Forwarding disabled (testing/monitoring mode)");
let (tx, _rx) = mpsc::channel::<()>(1);
(ForwardingSetup::Testing, tx)
}
};
// Compute effective MTU from overhead
let overhead = TunnelOverhead::default_overhead();
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu));
@@ -151,8 +223,38 @@ impl VpnServer {
mtu_config,
started_at: std::time::Instant::now(),
client_registry: RwLock::new(registry),
forwarding_engine: Mutex::new(ForwardingEngine::Testing),
tun_routes: RwLock::new(HashMap::new()),
tun_shutdown: fwd_shutdown_tx,
});
// Spawn the forwarding background task and set the engine
match setup {
ForwardingSetup::Tun { writer, reader, shutdown_rx } => {
*state.forwarding_engine.lock().await = ForwardingEngine::Tun(writer);
let tun_state = state.clone();
tokio::spawn(async move {
if let Err(e) = run_tun_reader(tun_state, reader, shutdown_rx).await {
error!("TUN reader error: {}", e);
}
});
}
ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx);
let nat_engine = crate::userspace_nat::NatEngine::new(
gateway_ip,
link_mtu as usize,
state.clone(),
);
tokio::spawn(async move {
if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await {
error!("NAT engine error: {}", e);
}
});
}
ForwardingSetup::Testing => {}
}
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
self.state = Some(state.clone());
self.shutdown_tx = Some(shutdown_tx);
@@ -213,6 +315,34 @@ impl VpnServer {
}
pub async fn stop(&mut self) -> Result<()> {
if let Some(ref state) = self.state {
let mode = state.config.forwarding_mode.as_deref().unwrap_or("testing");
match mode {
"tun" => {
let _ = state.tun_shutdown.send(()).await;
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
if let Err(e) = tunnel::remove_route(&state.config.subnet, "svpn0").await {
warn!("Failed to remove TUN route: {}", e);
}
}
"socket" => {
let _ = state.tun_shutdown.send(()).await;
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
}
_ => {}
}
// Clean up NAT rules
if state.config.enable_nat.unwrap_or(false) {
if let Ok(iface) = crate::network::get_default_interface() {
if let Err(e) = crate::network::remove_nat(&state.config.subnet, &iface).await {
warn!("Failed to remove NAT rules: {}", e);
}
}
}
}
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(()).await;
}
@@ -562,8 +692,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<ServerState>,
listen_addr: String,
@@ -576,17 +706,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 +826,7 @@ async fn run_quic_listener(
state,
Box::new(sink),
Box::new(stream),
Some(remote),
).await {
warn!("QUIC client error: {}", e);
}
@@ -694,12 +859,63 @@ async fn run_quic_listener(
Ok(())
}
/// TUN reader task: reads IP packets from the TUN device and dispatches them
/// to the correct client via the routing table.
async fn run_tun_reader(
state: Arc<ServerState>,
mut tun_reader: tokio::io::ReadHalf<tun::AsyncDevice>,
mut shutdown_rx: mpsc::Receiver<()>,
) -> Result<()> {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 65536];
loop {
tokio::select! {
result = tun_reader.read(&mut buf) => {
let n = match result {
Ok(0) => {
info!("TUN reader: device closed");
break;
}
Ok(n) => n,
Err(e) => {
error!("TUN reader error: {}", e);
break;
}
};
// Extract destination IP from the raw IP packet
let dst_ip = match tunnel::extract_dst_ip(&buf[..n]) {
Some(std::net::IpAddr::V4(v4)) => v4,
_ => continue, // IPv6 or malformed — skip
};
// Look up client by destination IP
let routes = state.tun_routes.read().await;
if let Some(sender) = routes.get(&dst_ip) {
if sender.try_send(buf[..n].to_vec()).is_err() {
// Channel full or closed — drop packet (correct for IP best-effort)
}
}
}
_ = shutdown_rx.recv() => {
info!("TUN reader shutting down");
break;
}
}
}
Ok(())
}
/// Transport-agnostic client handler. Performs the Noise IK handshake, authenticates
/// the client against the registry, and runs the main packet forwarding loop.
async fn handle_client_connection(
state: Arc<ServerState>,
mut sink: Box<dyn TransportSink>,
mut stream: Box<dyn TransportStream>,
remote_addr: Option<std::net::SocketAddr>,
) -> Result<()> {
let server_private_key = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
@@ -779,12 +995,38 @@ 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();
<FrameCodec as tokio_util::codec::Encoder<Frame>>::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();
// Allocate IP
let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?;
// Create return-packet channel for forwarding engine -> client
let (tun_return_tx, mut tun_return_rx) = mpsc::channel::<Vec<u8>>(256);
let fwd_mode = state.config.forwarding_mode.as_deref().unwrap_or("testing");
let forwarding_active = fwd_mode == "tun" || fwd_mode == "socket";
if forwarding_active {
state.tun_routes.write().await.insert(assigned_ip, tun_return_tx);
}
// Determine rate limits: per-client security overrides server defaults
let (rate_limit, burst) = if let Some(ref sec) = client_security {
if let Some(ref rl) = sec.rate_limit {
@@ -811,6 +1053,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 +1088,9 @@ async fn handle_client_connection(
<FrameCodec as tokio_util::codec::Encoder<Frame>>::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();
@@ -909,6 +1154,24 @@ async fn handle_client_connection(
if let Some(info) = clients.get_mut(&client_id) {
info.bytes_received += len as u64;
}
drop(clients);
// Forward decrypted packet via the active engine
{
let mut engine = state.forwarding_engine.lock().await;
match &mut *engine {
ForwardingEngine::Tun(writer) => {
use tokio::io::AsyncWriteExt;
if let Err(e) = writer.write_all(&buf[..len]).await {
warn!("TUN write error for client {}: {}", client_id, e);
}
}
ForwardingEngine::Socket(sender) => {
let _ = sender.try_send(buf[..len].to_vec());
}
ForwardingEngine::Testing => {}
}
}
}
Err(e) => {
warn!("Decrypt error from {}: {}", client_id, e);
@@ -965,6 +1228,37 @@ async fn handle_client_connection(
}
}
}
// Return packets from TUN device destined for this client
Some(packet) = tun_return_rx.recv() => {
let pkt_len = packet.len();
match noise_transport.write_message(&packet, &mut buf) {
Ok(len) => {
let frame = Frame {
packet_type: PacketType::IpPacket,
payload: buf[..len].to_vec(),
};
let mut frame_bytes = BytesMut::new();
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(
&mut FrameCodec, frame, &mut frame_bytes
)?;
sink.send_reliable(frame_bytes.to_vec()).await?;
// Update stats
let mut stats = state.stats.write().await;
stats.bytes_sent += pkt_len as u64;
stats.packets_sent += 1;
drop(stats);
let mut clients = state.clients.write().await;
if let Some(info) = clients.get_mut(&client_id) {
info.bytes_sent += pkt_len as u64;
}
}
Err(e) => {
warn!("Noise encrypt error for return packet to {}: {}", client_id, e);
break;
}
}
}
_ = tokio::time::sleep_until(last_activity + DEAD_PEER_TIMEOUT) => {
warn!("Client {} dead-peer timeout ({}s inactivity)", client_id, DEAD_PEER_TIMEOUT.as_secs());
break;
@@ -973,6 +1267,9 @@ async fn handle_client_connection(
}
// Cleanup
if forwarding_active {
state.tun_routes.write().await.remove(&assigned_ip);
}
state.clients.write().await.remove(&client_id);
state.ip_pool.lock().await.release(&assigned_ip);
state.rate_limiters.lock().await.remove(&client_id);

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use std::net::Ipv4Addr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use tracing::info;
/// Configuration for creating a TUN device.
@@ -80,6 +80,26 @@ pub fn check_tun_mtu(packet: &[u8], mtu_config: &crate::mtu::MtuConfig) -> TunMt
}
}
/// Extract destination IP from a raw IP packet header.
pub fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
if packet.is_empty() {
return None;
}
let version = packet[0] >> 4;
match version {
4 if packet.len() >= 20 => {
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
Some(IpAddr::V4(dst))
}
6 if packet.len() >= 40 => {
let mut octets = [0u8; 16];
octets.copy_from_slice(&packet[24..40]);
Some(IpAddr::V6(Ipv6Addr::from(octets)))
}
_ => None,
}
}
/// Remove a route.
pub async fn remove_route(subnet: &str, device_name: &str) -> Result<()> {
let output = tokio::process::Command::new("ip")

640
rust/src/userspace_nat.rs Normal file
View File

@@ -0,0 +1,640 @@
use std::collections::{HashMap, VecDeque};
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use smoltcp::iface::{Config, Interface, SocketHandle, SocketSet};
use smoltcp::phy::{self, Device, DeviceCapabilities, Medium};
use smoltcp::socket::{tcp, udp};
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::server::ServerState;
use crate::tunnel;
// ============================================================================
// Virtual IP device for smoltcp
// ============================================================================
pub struct VirtualIpDevice {
rx_queue: VecDeque<Vec<u8>>,
tx_queue: VecDeque<Vec<u8>>,
mtu: usize,
}
impl VirtualIpDevice {
pub fn new(mtu: usize) -> Self {
Self {
rx_queue: VecDeque::new(),
tx_queue: VecDeque::new(),
mtu,
}
}
pub fn inject_packet(&mut self, packet: Vec<u8>) {
self.rx_queue.push_back(packet);
}
pub fn drain_tx(&mut self) -> impl Iterator<Item = Vec<u8>> + '_ {
self.tx_queue.drain(..)
}
}
pub struct VirtualRxToken {
buffer: Vec<u8>,
}
impl phy::RxToken for VirtualRxToken {
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
f(&self.buffer)
}
}
pub struct VirtualTxToken<'a> {
queue: &'a mut VecDeque<Vec<u8>>,
}
impl<'a> phy::TxToken for VirtualTxToken<'a> {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buffer = vec![0u8; len];
let result = f(&mut buffer);
self.queue.push_back(buffer);
result
}
}
impl Device for VirtualIpDevice {
type RxToken<'a> = VirtualRxToken;
type TxToken<'a> = VirtualTxToken<'a>;
fn receive(
&mut self,
_timestamp: smoltcp::time::Instant,
) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
self.rx_queue.pop_front().map(|buffer| {
let rx = VirtualRxToken { buffer };
let tx = VirtualTxToken {
queue: &mut self.tx_queue,
};
(rx, tx)
})
}
fn transmit(&mut self, _timestamp: smoltcp::time::Instant) -> Option<Self::TxToken<'_>> {
Some(VirtualTxToken {
queue: &mut self.tx_queue,
})
}
fn capabilities(&self) -> DeviceCapabilities {
let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ip;
caps.max_transmission_unit = self.mtu;
caps.max_burst_size = Some(1);
caps
}
}
// ============================================================================
// Session tracking
// ============================================================================
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
struct SessionKey {
src_ip: Ipv4Addr,
src_port: u16,
dst_ip: Ipv4Addr,
dst_port: u16,
protocol: u8,
}
struct TcpSession {
smoltcp_handle: SocketHandle,
bridge_data_tx: mpsc::Sender<Vec<u8>>,
#[allow(dead_code)]
client_ip: Ipv4Addr,
}
struct UdpSession {
smoltcp_handle: SocketHandle,
bridge_data_tx: mpsc::Sender<Vec<u8>>,
#[allow(dead_code)]
client_ip: Ipv4Addr,
last_activity: tokio::time::Instant,
}
enum BridgeMessage {
TcpData { key: SessionKey, data: Vec<u8> },
TcpClosed { key: SessionKey },
UdpData { key: SessionKey, data: Vec<u8> },
}
// ============================================================================
// IP packet parsing helpers
// ============================================================================
fn parse_ipv4_header(packet: &[u8]) -> Option<(u8, Ipv4Addr, Ipv4Addr, u8)> {
if packet.len() < 20 {
return None;
}
let version = packet[0] >> 4;
if version != 4 {
return None;
}
let ihl = (packet[0] & 0x0F) as usize * 4;
let protocol = packet[9];
let src = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
Some((ihl as u8, src, dst, protocol))
}
fn parse_tcp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16, u8)> {
if packet.len() < ihl + 14 {
return None;
}
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
let flags = packet[ihl + 13];
Some((src_port, dst_port, flags))
}
fn parse_udp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16)> {
if packet.len() < ihl + 4 {
return None;
}
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
Some((src_port, dst_port))
}
// ============================================================================
// NAT Engine
// ============================================================================
pub struct NatEngine {
device: VirtualIpDevice,
iface: Interface,
sockets: SocketSet<'static>,
tcp_sessions: HashMap<SessionKey, TcpSession>,
udp_sessions: HashMap<SessionKey, UdpSession>,
state: Arc<ServerState>,
bridge_rx: mpsc::Receiver<BridgeMessage>,
bridge_tx: mpsc::Sender<BridgeMessage>,
start_time: std::time::Instant,
}
impl NatEngine {
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>) -> Self {
let mut device = VirtualIpDevice::new(mtu);
let config = Config::new(HardwareAddress::Ip);
let now = smoltcp::time::Instant::from_millis(0);
let mut iface = Interface::new(config, &mut device, now);
// Accept packets to ANY destination IP (essential for NAT)
iface.set_any_ip(true);
// Assign the gateway IP as the interface address
iface.update_ip_addrs(|addrs| {
addrs
.push(IpCidr::new(IpAddress::Ipv4(gateway_ip.into()), 24))
.unwrap();
});
// Add a default route so smoltcp knows where to send packets
iface.routes_mut().add_default_ipv4_route(gateway_ip.into()).unwrap();
let sockets = SocketSet::new(Vec::with_capacity(256));
let (bridge_tx, bridge_rx) = mpsc::channel(4096);
Self {
device,
iface,
sockets,
tcp_sessions: HashMap::new(),
udp_sessions: HashMap::new(),
state,
bridge_rx,
bridge_tx,
start_time: std::time::Instant::now(),
}
}
fn smoltcp_now(&self) -> smoltcp::time::Instant {
smoltcp::time::Instant::from_millis(self.start_time.elapsed().as_millis() as i64)
}
/// Inject a raw IP packet from a VPN client and handle new session creation.
fn inject_packet(&mut self, packet: Vec<u8>) {
let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else {
return;
};
let ihl = ihl as usize;
match protocol {
6 => {
// TCP
let Some((src_port, dst_port, flags)) = parse_tcp_ports(&packet, ihl) else {
return;
};
let key = SessionKey {
src_ip,
src_port,
dst_ip,
dst_port,
protocol: 6,
};
// SYN without ACK = new connection
let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0;
if is_syn && !self.tcp_sessions.contains_key(&key) {
self.create_tcp_session(&key);
}
}
17 => {
// UDP
let Some((src_port, dst_port)) = parse_udp_ports(&packet, ihl) else {
return;
};
let key = SessionKey {
src_ip,
src_port,
dst_ip,
dst_port,
protocol: 17,
};
if !self.udp_sessions.contains_key(&key) {
self.create_udp_session(&key);
}
// Update last_activity for existing sessions
if let Some(session) = self.udp_sessions.get_mut(&key) {
session.last_activity = tokio::time::Instant::now();
}
}
_ => {
// ICMP and other protocols — not forwarded in socket mode
return;
}
}
self.device.inject_packet(packet);
}
fn create_tcp_session(&mut self, key: &SessionKey) {
// Create smoltcp TCP socket
let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
let tcp_tx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
let mut socket = tcp::Socket::new(tcp_rx_buf, tcp_tx_buf);
// Listen on the destination address so smoltcp accepts the SYN
let endpoint = IpEndpoint::new(
IpAddress::Ipv4(key.dst_ip.into()),
key.dst_port,
);
if socket.listen(endpoint).is_err() {
warn!("NAT: failed to listen on {:?}", endpoint);
return;
}
let handle = self.sockets.add(socket);
// Channel for sending data from NAT engine to bridge task
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
let session = TcpSession {
smoltcp_handle: handle,
bridge_data_tx: data_tx,
client_ip: key.src_ip,
};
self.tcp_sessions.insert(key.clone(), session);
// Spawn bridge task that connects to the real destination
let bridge_tx = self.bridge_tx.clone();
let key_clone = key.clone();
tokio::spawn(async move {
tcp_bridge_task(key_clone, data_rx, bridge_tx).await;
});
debug!(
"NAT: new TCP session {}:{} -> {}:{}",
key.src_ip, key.src_port, key.dst_ip, key.dst_port
);
}
fn create_udp_session(&mut self, key: &SessionKey) {
// Create smoltcp UDP socket
let udp_rx_buf = udp::PacketBuffer::new(
vec![udp::PacketMetadata::EMPTY; 32],
vec![0u8; 65535],
);
let udp_tx_buf = udp::PacketBuffer::new(
vec![udp::PacketMetadata::EMPTY; 32],
vec![0u8; 65535],
);
let mut socket = udp::Socket::new(udp_rx_buf, udp_tx_buf);
let endpoint = IpEndpoint::new(
IpAddress::Ipv4(key.dst_ip.into()),
key.dst_port,
);
if socket.bind(endpoint).is_err() {
warn!("NAT: failed to bind UDP on {:?}", endpoint);
return;
}
let handle = self.sockets.add(socket);
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
let session = UdpSession {
smoltcp_handle: handle,
bridge_data_tx: data_tx,
client_ip: key.src_ip,
last_activity: tokio::time::Instant::now(),
};
self.udp_sessions.insert(key.clone(), session);
let bridge_tx = self.bridge_tx.clone();
let key_clone = key.clone();
tokio::spawn(async move {
udp_bridge_task(key_clone, data_rx, bridge_tx).await;
});
debug!(
"NAT: new UDP session {}:{} -> {}:{}",
key.src_ip, key.src_port, key.dst_ip, key.dst_port
);
}
/// Poll smoltcp, bridge data between smoltcp sockets and bridge tasks,
/// and dispatch outgoing packets to VPN clients.
async fn process(&mut self) {
let now = self.smoltcp_now();
self.iface
.poll(now, &mut self.device, &mut self.sockets);
// Bridge: read data from smoltcp TCP sockets → send to bridge tasks
let mut closed_tcp: Vec<SessionKey> = Vec::new();
for (key, session) in &self.tcp_sessions {
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
if socket.can_recv() {
let _ = socket.recv(|data| {
let _ = session.bridge_data_tx.try_send(data.to_vec());
(data.len(), ())
});
}
// Detect closed connections
if !socket.is_open() && !socket.is_listening() {
closed_tcp.push(key.clone());
}
}
// Clean up closed TCP sessions
for key in closed_tcp {
if let Some(session) = self.tcp_sessions.remove(&key) {
self.sockets.remove(session.smoltcp_handle);
debug!("NAT: TCP session closed {}:{} -> {}:{}", key.src_ip, key.src_port, key.dst_ip, key.dst_port);
}
}
// Bridge: read data from smoltcp UDP sockets → send to bridge tasks
for (_key, session) in &self.udp_sessions {
let socket = self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
while let Ok((data, _meta)) = socket.recv() {
let _ = session.bridge_data_tx.try_send(data.to_vec());
}
}
// Dispatch outgoing packets from smoltcp to VPN clients
let routes = self.state.tun_routes.read().await;
for packet in self.device.drain_tx() {
if let Some(std::net::IpAddr::V4(dst_ip)) = tunnel::extract_dst_ip(&packet) {
if let Some(sender) = routes.get(&dst_ip) {
let _ = sender.try_send(packet);
}
}
}
}
fn handle_bridge_message(&mut self, msg: BridgeMessage) {
match msg {
BridgeMessage::TcpData { key, data } => {
if let Some(session) = self.tcp_sessions.get(&key) {
let socket =
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
if socket.can_send() {
let _ = socket.send_slice(&data);
}
}
}
BridgeMessage::TcpClosed { key } => {
if let Some(session) = self.tcp_sessions.remove(&key) {
let socket =
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
socket.close();
// Don't remove from SocketSet yet — let smoltcp send FIN
// It will be cleaned up in process() when is_open() returns false
self.tcp_sessions.insert(key, session);
}
}
BridgeMessage::UdpData { key, data } => {
if let Some(session) = self.udp_sessions.get_mut(&key) {
session.last_activity = tokio::time::Instant::now();
let socket =
self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
let dst_endpoint = IpEndpoint::new(
IpAddress::Ipv4(key.src_ip.into()),
key.src_port,
);
// Send response: from the "server" (dst) back to the "client" (src)
let _ = socket.send_slice(&data, dst_endpoint);
}
}
}
}
fn cleanup_idle_udp_sessions(&mut self) {
let timeout = Duration::from_secs(60);
let now = tokio::time::Instant::now();
let expired: Vec<SessionKey> = self
.udp_sessions
.iter()
.filter(|(_, s)| now.duration_since(s.last_activity) > timeout)
.map(|(k, _)| k.clone())
.collect();
for key in expired {
if let Some(session) = self.udp_sessions.remove(&key) {
self.sockets.remove(session.smoltcp_handle);
debug!(
"NAT: UDP session timed out {}:{} -> {}:{}",
key.src_ip, key.src_port, key.dst_ip, key.dst_port
);
}
}
}
/// Main async event loop for the NAT engine.
pub async fn run(
mut self,
mut packet_rx: mpsc::Receiver<Vec<u8>>,
mut shutdown_rx: mpsc::Receiver<()>,
) -> Result<()> {
info!("Userspace NAT engine started");
let mut timer = tokio::time::interval(Duration::from_millis(50));
let mut cleanup_timer = tokio::time::interval(Duration::from_secs(10));
loop {
tokio::select! {
Some(packet) = packet_rx.recv() => {
self.inject_packet(packet);
self.process().await;
}
Some(msg) = self.bridge_rx.recv() => {
self.handle_bridge_message(msg);
self.process().await;
}
_ = timer.tick() => {
// Periodic poll for smoltcp maintenance (TCP retransmit, etc.)
self.process().await;
}
_ = cleanup_timer.tick() => {
self.cleanup_idle_udp_sessions();
}
_ = shutdown_rx.recv() => {
info!("Userspace NAT engine shutting down");
break;
}
}
}
Ok(())
}
}
// ============================================================================
// Bridge tasks
// ============================================================================
async fn tcp_bridge_task(
key: SessionKey,
mut data_rx: mpsc::Receiver<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>,
) {
let addr = SocketAddr::new(key.dst_ip.into(), key.dst_port);
// Connect to real destination with timeout
let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr)).await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("NAT TCP connect to {} failed: {}", addr, e);
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
return;
}
Err(_) => {
debug!("NAT TCP connect to {} timed out", addr);
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
return;
}
};
let (mut reader, mut writer) = stream.into_split();
// Read from real socket → send to NAT engine
let bridge_tx2 = bridge_tx.clone();
let key2 = key.clone();
let read_task = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if bridge_tx2
.send(BridgeMessage::TcpData {
key: key2.clone(),
data: buf[..n].to_vec(),
})
.await
.is_err()
{
break;
}
}
Err(_) => break,
}
}
let _ = bridge_tx2
.send(BridgeMessage::TcpClosed { key: key2 })
.await;
});
// Receive from NAT engine → write to real socket
while let Some(data) = data_rx.recv().await {
if writer.write_all(&data).await.is_err() {
break;
}
}
read_task.abort();
}
async fn udp_bridge_task(
key: SessionKey,
mut data_rx: mpsc::Receiver<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>,
) {
let socket = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
warn!("NAT UDP bind failed: {}", e);
return;
}
};
let dest = SocketAddr::new(key.dst_ip.into(), key.dst_port);
let socket = Arc::new(socket);
let socket2 = socket.clone();
let bridge_tx2 = bridge_tx.clone();
let key2 = key.clone();
// Read responses from real socket
let read_task = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match socket2.recv_from(&mut buf).await {
Ok((n, _src)) => {
if bridge_tx2
.send(BridgeMessage::UdpData {
key: key2.clone(),
data: buf[..n].to_vec(),
})
.await
.is_err()
{
break;
}
}
Err(_) => break,
}
}
});
// Forward data from NAT engine to real socket
while let Some(data) = data_rx.recv().await {
let _ = socket.send_to(&data, dest).await;
}
read_task.abort();
}

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
@@ -18,6 +18,7 @@ use tokio::sync::{mpsc, oneshot, RwLock};
use tracing::{debug, error, info, warn};
use crate::network;
use crate::tunnel::extract_dst_ip;
use crate::tunnel::{self, TunConfig};
// ============================================================================
@@ -228,26 +229,6 @@ impl AllowedIp {
}
}
/// Extract destination IP from an IP packet header.
fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
if packet.is_empty() {
return None;
}
let version = packet[0] >> 4;
match version {
4 if packet.len() >= 20 => {
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
Some(IpAddr::V4(dst))
}
6 if packet.len() >= 40 => {
let mut octets = [0u8; 16];
octets.copy_from_slice(&packet[24..40]);
Some(IpAddr::V6(Ipv6Addr::from(octets)))
}
_ => None,
}
}
// ============================================================================
// Dynamic peer management commands
// ============================================================================
@@ -1096,6 +1077,7 @@ fn chrono_now() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv6Addr;
#[test]
fn test_generate_wg_keypair() {

View File

@@ -211,8 +211,8 @@ tap.test('throttled connection: handshake succeeds through throttle', async () =
});
tap.test('sustained keepalive under throttle', async () => {
// Wait for at least 2 keepalive cycles (3s interval)
await delay(8000);
// Wait for at least 1 keepalive cycle (3s interval)
await delay(4000);
const client = allClients[0];
const stats = await client.getStatistics();
@@ -262,14 +262,14 @@ tap.test('rate limiting combined with network throttle', async () => {
await server.removeClientRateLimit(targetId);
});
tap.test('burst waves: 3 waves of 3 clients', async () => {
tap.test('burst waves: 2 waves of 2 clients', async () => {
const initialCount = (await server.listClients()).length;
for (let wave = 0; wave < 3; wave++) {
for (let wave = 0; wave < 2; wave++) {
const waveClients: VpnClient[] = [];
// Connect 3 clients
for (let i = 0; i < 3; i++) {
// Connect 2 clients
for (let i = 0; i < 2; i++) {
const c = await createConnectedClient(proxyPort);
waveClients.push(c);
}
@@ -277,7 +277,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
// Verify all connected
await waitFor(async () => {
const all = await server.listClients();
return all.length === initialCount + 3;
return all.length === initialCount + 2;
});
// Disconnect all wave clients
@@ -296,7 +296,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
// Verify total connections accumulated
const stats = await server.getStatistics();
expect(stats.totalConnections).toBeGreaterThanOrEqual(9 + initialCount);
expect(stats.totalConnections).toBeGreaterThanOrEqual(4 + initialCount);
// Original clients still connected
const remaining = await server.listClients();
@@ -315,7 +315,7 @@ tap.test('aggressive throttle: 10 KB/s', async () => {
expect(status.state).toEqual('connected');
// Wait for keepalive exchange (might take longer due to throttle)
await delay(10000);
await delay(4000);
const stats = await client.getStatistics();
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
@@ -332,7 +332,7 @@ tap.test('post-load health: direct connection still works', async () => {
const status = await directClient.getStatus();
expect(status.state).toEqual('connected');
await delay(5000);
await delay(3500);
const stats = await directClient.getStatistics();
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartvpn',
version: '1.8.0',
version: '1.10.1',
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
}

View File

@@ -40,6 +40,9 @@ export interface IVpnClientConfig {
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
serverCertHash?: string;
/** Forwarding mode: 'tun' (TUN device, requires root) or 'testing' (no TUN).
* Default: 'testing'. */
forwardingMode?: 'tun' | 'testing';
/** WireGuard: client private key (base64, X25519) */
wgPrivateKey?: string;
/** WireGuard: client TUN address (e.g. 10.8.0.2) */
@@ -86,6 +89,9 @@ export interface IVpnServerConfig {
keepaliveIntervalSecs?: number;
/** Enable NAT/masquerade for client traffic */
enableNat?: boolean;
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
* or 'testing' (monitoring only). Default: 'testing'. */
forwardingMode?: 'tun' | 'socket' | 'testing';
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
defaultRateLimitBytesPerSec?: number;
/** Default burst size for new clients (bytes). Omit for unlimited. */
@@ -102,6 +108,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 +169,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 {