2 Commits

10 changed files with 593 additions and 405 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvpn",
"version": "1.8.0",
"version": "1.9.0",
"private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module",

View File

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

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

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

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

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

@@ -49,6 +49,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 +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<String>,
}
/// 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<ServerState>,
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<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,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();
<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();
@@ -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(
<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();

View File

@@ -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'
}

View File

@@ -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 {