diff --git a/changelog.md b/changelog.md index c69bdba..38cebd6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-29 - 1.8.0 - feat(auth,client-registry) +add Noise IK client authentication with managed client registry and per-client ACL controls + +- switch the native tunnel handshake from Noise NK to Noise IK and require client keypairs in client configuration +- add server-side client registry management APIs for creating, updating, disabling, rotating, listing, and exporting client configs +- enforce client authorization from the registry during handshake and expose authenticated client metadata in server client info +- introduce per-client security policies with source/destination ACLs and per-client rate limit settings +- add Rust ACL matching support for exact IPs, CIDR ranges, wildcards, and IP ranges with test coverage + ## 2026-03-29 - 1.7.0 - feat(rust-tests) add end-to-end WireGuard UDP integration tests and align TypeScript build configuration diff --git a/readme.md b/readme.md index 6ba0f24..3e210cb 100644 --- a/readme.md +++ b/readme.md @@ -1,893 +1,366 @@ # @push.rocks/smartvpn -A high-performance VPN with a **TypeScript control plane** and a **Rust data plane daemon**. Manage VPN connections with clean, fully-typed APIs while all networking heavy lifting β€” encryption, tunneling, QoS, rate limiting β€” runs at native speed in Rust. +A high-performance VPN solution with a **TypeScript control plane** and a **Rust data plane daemon**. Enterprise-ready client authentication, triple transport support (WebSocket + QUIC + WireGuard), and a typed hub API for managing clients from code. -πŸ”’ **Noise NK** handshake + **XChaCha20-Poly1305** encryption +πŸ” **Noise IK** mutual authentication β€” per-client X25519 keypairs, server-side registry πŸš€ **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol) -πŸ“Š **Adaptive QoS**: packet classification, priority queues, per-client rate limiting -πŸ”„ **Auto-transport**: tries QUIC first, falls back to WebSocket seamlessly -πŸ“‘ **Real-time telemetry**: RTT, jitter, loss, link health β€” all exposed via typed APIs -πŸ›‘οΈ **WireGuard mode**: full userspace WireGuard via `boringtun` β€” generate `.conf` files, manage peers live +πŸ›‘οΈ **ACL engine** β€” deny-overrides-allow IP filtering, aligned with SmartProxy conventions +πŸ“Š **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 ## Issue Reporting and Security For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. -## Install +## Install πŸ“¦ ```bash pnpm install @push.rocks/smartvpn +# or +npm install @push.rocks/smartvpn ``` -## πŸ—οΈ Architecture +The package ships with pre-compiled Rust binaries for **linux/amd64** and **linux/arm64**. No Rust toolchain required at runtime. + +## Architecture πŸ—οΈ ``` -TypeScript (control plane) Rust (data plane) -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ VpnClient / VpnServer β”‚ β”‚ smartvpn_daemon β”‚ -β”‚ └─ VpnBridge │──stdio/──▢ β”‚ β”œβ”€ management (JSON IPC) β”‚ -β”‚ └─ RustBridge β”‚ socket β”‚ β”œβ”€ transport_trait (abstraction) β”‚ -β”‚ (smartrust) β”‚ β”‚ β”‚ β”œβ”€ transport (WebSocket/TLS) β”‚ -β”‚ β”‚ β”‚ β”‚ └─ quic_transport (QUIC/UDP) β”‚ -β”‚ WgConfigGenerator β”‚ β”‚ β”œβ”€ wireguard (boringtun WG) β”‚ -β”‚ └─ .conf file output β”‚ β”‚ β”œβ”€ crypto (Noise NK + XCha20) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”œβ”€ codec (binary framing) β”‚ - β”‚ β”œβ”€ keepalive (adaptive state FSM) β”‚ - β”‚ β”œβ”€ telemetry (RTT/jitter/loss) β”‚ - β”‚ β”œβ”€ qos (classify + priority Q) β”‚ - β”‚ β”œβ”€ ratelimit (token bucket) β”‚ - β”‚ β”œβ”€ mtu (overhead calc + ICMP) β”‚ - β”‚ β”œβ”€ tunnel (TUN device) β”‚ - β”‚ β”œβ”€ network (NAT/IP pool) β”‚ - β”‚ └─ reconnect (exp. backoff) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” JSON-lines IPC β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ TypeScript Control Plane β”‚ ◄─────────────────────► β”‚ Rust Data Plane Daemon β”‚ +β”‚ β”‚ stdio or Unix sock β”‚ β”‚ +β”‚ VpnServer / VpnClient β”‚ β”‚ Noise IK handshake β”‚ +β”‚ Typed IPC commands β”‚ β”‚ XChaCha20-Poly1305 β”‚ +β”‚ Config validation β”‚ β”‚ WS + QUIC + WireGuard β”‚ +β”‚ Hub: client management β”‚ β”‚ TUN device, IP pool, NAT β”‚ +β”‚ WireGuard .conf generation β”‚ β”‚ Rate limiting, ACLs, QoS β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -**Key design decisions:** +**Split-plane design** β€” TypeScript handles orchestration, config, and DX; Rust handles every hot-path byte with zero-copy async I/O (tokio, mimalloc). -| Decision | Choice | Why | -|----------|--------|-----| -| Transport | WebSocket + QUIC + WireGuard | WS works through Cloudflare; QUIC gives low latency + datagrams; WG for standard protocol interop | -| Auto-transport | QUIC first, WS fallback | Best performance when QUIC is available, graceful degradation when it's not | -| WireGuard | Userspace via `boringtun` | No kernel module needed, runs on any platform, full peer management via IPC | -| Encryption | Noise NK + XChaCha20-Poly1305 | Strong forward secrecy, large nonce space (no counter sync needed) | -| QUIC auth | Certificate hash pinning | WireGuard-style trust model β€” no CA needed, just pin the server cert hash | -| Keepalive | Adaptive app-level pings | Cloudflare drops WS pings; interval adapts to link health (10–60s) | -| QoS | Packet classification + priority queues | DNS/SSH/ICMP always drain first; bulk flows get deprioritized | -| Rate limiting | Per-client token bucket | Byte-granular, dynamically reconfigurable via IPC | -| IPC | JSON lines over stdio / Unix socket | `stdio` for dev, `socket` for production (daemon stays alive) | -| Binary protocol | `[type:1B][length:4B][payload:NB]` | Minimal overhead, easy to parse at wire speed | +## Quick Start πŸš€ -## πŸš€ Quick Start - -### VPN Client - -```typescript -import { VpnClient } from '@push.rocks/smartvpn'; - -const client = new VpnClient({ - transport: { transport: 'stdio' }, -}); - -await client.start(); - -const { assignedIp } = await client.connect({ - serverUrl: 'wss://vpn.example.com/tunnel', - serverPublicKey: 'BASE64_SERVER_PUBLIC_KEY', - dns: ['1.1.1.1', '8.8.8.8'], - mtu: 1420, - keepaliveIntervalSecs: 30, -}); - -console.log(`Connected! Assigned IP: ${assignedIp}`); - -// Connection quality (adaptive keepalive + telemetry) -const quality = await client.getConnectionQuality(); -console.log(quality); -// { -// srttMs: 42.5, jitterMs: 3.2, minRttMs: 38.0, maxRttMs: 67.0, -// lossRatio: 0.0, consecutiveTimeouts: 0, -// linkHealth: 'healthy', currentKeepaliveIntervalSecs: 60 -// } - -// MTU info -const mtu = await client.getMtuInfo(); -console.log(mtu); -// { tunMtu: 1420, effectiveMtu: 1421, linkMtu: 1500, overheadBytes: 79, ... } - -// Traffic stats (includes quality snapshot) -const stats = await client.getStatistics(); - -await client.disconnect(); -client.stop(); -``` - -### VPN Client with QUIC - -```typescript -import { VpnClient } from '@push.rocks/smartvpn'; - -// Explicit QUIC β€” serverUrl is host:port, pinned by cert hash -const quicClient = new VpnClient({ - transport: { transport: 'stdio' }, -}); - -await quicClient.start(); - -const { assignedIp } = await quicClient.connect({ - serverUrl: 'vpn.example.com:443', - serverPublicKey: 'BASE64_SERVER_PUBLIC_KEY', - transport: 'quic', - serverCertHash: 'BASE64_SHA256_CERT_HASH', // printed by server on startup -}); - -// Or use auto-transport: tries QUIC first (3s timeout), falls back to WS -const autoClient = new VpnClient({ - transport: { transport: 'stdio' }, -}); - -await autoClient.start(); - -await autoClient.connect({ - serverUrl: 'wss://vpn.example.com/tunnel', // WS URL β€” host:port extracted for QUIC attempt - serverPublicKey: 'BASE64_SERVER_PUBLIC_KEY', - transport: 'auto', // default β€” QUIC first, then WS -}); -``` - -### VPN Client with WireGuard - -```typescript -import { VpnClient } from '@push.rocks/smartvpn'; - -const wgClient = new VpnClient({ - transport: { transport: 'stdio' }, -}); - -await wgClient.start(); - -const { assignedIp } = await wgClient.connect({ - serverPublicKey: 'BASE64_SERVER_WG_PUBLIC_KEY', - serverUrl: '', // not used for WireGuard - transport: 'wireguard', - wgPrivateKey: 'BASE64_CLIENT_PRIVATE_KEY', - wgAddress: '10.8.0.2', - wgAddressPrefix: 24, - wgEndpoint: 'vpn.example.com:51820', - wgAllowedIps: ['0.0.0.0/0'], // route all traffic - wgPersistentKeepalive: 25, - wgPresharedKey: 'OPTIONAL_PSK', // optional extra layer - dns: ['1.1.1.1'], - mtu: 1420, -}); - -console.log(`WireGuard connected! IP: ${assignedIp}`); -await wgClient.disconnect(); -wgClient.stop(); -``` - -### VPN Server +### 1. Start a VPN Server (Hub) ```typescript import { VpnServer } from '@push.rocks/smartvpn'; -const server = new VpnServer({ - transport: { transport: 'stdio' }, -}); - -// Generate a Noise keypair first -await server.start(); -const keypair = await server.generateKeypair(); - -// Start the VPN listener +const server = new VpnServer({ transport: { transport: 'stdio' } }); await server.start({ listenAddr: '0.0.0.0:443', - privateKey: keypair.privateKey, - publicKey: keypair.publicKey, + privateKey: '', + publicKey: '', subnet: '10.8.0.0/24', - dns: ['1.1.1.1'], - mtu: 1420, + transportMode: 'both', // WebSocket + QUIC simultaneously enableNat: true, - // Transport mode: 'websocket', 'quic', 'both', or 'wireguard' - transportMode: 'both', - // Optional: separate QUIC listen address - quicListenAddr: '0.0.0.0:4433', - // Optional: QUIC idle timeout - quicIdleTimeoutSecs: 30, - // Optional: default rate limit for all new clients - defaultRateLimitBytesPerSec: 10_000_000, // 10 MB/s - defaultBurstBytes: 20_000_000, // 20 MB burst + dns: ['1.1.1.1', '8.8.8.8'], }); - -// List connected clients -const clients = await server.listClients(); - -// Per-client rate limiting (live, no reconnect needed) -await server.setClientRateLimit('client-id', 5_000_000, 10_000_000); -await server.removeClientRateLimit('client-id'); // unlimited - -// Per-client telemetry -const telemetry = await server.getClientTelemetry('client-id'); -console.log(telemetry); -// { -// clientId, assignedIp, lastKeepaliveAt, keepalivesReceived, -// packetsDropped, bytesDropped, bytesReceived, bytesSent, -// rateLimitBytesPerSec, burstBytes -// } - -// Kick a client -await server.disconnectClient('client-id'); - -await server.stopServer(); -server.stop(); ``` -### WireGuard Server Mode +### 2. Create a Client (One Call = Everything) ```typescript -import { VpnServer } from '@push.rocks/smartvpn'; - -const wgServer = new VpnServer({ - transport: { transport: 'stdio' }, +const bundle = await server.createClient({ + clientId: 'alice-laptop', + tags: ['engineering'], + security: { + destinationAllowList: ['10.0.0.0/8'], // can only reach internal network + destinationBlockList: ['10.0.0.99'], // except this host + rateLimit: { bytesPerSec: 10_000_000, burstBytes: 20_000_000 }, + }, }); -// Generate a WireGuard X25519 keypair -await wgServer.start(); -const keypair = await wgServer.generateWgKeypair(); -console.log(`Server public key: ${keypair.publicKey}`); - -// Start in WireGuard mode -await wgServer.start({ - listenAddr: '0.0.0.0:51820', - privateKey: keypair.privateKey, - publicKey: keypair.publicKey, - subnet: '10.8.0.0/24', - transportMode: 'wireguard', - wgListenPort: 51820, - wgPeers: [ - { - publicKey: 'CLIENT_PUBLIC_KEY_BASE64', - allowedIps: ['10.8.0.2/32'], - persistentKeepalive: 25, - }, - ], - enableNat: true, - dns: ['1.1.1.1'], - mtu: 1420, -}); - -// Live peer management β€” add/remove peers without restart -await wgServer.addWgPeer({ - publicKey: 'NEW_CLIENT_PUBLIC_KEY', - allowedIps: ['10.8.0.3/32'], - persistentKeepalive: 25, -}); - -// List peers with live stats -const peers = await wgServer.listWgPeers(); -for (const peer of peers) { - console.log(`${peer.publicKey}: ↑${peer.bytesSent} ↓${peer.bytesReceived}`); -} - -// Remove a peer by public key -await wgServer.removeWgPeer('CLIENT_PUBLIC_KEY_BASE64'); - -await wgServer.stopServer(); -wgServer.stop(); +// bundle.smartvpnConfig β†’ typed IVpnClientConfig, ready to use +// bundle.wireguardConfig β†’ standard WireGuard .conf string +// bundle.secrets β†’ { noisePrivateKey, wgPrivateKey } β€” shown ONCE ``` -### Generating WireGuard .conf Files +### 3. Connect a Client -The `WgConfigGenerator` creates standard WireGuard `.conf` files compatible with `wg-quick`, iOS/Android apps, and all standard WireGuard clients: +```typescript +import { VpnClient } from '@push.rocks/smartvpn'; + +const client = new VpnClient({ transport: { transport: 'stdio' } }); +await client.start(); + +const { assignedIp } = await client.connect(bundle.smartvpnConfig); +console.log(`Connected! VPN IP: ${assignedIp}`); +``` + +## Features ✨ + +### πŸ” Enterprise Authentication (Noise IK) + +Every client authenticates with a **Noise IK handshake** (`Noise_IK_25519_ChaChaPoly_BLAKE2s`). The server verifies the client's static public key against its registry β€” unauthorized clients are rejected before any data flows. + +- Per-client X25519 keypair generated server-side +- Client registry with enable/disable, expiry, tags +- Key rotation with `rotateClientKey()` β€” generates new keys, returns fresh config bundle, disconnects old session + +### 🌐 Triple Transport + +| Transport | Protocol | Best For | +|-----------|----------|----------| +| **WebSocket** | TLS over TCP | Firewall-friendly, Cloudflare compatible | +| **QUIC** | UDP (via quinn) | Low latency, datagram support for IP packets | +| **WireGuard** | UDP (via boringtun) | Standard WG clients (iOS, Android, wg-quick) | + +The server can run **all three simultaneously** with `transportMode: 'both'` (WS + QUIC) or `'wireguard'`. Clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS). + +### πŸ›‘οΈ ACL Engine (SmartProxy-Aligned) + +Security policies per client, using the same `ipAllowList` / `ipBlockList` naming convention as `@push.rocks/smartproxy`: + +```typescript +security: { + ipAllowList: ['192.168.1.0/24'], // source IPs allowed to connect + ipBlockList: ['192.168.1.100'], // deny overrides allow + destinationAllowList: ['10.0.0.0/8'], // VPN destinations permitted + destinationBlockList: ['10.0.0.99'], // deny overrides allow + maxConnections: 5, + rateLimit: { bytesPerSec: 1_000_000, burstBytes: 2_000_000 }, +} +``` + +Supports exact IPs, CIDR, wildcards (`192.168.1.*`), and ranges (`1.1.1.1-1.1.1.100`). + +### πŸ“Š Telemetry & QoS + +- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`) +- **Adaptive keepalives**: Interval adjusts based on link health (60s β†’ 30s β†’ 10s) +- **Per-client rate limiting**: Token bucket with configurable bytes/sec and burst +- **Dead-peer detection**: 180s inactivity timeout +- **MTU management**: Automatic overhead calculation (IP+TCP+WS+Noise = 79 bytes) + +### πŸ”„ Hub Client Management + +The server acts as a **hub** β€” one API to manage all clients: + +```typescript +// Create (generates keys, assigns IP, returns config bundle) +const bundle = await server.createClient({ clientId: 'bob-phone' }); + +// Read +const entry = await server.getClient('bob-phone'); +const all = await server.listRegisteredClients(); + +// Update (ACLs, tags, description, rate limits...) +await server.updateClient('bob-phone', { + security: { destinationAllowList: ['0.0.0.0/0'] }, + tags: ['mobile', 'field-ops'], +}); + +// Enable / Disable +await server.disableClient('bob-phone'); // disconnects + blocks reconnection +await server.enableClient('bob-phone'); + +// Key rotation +const newBundle = await server.rotateClientKey('bob-phone'); + +// Export config (without secrets) +const wgConf = await server.exportClientConfig('bob-phone', 'wireguard'); + +// Remove +await server.removeClient('bob-phone'); +``` + +### πŸ“ WireGuard Config Generation + +Generate standard `.conf` files for any WireGuard client: ```typescript import { WgConfigGenerator } from '@push.rocks/smartvpn'; -// Client config (for wg-quick or mobile apps) -const clientConf = WgConfigGenerator.generateClientConfig({ - privateKey: 'CLIENT_PRIVATE_KEY_BASE64', +const conf = WgConfigGenerator.generateClientConfig({ + privateKey: '', address: '10.8.0.2/24', - dns: ['1.1.1.1', '8.8.8.8'], - mtu: 1420, - peer: { - publicKey: 'SERVER_PUBLIC_KEY_BASE64', - endpoint: 'vpn.example.com:51820', - allowedIps: ['0.0.0.0/0', '::/0'], - persistentKeepalive: 25, - presharedKey: 'OPTIONAL_PSK_BASE64', - }, -}); - -// Server config (for wg-quick) -const serverConf = WgConfigGenerator.generateServerConfig({ - privateKey: 'SERVER_PRIVATE_KEY_BASE64', - address: '10.8.0.1/24', - listenPort: 51820, dns: ['1.1.1.1'], - mtu: 1420, - enableNat: true, - natInterface: 'eth0', // auto-detected if omitted - peers: [ - { - publicKey: 'CLIENT_PUBLIC_KEY_BASE64', - allowedIps: ['10.8.0.2/32'], - persistentKeepalive: 25, - }, - ], -}); - -// Write to disk -import * as fs from 'fs'; -fs.writeFileSync('/etc/wireguard/wg0.conf', serverConf); -``` - -
-Example output: client .conf - -```ini -[Interface] -PrivateKey = CLIENT_PRIVATE_KEY_BASE64 -Address = 10.8.0.2/24 -DNS = 1.1.1.1, 8.8.8.8 -MTU = 1420 - -[Peer] -PublicKey = SERVER_PUBLIC_KEY_BASE64 -PresharedKey = OPTIONAL_PSK_BASE64 -Endpoint = vpn.example.com:51820 -AllowedIPs = 0.0.0.0/0, ::/0 -PersistentKeepalive = 25 -``` - -
- -### Production: Socket Transport - -In production, the daemon runs as a system service and you connect over a Unix socket: - -```typescript -const client = new VpnClient({ - transport: { - transport: 'socket', - socketPath: '/var/run/smartvpn.sock', - autoReconnect: true, - reconnectBaseDelayMs: 100, - reconnectMaxDelayMs: 30000, - maxReconnectAttempts: 10, + peer: { + publicKey: '', + endpoint: 'vpn.example.com:51820', + allowedIps: ['0.0.0.0/0'], + persistentKeepalive: 25, }, }); - -await client.start(); // connects to existing daemon (does not spawn) +// β†’ standard WireGuard .conf compatible with wg-quick, iOS, Android ``` -When using socket transport, `client.stop()` closes the socket but **does not kill the daemon** β€” exactly what you want in production. - -## πŸ“‹ API Reference - -### `VpnClient` - -| Method | Returns | Description | -|--------|---------|-------------| -| `start()` | `Promise` | Start the daemon bridge (spawn or connect) | -| `connect(config?)` | `Promise<{ assignedIp }>` | Connect to VPN server (WS, QUIC, or WireGuard) | -| `disconnect()` | `Promise` | Disconnect from VPN | -| `getStatus()` | `Promise` | Current connection state | -| `getStatistics()` | `Promise` | Traffic stats + connection quality | -| `getConnectionQuality()` | `Promise` | RTT, jitter, loss, link health | -| `getMtuInfo()` | `Promise` | MTU info and overhead breakdown | -| `stop()` | `void` | Kill/close the daemon bridge | -| `running` | `boolean` | Whether bridge is active | - -### `VpnServer` - -| Method | Returns | Description | -|--------|---------|-------------| -| `start(config?)` | `Promise` | Start daemon + VPN server | -| `stopServer()` | `Promise` | Stop the VPN server | -| `getStatus()` | `Promise` | Server connection state | -| `getStatistics()` | `Promise` | Server stats (includes client counts) | -| `listClients()` | `Promise` | Connected clients with QoS stats | -| `disconnectClient(id)` | `Promise` | Kick a client | -| `generateKeypair()` | `Promise` | Generate Noise NK keypair | -| `setClientRateLimit(id, rate, burst)` | `Promise` | Set per-client rate limit (bytes/sec) | -| `removeClientRateLimit(id)` | `Promise` | Remove rate limit (unlimited) | -| `getClientTelemetry(id)` | `Promise` | Per-client telemetry + drop stats | -| `generateWgKeypair()` | `Promise` | Generate WireGuard X25519 keypair | -| `addWgPeer(peer)` | `Promise` | Add a WireGuard peer at runtime | -| `removeWgPeer(publicKey)` | `Promise` | Remove a WireGuard peer by key | -| `listWgPeers()` | `Promise` | List WG peers with traffic stats | -| `stop()` | `void` | Kill/close the daemon bridge | - -### `VpnConfig` - -Static utility class for config validation and file I/O: - -```typescript -import { VpnConfig } from '@push.rocks/smartvpn'; - -// Validate (throws on invalid) -VpnConfig.validateClientConfig(config); -VpnConfig.validateServerConfig(config); - -// Load/save JSON configs -const config = await VpnConfig.loadFromFile('/etc/smartvpn/client.json'); -await VpnConfig.saveToFile('/etc/smartvpn/client.json', config); -``` - -Validation covers both smartvpn-native configs and WireGuard configs β€” base64 key format, CIDR ranges, port ranges, and required fields are all checked. - -### `WgConfigGenerator` - -Static generator for standard WireGuard `.conf` files: - -| Method | Returns | Description | -|--------|---------|-------------| -| `generateClientConfig(opts)` | `string` | Generate a `wg-quick` compatible client `.conf` | -| `generateServerConfig(opts)` | `string` | Generate a `wg-quick` compatible server `.conf` with NAT rules | - -Output is compatible with `wg-quick`, WireGuard iOS/Android apps, and any standard WireGuard implementation. - -### `VpnInstaller` - -Generate system service units for the daemon: +### πŸ–₯️ System Service Installation ```typescript import { VpnInstaller } from '@push.rocks/smartvpn'; -const platform = VpnInstaller.detectPlatform(); // 'linux' | 'macos' | 'windows' | 'unknown' - -// Linux (systemd) -const unit = VpnInstaller.generateSystemdUnit({ - binaryPath: '/usr/local/bin/smartvpn_daemon', - socketPath: '/var/run/smartvpn.sock', - mode: 'server', -}); - -// macOS (launchd) -const plist = VpnInstaller.generateLaunchdPlist({ - binaryPath: '/usr/local/bin/smartvpn_daemon', - socketPath: '/var/run/smartvpn.sock', - mode: 'client', -}); - -// Auto-detect platform -const serviceUnit = VpnInstaller.generateServiceUnit({ - binaryPath: '/usr/local/bin/smartvpn_daemon', - socketPath: '/var/run/smartvpn.sock', +const unit = VpnInstaller.generateServiceUnit({ mode: 'server', + configPath: '/etc/smartvpn/server.json', }); +// unit.platform β†’ 'linux' | 'macos' +// unit.content β†’ systemd unit file or launchd plist +// unit.installPath β†’ /etc/systemd/system/smartvpn-server.service ``` -### Events +## API Reference πŸ“– -Both `VpnClient` and `VpnServer` extend `EventEmitter`: +### Classes + +| Class | Description | +|-------|-------------| +| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD. | +| `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, telemetry. | +| `VpnBridge` | Low-level typed IPC bridge (stdio or Unix socket). | +| `VpnConfig` | Static config validation and file I/O. | +| `VpnInstaller` | Generates systemd/launchd service files. | +| `WgConfigGenerator` | Generates standard WireGuard `.conf` files. | + +### Key Interfaces + +| Interface | Purpose | +|-----------|---------| +| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients) | +| `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) | +| `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health | +| `IVpnKeypair` | Base64-encoded public/private key pair | + +### Server IPC Commands + +| Command | Description | +|---------|-------------| +| `start` / `stop` | Start/stop the VPN listener | +| `createClient` | Generate keys, assign IP, return config bundle | +| `removeClient` / `getClient` / `listRegisteredClients` | Client registry CRUD | +| `updateClient` / `enableClient` / `disableClient` | Modify client state | +| `rotateClientKey` | Fresh keypairs + new config bundle | +| `exportClientConfig` | Re-export as SmartVPN config or WireGuard `.conf` | +| `listClients` / `disconnectClient` | Manage live connections | +| `setClientRateLimit` / `removeClientRateLimit` | Runtime rate limit adjustments | +| `getStatus` / `getStatistics` / `getClientTelemetry` | Monitoring | +| `generateKeypair` / `generateWgKeypair` / `generateClientKeypair` | Key generation | +| `addWgPeer` / `removeWgPeer` / `listWgPeers` | WireGuard peer management | + +### Client IPC Commands + +| Command | Description | +|---------|-------------| +| `connect` / `disconnect` | Manage the tunnel | +| `getStatus` / `getStatistics` | Connection state and traffic stats | +| `getConnectionQuality` | RTT, jitter, loss, link health | +| `getMtuInfo` | MTU and overhead details | + +## Transport Modes πŸ”€ + +### Server Configuration ```typescript -client.on('exit', ({ code, signal }) => { /* daemon exited */ }); -client.on('reconnected', () => { /* socket reconnected */ }); -client.on('status', (status) => { /* IVpnStatus update */ }); -client.on('error', (error) => { /* error from daemon */ }); +// WebSocket only +{ transportMode: 'websocket', listenAddr: '0.0.0.0:443' } -server.on('client-connected', (info) => { /* IVpnClientInfo */ }); -server.on('client-disconnected', ({ clientId, reason }) => { /* ... */ }); -server.on('started', () => { /* server listener started */ }); -server.on('stopped', () => { /* server listener stopped */ }); +// QUIC only +{ transportMode: 'quic', listenAddr: '0.0.0.0:443' } + +// Both (WS + QUIC on same or different ports) +{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' } + +// WireGuard +{ transportMode: 'wireguard', wgListenPort: 51820, wgPeers: [...] } ``` -## 🌐 Transport Modes - -smartvpn supports three transport protocols. The smartvpn-native transports (WebSocket + QUIC) share the same encryption, framing, and QoS pipeline. WireGuard mode uses the standard WireGuard protocol for broad interoperability. - -### WebSocket (default for smartvpn-native) - -- Works through Cloudflare, reverse proxies, and HTTP load balancers -- Reliable delivery only (no datagram support) -- URL format: `wss://host/path` or `ws://host:port/path` - -### QUIC - -- Lower latency, built-in multiplexing, 0-RTT connection establishment -- Supports **unreliable datagrams** for IP packets (with automatic fallback to reliable if oversized) -- Certificate hash pinning β€” no CA chain needed, WireGuard-style trust -- URL format: `host:port` -- ALPN protocol: `smartvpn` - -### WireGuard - -- Standard WireGuard protocol via `boringtun` (userspace, no kernel module) -- Compatible with **all WireGuard clients** β€” iOS, Android, macOS, Windows, Linux, routers -- X25519 key exchange, ChaCha20-Poly1305 encryption -- Dynamic peer management at runtime (add/remove without restart) -- Optional preshared keys for post-quantum defense-in-depth -- Generate `.conf` files for standard clients via `WgConfigGenerator` -- Default port: `51820/UDP` - -### Auto-Transport (Recommended for smartvpn-native) - -The default `transport: 'auto'` mode gives you the best of both worlds: - -1. Extract `host:port` from the WebSocket URL -2. Attempt QUIC connection (3-second timeout) -3. If QUIC fails or times out β†’ fall back to WebSocket -4. Completely transparent to the application +### Client Configuration ```typescript -await client.connect({ - serverUrl: 'wss://vpn.example.com/tunnel', - serverPublicKey: '...', - transport: 'auto', // default β€” QUIC first, WS fallback -}); +// Auto (tries QUIC first, falls back to WS) +{ transport: 'auto', serverUrl: 'wss://vpn.example.com' } + +// Explicit QUIC with certificate pinning +{ transport: 'quic', serverUrl: '1.2.3.4:4433', serverCertHash: '' } + +// WireGuard +{ transport: 'wireguard', wgPrivateKey: '...', wgEndpoint: 'vpn.example.com:51820', ... } ``` -### Server Dual-Mode / Multi-Mode +## Cryptography πŸ”‘ -The server can listen on multiple transports simultaneously: +| Layer | Algorithm | Purpose | +|-------|-----------|---------| +| **Handshake** | Noise IK (X25519 + ChaChaPoly + BLAKE2s) | Mutual authentication + key exchange | +| **Transport** | Noise transport state (ChaChaPoly) | All post-handshake data encryption | +| **Additional** | XChaCha20-Poly1305 | Extended nonce space for data-at-rest | +| **WireGuard** | X25519 + ChaCha20-Poly1305 (via boringtun) | Standard WireGuard crypto | -```typescript -// WebSocket + QUIC (dual mode) -await server.start({ - listenAddr: '0.0.0.0:443', // WebSocket listener - quicListenAddr: '0.0.0.0:4433', // QUIC listener (optional, defaults to listenAddr) - transportMode: 'both', // 'websocket' | 'quic' | 'both' | 'wireguard' - quicIdleTimeoutSecs: 30, - // ... other config -}); +## Binary Protocol πŸ“‘ -// WireGuard standalone -await server.start({ - listenAddr: '0.0.0.0:51820', - transportMode: 'wireguard', - wgListenPort: 51820, - wgPeers: [{ publicKey: '...', allowedIps: ['10.8.0.2/32'] }], - // ... other config -}); -``` +All frames use `[type:1B][length:4B][payload:NB]` with a 64KB max payload: -When using `'both'` mode, the server logs the QUIC certificate hash on startup β€” share this with clients for cert pinning. +| Type | Hex | Direction | Description | +|------|-----|-----------|-------------| +| HandshakeInit | `0x01` | Client β†’ Server | Noise IK first message | +| HandshakeResp | `0x02` | Server β†’ Client | Noise IK response | +| IpPacket | `0x10` | Bidirectional | Encrypted tunnel data | +| Keepalive | `0x20` | Client β†’ Server | App-level keepalive (not WS ping) | +| KeepaliveAck | `0x21` | Server β†’ Client | Keepalive response with RTT payload | +| Disconnect | `0x3F` | Bidirectional | Graceful disconnect | -## πŸ“Š QoS System - -The Rust daemon includes a full QoS stack that operates on decrypted IP packets: - -### Adaptive Keepalive - -The keepalive system automatically adjusts its interval based on connection quality: - -| Link Health | Keepalive Interval | Triggered When | -|-------------|-------------------|----------------| -| 🟒 Healthy | 60s | Jitter < 30ms, loss < 2%, no timeouts | -| 🟑 Degraded | 30s | Jitter > 50ms, loss > 5%, or 1+ timeout | -| πŸ”΄ Critical | 10s | Loss > 20% or 2+ consecutive timeouts | - -State transitions include hysteresis (3 consecutive good checks to upgrade, 2 to recover) to prevent flapping. Dead peer detection fires after 3 consecutive timeouts in Critical state. - -### Packet Classification - -IP packets are classified into three priority levels by inspecting headers (no deep packet inspection): - -| Priority | Traffic | -|----------|---------| -| **High** | ICMP, DNS (port 53), SSH (port 22), small packets (< 128 bytes) | -| **Normal** | Everything else | -| **Low** | Bulk flows exceeding 1 MB within a 60s window | - -Priority channels drain with biased `tokio::select!` β€” high-priority packets always go first. - -### Smart Packet Dropping - -Under backpressure, packets are dropped intelligently: - -1. **Low** queue full β†’ drop silently -2. **Normal** queue full β†’ drop -3. **High** queue full β†’ wait 5ms, then drop as last resort - -Drop statistics are tracked per priority level and exposed via telemetry. - -### Per-Client Rate Limiting - -Token bucket algorithm with byte granularity: - -```typescript -// Set: 10 MB/s sustained, 20 MB burst -await server.setClientRateLimit('client-id', 10_000_000, 20_000_000); - -// Check drops via telemetry -const t = await server.getClientTelemetry('client-id'); -console.log(`Dropped: ${t.packetsDropped} packets, ${t.bytesDropped} bytes`); - -// Remove limit -await server.removeClientRateLimit('client-id'); -``` - -Rate limits can be changed live without disconnecting the client. - -### Path MTU - -Tunnel overhead is calculated precisely: - -| Layer | Bytes | -|-------|-------| -| IP header | 20 | -| TCP header (with timestamps) | 32 | -| WebSocket framing | 6 | -| VPN frame header | 5 | -| Noise AEAD tag | 16 | -| **Total overhead** | **79** | - -For a standard 1500-byte Ethernet link, effective TUN MTU = **1421 bytes**. The default TUN MTU of 1420 is conservative and correct. Oversized packets get an ICMP "Fragmentation Needed" (Type 3, Code 4) written back into the TUN, so the source TCP adjusts its MSS automatically. - -## πŸ” Security Model - -### smartvpn-native (WebSocket / QUIC) - -The VPN uses a **Noise NK** handshake pattern: - -1. **NK** = client does **N**ot authenticate, but **K**nows the server's static public key -2. The client generates an ephemeral keypair, performs `e, es` (DH with server's static key) -3. Server responds with `e, ee` (DH with both ephemeral keys) -4. Result: forward-secret transport keys derived from both DH operations - -Post-handshake, all IP packets are encrypted with **XChaCha20-Poly1305**: -- 24-byte random nonces (no counter synchronization needed) -- 16-byte authentication tags -- Wire format: `[nonce:24B][ciphertext:var][tag:16B]` - -### WireGuard Mode - -Uses the standard [Noise IKpsk2](https://www.wireguard.com/protocol/) handshake: -- **X25519** key exchange (Curve25519 Diffie-Hellman) -- **ChaCha20-Poly1305** AEAD encryption -- Optional **preshared keys** for post-quantum defense-in-depth -- Implemented via `boringtun` β€” Cloudflare's userspace WireGuard in Rust - -### QUIC Certificate Pinning - -When using QUIC transport, the server generates a self-signed TLS certificate (or uses a configured PEM). Instead of relying on a CA chain, clients pin the server's certificate by its **SHA-256 hash** (base64-encoded) β€” a WireGuard-inspired trust model: - -```typescript -// Server logs the cert hash on startup: -// "QUIC cert hash: " - -// Client pins it: -await client.connect({ - serverUrl: 'vpn.example.com:443', - transport: 'quic', - serverCertHash: '', - serverPublicKey: '...', -}); -``` - -## πŸ“¦ Binary Protocol - -Inside the tunnel (both WebSocket and QUIC reliable channels), packets use a simple binary framing: - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Type (1B)β”‚ Len (4B) β”‚ Payload (variable) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -| Type | Value | Description | -|------|-------|-------------| -| `HandshakeInit` | `0x01` | Client β†’ Server handshake | -| `HandshakeResp` | `0x02` | Server β†’ Client handshake | -| `IpPacket` | `0x10` | Encrypted IP packet | -| `Keepalive` | `0x20` | App-level ping (8-byte timestamp payload) | -| `KeepaliveAck` | `0x21` | App-level pong (echoes timestamp for RTT) | -| `SessionResume` | `0x30` | Resume a dropped session | -| `SessionResumeOk` | `0x31` | Resume accepted | -| `SessionResumeErr` | `0x32` | Resume rejected | -| `Disconnect` | `0x3F` | Graceful disconnect | - -When QUIC datagrams are available, IP packets can optionally be sent via the unreliable datagram channel for lower latency. Packets that exceed the max datagram size automatically fall back to the reliable stream. - -> **Note:** WireGuard mode uses the standard WireGuard wire protocol, not this binary framing. - -## πŸ› οΈ Rust Daemon CLI - -```bash -# Development: stdio management (JSON lines on stdin/stdout) -smartvpn_daemon --management --mode client -smartvpn_daemon --management --mode server - -# Production: Unix socket management -smartvpn_daemon --management-socket /var/run/smartvpn.sock --mode server - -# Generate a Noise keypair -smartvpn_daemon --generate-keypair -``` - -## πŸ”§ Building from Source +## Development πŸ› οΈ ```bash # Install dependencies pnpm install -# Build TypeScript + cross-compile Rust (amd64 + arm64) +# Build (TypeScript + Rust cross-compile) pnpm build -# Build Rust only (debug) -cd rust && cargo build - -# Run all tests (93 Rust + 77 TypeScript) -cd rust && cargo test +# Run all tests (79 TS + 121 Rust = 200 tests) pnpm test + +# Run Rust tests directly +cd rust && cargo test + +# Run a specific TS test +tstest test/test.flowcontrol.node.ts --verbose ``` -## πŸ“˜ TypeScript Interfaces +### Project Structure -
-Click to expand full type definitions - -```typescript -// Transport options -type TVpnTransportOptions = - | { transport: 'stdio' } - | { - transport: 'socket'; - socketPath: string; - autoReconnect?: boolean; - reconnectBaseDelayMs?: number; - reconnectMaxDelayMs?: number; - maxReconnectAttempts?: number; - }; - -// Client config -interface IVpnClientConfig { - serverUrl: string; // WS: 'wss://host/path' | QUIC: 'host:port' - serverPublicKey: string; // Base64-encoded Noise static key (or WG public key) - transport?: 'auto' | 'websocket' | 'quic' | 'wireguard'; // Default: 'auto' - serverCertHash?: string; // SHA-256 cert hash (base64) for QUIC pinning - dns?: string[]; - mtu?: number; - keepaliveIntervalSecs?: number; - // WireGuard-specific - wgPrivateKey?: string; // Client private key (base64, X25519) - wgAddress?: string; // Client TUN address (e.g. 10.8.0.2) - wgAddressPrefix?: number; // Address prefix length (default: 24) - wgPresharedKey?: string; // Optional preshared key (base64) - wgPersistentKeepalive?: number; // Persistent keepalive interval (seconds) - wgEndpoint?: string; // Server endpoint (host:port) - wgAllowedIps?: string[]; // Allowed IPs (CIDR strings) -} - -// Server config -interface IVpnServerConfig { - listenAddr: string; - privateKey: string; - publicKey: string; - subnet: string; - tlsCert?: string; - tlsKey?: string; - dns?: string[]; - mtu?: number; - keepaliveIntervalSecs?: number; - enableNat?: boolean; - transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard'; - quicListenAddr?: string; - quicIdleTimeoutSecs?: number; - defaultRateLimitBytesPerSec?: number; - defaultBurstBytes?: number; - // WireGuard-specific - wgListenPort?: number; // UDP port (default: 51820) - wgPeers?: IWgPeerConfig[]; // Initial peers -} - -// WireGuard peer config -interface IWgPeerConfig { - publicKey: string; // Peer's X25519 public key (base64) - presharedKey?: string; // Optional preshared key (base64) - allowedIps: string[]; // Allowed IP ranges (CIDR) - endpoint?: string; // Peer endpoint (host:port) - persistentKeepalive?: number; // Keepalive interval (seconds) -} - -// WireGuard peer info (with live stats) -interface IWgPeerInfo { - publicKey: string; - allowedIps: string[]; - endpoint?: string; - persistentKeepalive?: number; - bytesSent: number; - bytesReceived: number; - packetsSent: number; - packetsReceived: number; - lastHandshakeTime?: string; -} - -// Status -type TVpnConnectionState = 'disconnected' | 'connecting' | 'handshaking' - | 'connected' | 'reconnecting' | 'error'; - -interface IVpnStatus { - state: TVpnConnectionState; - assignedIp?: string; - serverAddr?: string; - connectedSince?: string; - lastError?: string; -} - -// Statistics -interface IVpnStatistics { - bytesSent: number; - bytesReceived: number; - packetsSent: number; - packetsReceived: number; - keepalivesSent: number; - keepalivesReceived: number; - uptimeSeconds: number; - quality?: IVpnConnectionQuality; -} - -interface IVpnServerStatistics extends IVpnStatistics { - activeClients: number; - totalConnections: number; -} - -// Connection quality (QoS) -type TVpnLinkHealth = 'healthy' | 'degraded' | 'critical'; - -interface IVpnConnectionQuality { - srttMs: number; - jitterMs: number; - minRttMs: number; - maxRttMs: number; - lossRatio: number; - consecutiveTimeouts: number; - linkHealth: TVpnLinkHealth; - currentKeepaliveIntervalSecs: number; -} - -// MTU info -interface IVpnMtuInfo { - tunMtu: number; - effectiveMtu: number; - linkMtu: number; - overheadBytes: number; - oversizedPacketsDropped: number; - icmpTooBigSent: number; -} - -// Client info (with QoS fields) -interface IVpnClientInfo { - clientId: string; - assignedIp: string; - connectedSince: string; - bytesSent: number; - bytesReceived: number; - packetsDropped: number; - bytesDropped: number; - lastKeepaliveAt?: string; - keepalivesReceived: number; - rateLimitBytesPerSec?: number; - burstBytes?: number; -} - -// Per-client telemetry -interface IVpnClientTelemetry { - clientId: string; - assignedIp: string; - lastKeepaliveAt?: string; - keepalivesReceived: number; - packetsDropped: number; - bytesDropped: number; - bytesReceived: number; - bytesSent: number; - rateLimitBytesPerSec?: number; - burstBytes?: number; -} - -interface IVpnKeypair { - publicKey: string; - privateKey: string; -} ``` - -
+smartvpn/ +β”œβ”€β”€ ts/ # TypeScript control plane +β”‚ β”œβ”€β”€ index.ts # All exports +β”‚ β”œβ”€β”€ smartvpn.interfaces.ts # Interfaces, types, IPC command maps +β”‚ β”œβ”€β”€ smartvpn.classes.vpnserver.ts +β”‚ β”œβ”€β”€ smartvpn.classes.vpnclient.ts +β”‚ β”œβ”€β”€ smartvpn.classes.vpnbridge.ts +β”‚ β”œβ”€β”€ smartvpn.classes.vpnconfig.ts +β”‚ β”œβ”€β”€ smartvpn.classes.vpninstaller.ts +β”‚ └── smartvpn.classes.wgconfig.ts +β”œβ”€β”€ rust/ # Rust data plane daemon +β”‚ └── src/ +β”‚ β”œβ”€β”€ main.rs # CLI entry point +β”‚ β”œβ”€β”€ server.rs # VPN server + hub methods +β”‚ β”œβ”€β”€ client.rs # VPN client +β”‚ β”œβ”€β”€ crypto.rs # Noise IK + XChaCha20 +β”‚ β”œβ”€β”€ client_registry.rs # Client database +β”‚ β”œβ”€β”€ acl.rs # ACL engine +β”‚ β”œβ”€β”€ management.rs # JSON-lines IPC +β”‚ β”œβ”€β”€ transport.rs # WebSocket transport +β”‚ β”œβ”€β”€ quic_transport.rs # QUIC transport +β”‚ β”œβ”€β”€ wireguard.rs # WireGuard (boringtun) +β”‚ β”œβ”€β”€ codec.rs # Binary frame protocol +β”‚ β”œβ”€β”€ keepalive.rs # Adaptive keepalives +β”‚ β”œβ”€β”€ ratelimit.rs # Token bucket +β”‚ └── ... # tunnel, network, telemetry, qos, mtu, reconnect +β”œβ”€β”€ test/ # 9 test files (79 tests) +β”œβ”€β”€ dist_ts/ # Compiled TypeScript +└── dist_rust/ # Cross-compiled binaries (linux amd64 + arm64) +``` ## 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.md) 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. @@ -899,7 +372,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G ### Company Information -Task Venture Capital GmbH +Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany For any legal inquiries or further information, please contact us via email at hello@task.vc. diff --git a/readme.plan.md b/readme.plan.md index 6c8e012..f15d140 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -44,7 +44,8 @@ IK is a 2-message handshake (same count as NK), so **the frame protocol stays id ## Core Interface: `IClientEntry` -This is the server-side client definition β€” the central config object: +This is the server-side client definition β€” the central config object. +Naming and structure are aligned with SmartProxy's `IRouteConfig` / `IRouteSecurity` patterns. ```typescript export interface IClientEntry { @@ -56,27 +57,16 @@ export interface IClientEntry { /** Client's WireGuard public key (base64) β€” for WireGuard transport */ wgPublicKey?: string; - // ── Network ACLs ────────────────────────────────────────────────────── + // ── Security (aligned with SmartProxy IRouteSecurity pattern) ───────── - /** Source IPs/CIDRs the client may connect FROM (empty = any) */ - allowedFrom?: string[]; - /** Destination IPs/CIDRs the client is allowed to reach (empty = all) */ - allowedTo?: string[]; - /** Blocklist: source IPs denied β€” overrides allowedFrom */ - notAllowedFrom?: string[]; - /** Blocklist: destination IPs denied β€” overrides allowedTo */ - notAllowedTo?: string[]; + security?: IClientSecurity; // ── QoS ──────────────────────────────────────────────────────────────── - /** Rate limit in bytes/sec (omit = server default or unlimited) */ - rateLimitBytesPerSec?: number; - /** Burst size in bytes */ - burstBytes?: number; /** Traffic priority (lower = higher priority, default: 100) */ priority?: number; - // ── Metadata ─────────────────────────────────────────────────────────── + // ── Metadata (aligned with SmartProxy IRouteConfig pattern) ──────────── /** Whether this client is enabled (default: true) */ enabled?: boolean; @@ -87,16 +77,57 @@ export interface IClientEntry { /** 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; +} ``` +### 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 ``` -1. Check notAllowedFrom / notAllowedTo first (explicit deny wins) +1. Check ipBlockList / destinationBlockList first (explicit deny wins) 2. If denied, DROP -3. Check allowedFrom / allowedTo (explicit allow) -4. If allowedFrom is empty β†’ allow any source -5. If allowedTo is empty β†’ allow all destinations +3. Check ipAllowList / destinationAllowList (explicit allow) +4. If ipAllowList is empty β†’ allow any source +5. If destinationAllowList is empty β†’ allow all destinations ``` --- @@ -241,12 +272,7 @@ pub struct ClientEntry { pub client_id: String, pub public_key: String, pub wg_public_key: Option, - pub allowed_from: Option>, - pub allowed_to: Option>, - pub not_allowed_from: Option>, - pub not_allowed_to: Option>, - pub rate_limit_bytes_per_sec: Option, - pub burst_bytes: Option, + pub security: Option, pub priority: Option, pub enabled: Option, pub tags: Option>, @@ -255,6 +281,21 @@ pub struct ClientEntry { pub assigned_ip: Option, } +/// Mirrors IClientSecurity β€” aligned with SmartProxy's IRouteSecurity +pub struct ClientSecurity { + pub ip_allow_list: Option>, + pub ip_block_list: Option>, + pub destination_allow_list: Option>, + pub destination_block_list: Option>, + pub max_connections: Option, + pub rate_limit: Option, +} + +pub struct ClientRateLimit { + pub bytes_per_sec: u64, + pub burst_bytes: u64, +} + pub struct ClientRegistry { entries: HashMap, // keyed by clientId key_index: HashMap, // publicKey β†’ clientId (fast lookup) @@ -269,9 +310,10 @@ Methods: `add`, `remove`, `get_by_id`, `get_by_key`, `update`, `list`, `is_autho **Modify: `rust/src/lib.rs`** β€” add `pub mod acl;` ```rust -pub fn check_acl(entry: &ClientEntry, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult { - // 1. Check notAllowedFrom/notAllowedTo (deny overrides) - // 2. Check allowedFrom/allowedTo (explicit allow) +/// 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 } ``` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 77da132..141a1e6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -46,6 +46,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -306,6 +315,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -728,6 +751,30 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "inout" version = "0.1.4" @@ -942,6 +989,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1528,8 +1584,10 @@ dependencies = [ "boringtun", "bytes", "chacha20poly1305", + "chrono", "clap", "futures-util", + "ipnet", "mimalloc", "quinn", "rand 0.8.5", @@ -2020,12 +2078,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f1706b9..e11f1c1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -35,6 +35,8 @@ rustls-pemfile = "2" webpki-roots = "1" mimalloc = "0.1" boringtun = "0.7" +chrono = { version = "0.4", features = ["serde"] } +ipnet = "2" [profile.release] opt-level = 3 diff --git a/rust/src/acl.rs b/rust/src/acl.rs new file mode 100644 index 0000000..afb4683 --- /dev/null +++ b/rust/src/acl.rs @@ -0,0 +1,278 @@ +use std::net::Ipv4Addr; +use ipnet::Ipv4Net; + +use crate::client_registry::ClientSecurity; + +/// Result of an ACL check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AclResult { + Allow, + DenySrc, + DenyDst, +} + +/// Check whether a packet from `src_ip` to `dst_ip` is allowed by the client's security policy. +/// +/// Evaluation order (deny overrides allow): +/// 1. If src_ip is in ip_block_list β†’ DenySrc +/// 2. If dst_ip is in destination_block_list β†’ DenyDst +/// 3. If ip_allow_list is non-empty and src_ip is NOT in it β†’ DenySrc +/// 4. If destination_allow_list is non-empty and dst_ip is NOT in it β†’ DenyDst +/// 5. Otherwise β†’ Allow +pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult { + // Step 1: Check source block list (deny overrides) + if let Some(ref block_list) = security.ip_block_list { + if ip_matches_any(src_ip, block_list) { + return AclResult::DenySrc; + } + } + + // Step 2: Check destination block list (deny overrides) + if let Some(ref block_list) = security.destination_block_list { + if ip_matches_any(dst_ip, block_list) { + return AclResult::DenyDst; + } + } + + // Step 3: Check source allow list (if non-empty, must match) + if let Some(ref allow_list) = security.ip_allow_list { + if !allow_list.is_empty() && !ip_matches_any(src_ip, allow_list) { + return AclResult::DenySrc; + } + } + + // Step 4: Check destination allow list (if non-empty, must match) + if let Some(ref allow_list) = security.destination_allow_list { + if !allow_list.is_empty() && !ip_matches_any(dst_ip, allow_list) { + return AclResult::DenyDst; + } + } + + AclResult::Allow +} + +/// Check if `ip` matches any pattern in the list. +/// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*), +/// and IP ranges (192.168.1.1-192.168.1.100). +fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool { + for pattern in patterns { + if ip_matches(ip, pattern) { + return true; + } + } + false +} + +/// Check if `ip` matches a single pattern. +fn ip_matches(ip: Ipv4Addr, pattern: &str) -> bool { + let pattern = pattern.trim(); + + // CIDR notation (e.g. 192.168.1.0/24) + if pattern.contains('/') { + if let Ok(net) = pattern.parse::() { + return net.contains(&ip); + } + return false; + } + + // IP range (e.g. 192.168.1.1-192.168.1.100) + if pattern.contains('-') { + let parts: Vec<&str> = pattern.splitn(2, '-').collect(); + if parts.len() == 2 { + if let (Ok(start), Ok(end)) = (parts[0].trim().parse::(), parts[1].trim().parse::()) { + let ip_u32 = u32::from(ip); + return ip_u32 >= u32::from(start) && ip_u32 <= u32::from(end); + } + } + return false; + } + + // Wildcard pattern (e.g. 192.168.1.*) + if pattern.contains('*') { + return wildcard_matches(ip, pattern); + } + + // Exact IP match + if let Ok(exact) = pattern.parse::() { + return ip == exact; + } + + false +} + +/// Match an IP against a wildcard pattern like "192.168.1.*" or "10.*.*.*". +fn wildcard_matches(ip: Ipv4Addr, pattern: &str) -> bool { + let ip_octets = ip.octets(); + let pattern_parts: Vec<&str> = pattern.split('.').collect(); + if pattern_parts.len() != 4 { + return false; + } + for (i, part) in pattern_parts.iter().enumerate() { + if *part == "*" { + continue; + } + if let Ok(octet) = part.parse::() { + if ip_octets[i] != octet { + return false; + } + } else { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client_registry::{ClientRateLimit, ClientSecurity}; + + fn security( + ip_allow: Option>, + ip_block: Option>, + dst_allow: Option>, + dst_block: Option>, + ) -> ClientSecurity { + ClientSecurity { + ip_allow_list: ip_allow.map(|v| v.into_iter().map(String::from).collect()), + ip_block_list: ip_block.map(|v| v.into_iter().map(String::from).collect()), + destination_allow_list: dst_allow.map(|v| v.into_iter().map(String::from).collect()), + destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()), + max_connections: None, + rate_limit: None, + } + } + + fn ip(s: &str) -> Ipv4Addr { + s.parse().unwrap() + } + + // ── No restrictions (empty security) ──────────────────────────────── + + #[test] + fn empty_security_allows_all() { + let sec = security(None, None, None, None); + assert_eq!(check_acl(&sec, ip("1.2.3.4"), ip("5.6.7.8")), AclResult::Allow); + } + + #[test] + fn empty_lists_allow_all() { + let sec = security(Some(vec![]), Some(vec![]), Some(vec![]), Some(vec![])); + assert_eq!(check_acl(&sec, ip("1.2.3.4"), ip("5.6.7.8")), AclResult::Allow); + } + + // ── Source IP allow list ──────────────────────────────────────────── + + #[test] + fn src_allow_exact_match() { + let sec = security(Some(vec!["10.0.0.1"]), None, None, None); + assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("5.6.7.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("10.0.0.2"), ip("5.6.7.8")), AclResult::DenySrc); + } + + #[test] + fn src_allow_cidr() { + let sec = security(Some(vec!["192.168.1.0/24"]), None, None, None); + assert_eq!(check_acl(&sec, ip("192.168.1.50"), ip("5.6.7.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("192.168.2.1"), ip("5.6.7.8")), AclResult::DenySrc); + } + + #[test] + fn src_allow_wildcard() { + let sec = security(Some(vec!["10.0.*.*"]), None, None, None); + assert_eq!(check_acl(&sec, ip("10.0.5.3"), ip("5.6.7.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("10.1.0.1"), ip("5.6.7.8")), AclResult::DenySrc); + } + + #[test] + fn src_allow_range() { + let sec = security(Some(vec!["10.0.0.1-10.0.0.10"]), None, None, None); + assert_eq!(check_acl(&sec, ip("10.0.0.5"), ip("5.6.7.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("10.0.0.11"), ip("5.6.7.8")), AclResult::DenySrc); + } + + // ── Source IP block list (deny overrides) ─────────────────────────── + + #[test] + fn src_block_overrides_allow() { + let sec = security( + Some(vec!["192.168.1.0/24"]), + Some(vec!["192.168.1.100"]), + None, + None, + ); + assert_eq!(check_acl(&sec, ip("192.168.1.50"), ip("5.6.7.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("192.168.1.100"), ip("5.6.7.8")), AclResult::DenySrc); + } + + // ── Destination allow list ────────────────────────────────────────── + + #[test] + fn dst_allow_exact() { + let sec = security(None, None, Some(vec!["8.8.8.8", "8.8.4.4"]), None); + assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("8.8.8.8")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("1.1.1.1")), AclResult::DenyDst); + } + + #[test] + fn dst_allow_cidr() { + let sec = security(None, None, Some(vec!["10.0.0.0/8"]), None); + assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.5.3.2")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("172.16.0.1")), AclResult::DenyDst); + } + + // ── Destination block list (deny overrides) ───────────────────────── + + #[test] + fn dst_block_overrides_allow() { + let sec = security( + None, + None, + Some(vec!["10.0.0.0/8"]), + Some(vec!["10.0.0.99"]), + ); + assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.0.0.1")), AclResult::Allow); + assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.0.0.99")), AclResult::DenyDst); + } + + // ── Combined source + destination ─────────────────────────────────── + + #[test] + fn combined_src_and_dst_filtering() { + let sec = security( + Some(vec!["192.168.1.0/24"]), + None, + Some(vec!["8.8.8.8"]), + None, + ); + // Valid source, valid dest + assert_eq!(check_acl(&sec, ip("192.168.1.10"), ip("8.8.8.8")), AclResult::Allow); + // Invalid source + assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("8.8.8.8")), AclResult::DenySrc); + // Valid source, invalid dest + assert_eq!(check_acl(&sec, ip("192.168.1.10"), ip("1.1.1.1")), AclResult::DenyDst); + } + + // ── IP matching edge cases ────────────────────────────────────────── + + #[test] + fn wildcard_single_octet() { + assert!(ip_matches(ip("10.0.0.5"), "10.0.0.*")); + assert!(!ip_matches(ip("10.0.1.5"), "10.0.0.*")); + } + + #[test] + fn range_boundaries() { + assert!(ip_matches(ip("10.0.0.1"), "10.0.0.1-10.0.0.5")); + assert!(ip_matches(ip("10.0.0.5"), "10.0.0.1-10.0.0.5")); + assert!(!ip_matches(ip("10.0.0.6"), "10.0.0.1-10.0.0.5")); + assert!(!ip_matches(ip("10.0.0.0"), "10.0.0.1-10.0.0.5")); + } + + #[test] + fn invalid_pattern_no_match() { + assert!(!ip_matches(ip("10.0.0.1"), "not-an-ip")); + assert!(!ip_matches(ip("10.0.0.1"), "10.0.0.1/99")); + assert!(!ip_matches(ip("10.0.0.1"), "10.0.0")); + } +} diff --git a/rust/src/client.rs b/rust/src/client.rs index ad1cadc..687ed4d 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -19,6 +19,10 @@ use crate::quic_transport; pub struct ClientConfig { pub server_url: String, pub server_public_key: String, + /// Client's Noise IK static private key (base64) β€” required for authentication. + pub client_private_key: String, + /// Client's Noise IK static public key (base64) β€” for reference/display. + pub client_public_key: String, pub dns: Option>, pub mtu: Option, pub keepalive_interval_secs: Option, @@ -104,11 +108,15 @@ impl VpnClient { let connected_since = self.connected_since.clone(); let link_health = self.link_health.clone(); - // Decode server public key + // Decode keys let server_pub_key = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &config.server_public_key, )?; + let client_priv_key = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &config.client_private_key, + )?; // Create transport based on configuration let (mut sink, mut stream): (Box, Box) = { @@ -171,12 +179,12 @@ impl VpnClient { } }; - // Noise NK handshake (client side = initiator) + // Noise IK handshake (client side = initiator, presents static key) *state.write().await = ClientState::Handshaking; - let mut initiator = crypto::create_initiator(&server_pub_key)?; + let mut initiator = crypto::create_initiator(&client_priv_key, &server_pub_key)?; let mut buf = vec![0u8; 65535]; - // -> e, es + // -> e, es, s, ss let len = initiator.write_message(&[], &mut buf)?; let init_frame = Frame { packet_type: PacketType::HandshakeInit, @@ -186,7 +194,7 @@ impl VpnClient { >::encode(&mut FrameCodec, init_frame, &mut frame_bytes)?; sink.send_reliable(frame_bytes.to_vec()).await?; - // <- e, ee + // <- e, ee, se let resp_msg = match stream.recv_reliable().await? { Some(data) => data, None => anyhow::bail!("Connection closed during handshake"), diff --git a/rust/src/client_registry.rs b/rust/src/client_registry.rs new file mode 100644 index 0000000..18867c8 --- /dev/null +++ b/rust/src/client_registry.rs @@ -0,0 +1,362 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Per-client rate limiting configuration. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientRateLimit { + pub bytes_per_sec: u64, + pub burst_bytes: u64, +} + +/// Per-client security settings β€” aligned with SmartProxy's IRouteSecurity pattern. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientSecurity { + /// Source IPs/CIDRs the client may connect FROM (empty/None = any). + pub ip_allow_list: Option>, + /// Source IPs blocked β€” overrides ip_allow_list (deny wins). + pub ip_block_list: Option>, + /// Destination IPs/CIDRs the client may reach (empty/None = all). + pub destination_allow_list: Option>, + /// Destination IPs blocked β€” overrides destination_allow_list (deny wins). + pub destination_block_list: Option>, + /// Max concurrent connections from this client. + pub max_connections: Option, + /// Per-client rate limiting. + pub rate_limit: Option, +} + +/// A registered client entry β€” the server-side source of truth. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientEntry { + /// Human-readable client ID (e.g. "alice-laptop"). + pub client_id: String, + /// Client's Noise IK public key (base64). + pub public_key: String, + /// Client's WireGuard public key (base64) β€” optional. + pub wg_public_key: Option, + /// Security settings (ACLs, rate limits). + pub security: Option, + /// Traffic priority (lower = higher priority, default: 100). + pub priority: Option, + /// Whether this client is enabled (default: true). + pub enabled: Option, + /// Tags for grouping. + pub tags: Option>, + /// Optional description. + pub description: Option, + /// Optional expiry (ISO 8601 timestamp). + pub expires_at: Option, + /// Assigned VPN IP address. + pub assigned_ip: Option, +} + +impl ClientEntry { + /// Whether this client is considered enabled (defaults to true). + pub fn is_enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } + + /// Whether this client has expired based on current time. + pub fn is_expired(&self) -> bool { + if let Some(ref expires) = self.expires_at { + if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires) { + return chrono::Utc::now() > expiry; + } + } + false + } +} + +/// In-memory client registry with dual-key indexing. +pub struct ClientRegistry { + /// Primary index: clientId β†’ ClientEntry + entries: HashMap, + /// Secondary index: publicKey (base64) β†’ clientId (fast lookup during handshake) + key_index: HashMap, +} + +impl ClientRegistry { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + key_index: HashMap::new(), + } + } + + /// Build a registry from a list of client entries. + pub fn from_entries(entries: Vec) -> Result { + let mut registry = Self::new(); + for entry in entries { + registry.add(entry)?; + } + Ok(registry) + } + + /// Add a client to the registry. + pub fn add(&mut self, entry: ClientEntry) -> Result<()> { + if self.entries.contains_key(&entry.client_id) { + anyhow::bail!("Client '{}' already exists", entry.client_id); + } + if self.key_index.contains_key(&entry.public_key) { + anyhow::bail!("Public key already registered to another client"); + } + self.key_index.insert(entry.public_key.clone(), entry.client_id.clone()); + self.entries.insert(entry.client_id.clone(), entry); + Ok(()) + } + + /// Remove a client by ID. + pub fn remove(&mut self, client_id: &str) -> Result { + let entry = self.entries.remove(client_id) + .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; + self.key_index.remove(&entry.public_key); + Ok(entry) + } + + /// Get a client by ID. + pub fn get_by_id(&self, client_id: &str) -> Option<&ClientEntry> { + self.entries.get(client_id) + } + + /// Get a client by public key (used during IK handshake verification). + pub fn get_by_key(&self, public_key: &str) -> Option<&ClientEntry> { + let client_id = self.key_index.get(public_key)?; + self.entries.get(client_id) + } + + /// Check if a public key is authorized (exists, enabled, not expired). + pub fn is_authorized(&self, public_key: &str) -> bool { + match self.get_by_key(public_key) { + Some(entry) => entry.is_enabled() && !entry.is_expired(), + None => false, + } + } + + /// Update a client entry. The closure receives a mutable reference to the entry. + pub fn update(&mut self, client_id: &str, updater: F) -> Result<()> + where + F: FnOnce(&mut ClientEntry), + { + let entry = self.entries.get_mut(client_id) + .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; + let old_key = entry.public_key.clone(); + updater(entry); + // If public key changed, update the index + if entry.public_key != old_key { + self.key_index.remove(&old_key); + self.key_index.insert(entry.public_key.clone(), client_id.to_string()); + } + Ok(()) + } + + /// List all client entries. + pub fn list(&self) -> Vec<&ClientEntry> { + self.entries.values().collect() + } + + /// Rotate a client's keys. Returns the updated entry. + pub fn rotate_key(&mut self, client_id: &str, new_public_key: String, new_wg_public_key: Option) -> Result<()> { + let entry = self.entries.get_mut(client_id) + .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; + // Update key index + self.key_index.remove(&entry.public_key); + entry.public_key = new_public_key.clone(); + entry.wg_public_key = new_wg_public_key; + self.key_index.insert(new_public_key, client_id.to_string()); + Ok(()) + } + + /// Number of registered clients. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_entry(id: &str, key: &str) -> ClientEntry { + ClientEntry { + client_id: id.to_string(), + public_key: key.to_string(), + wg_public_key: None, + security: None, + priority: None, + enabled: None, + tags: None, + description: None, + expires_at: None, + assigned_ip: None, + } + } + + #[test] + fn add_and_lookup() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key_alice")).unwrap(); + + assert!(reg.get_by_id("alice").is_some()); + assert!(reg.get_by_key("key_alice").is_some()); + assert_eq!(reg.get_by_key("key_alice").unwrap().client_id, "alice"); + assert!(reg.get_by_id("bob").is_none()); + assert!(reg.get_by_key("key_bob").is_none()); + } + + #[test] + fn reject_duplicate_id() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key1")).unwrap(); + assert!(reg.add(make_entry("alice", "key2")).is_err()); + } + + #[test] + fn reject_duplicate_key() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "same_key")).unwrap(); + assert!(reg.add(make_entry("bob", "same_key")).is_err()); + } + + #[test] + fn remove_client() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key_alice")).unwrap(); + assert_eq!(reg.len(), 1); + + let removed = reg.remove("alice").unwrap(); + assert_eq!(removed.client_id, "alice"); + assert_eq!(reg.len(), 0); + assert!(reg.get_by_key("key_alice").is_none()); + } + + #[test] + fn remove_nonexistent_fails() { + let mut reg = ClientRegistry::new(); + assert!(reg.remove("ghost").is_err()); + } + + #[test] + fn is_authorized_enabled() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key_alice")).unwrap(); + assert!(reg.is_authorized("key_alice")); // enabled by default + } + + #[test] + fn is_authorized_disabled() { + let mut reg = ClientRegistry::new(); + let mut entry = make_entry("alice", "key_alice"); + entry.enabled = Some(false); + reg.add(entry).unwrap(); + assert!(!reg.is_authorized("key_alice")); + } + + #[test] + fn is_authorized_expired() { + let mut reg = ClientRegistry::new(); + let mut entry = make_entry("alice", "key_alice"); + entry.expires_at = Some("2020-01-01T00:00:00Z".to_string()); + reg.add(entry).unwrap(); + assert!(!reg.is_authorized("key_alice")); + } + + #[test] + fn is_authorized_future_expiry() { + let mut reg = ClientRegistry::new(); + let mut entry = make_entry("alice", "key_alice"); + entry.expires_at = Some("2099-01-01T00:00:00Z".to_string()); + reg.add(entry).unwrap(); + assert!(reg.is_authorized("key_alice")); + } + + #[test] + fn is_authorized_unknown_key() { + let reg = ClientRegistry::new(); + assert!(!reg.is_authorized("nonexistent")); + } + + #[test] + fn update_client() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key_alice")).unwrap(); + + reg.update("alice", |entry| { + entry.description = Some("Updated".to_string()); + entry.enabled = Some(false); + }).unwrap(); + + let entry = reg.get_by_id("alice").unwrap(); + assert_eq!(entry.description.as_deref(), Some("Updated")); + assert!(!entry.is_enabled()); + } + + #[test] + fn update_nonexistent_fails() { + let mut reg = ClientRegistry::new(); + assert!(reg.update("ghost", |_| {}).is_err()); + } + + #[test] + fn rotate_key() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "old_key")).unwrap(); + + reg.rotate_key("alice", "new_key".to_string(), None).unwrap(); + + assert!(reg.get_by_key("old_key").is_none()); + assert!(reg.get_by_key("new_key").is_some()); + assert_eq!(reg.get_by_id("alice").unwrap().public_key, "new_key"); + } + + #[test] + fn from_entries() { + let entries = vec![ + make_entry("alice", "key_a"), + make_entry("bob", "key_b"), + ]; + let reg = ClientRegistry::from_entries(entries).unwrap(); + assert_eq!(reg.len(), 2); + assert!(reg.get_by_key("key_a").is_some()); + assert!(reg.get_by_key("key_b").is_some()); + } + + #[test] + fn list_clients() { + let mut reg = ClientRegistry::new(); + reg.add(make_entry("alice", "key_a")).unwrap(); + reg.add(make_entry("bob", "key_b")).unwrap(); + let list = reg.list(); + assert_eq!(list.len(), 2); + } + + #[test] + fn security_with_rate_limit() { + let mut entry = make_entry("alice", "key_alice"); + entry.security = Some(ClientSecurity { + ip_allow_list: Some(vec!["192.168.1.0/24".to_string()]), + ip_block_list: Some(vec!["192.168.1.100".to_string()]), + destination_allow_list: None, + destination_block_list: None, + max_connections: Some(5), + rate_limit: Some(ClientRateLimit { + bytes_per_sec: 1_000_000, + burst_bytes: 2_000_000, + }), + }); + let mut reg = ClientRegistry::new(); + reg.add(entry).unwrap(); + let e = reg.get_by_id("alice").unwrap(); + let sec = e.security.as_ref().unwrap(); + assert_eq!(sec.rate_limit.as_ref().unwrap().bytes_per_sec, 1_000_000); + assert_eq!(sec.max_connections, Some(5)); + } +} diff --git a/rust/src/crypto.rs b/rust/src/crypto.rs index 18e8a97..e46065a 100644 --- a/rust/src/crypto.rs +++ b/rust/src/crypto.rs @@ -3,8 +3,10 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use snow::Builder; -/// Noise protocol pattern: NK (client knows server pubkey, no client auth at Noise level) -const NOISE_PATTERN: &str = "Noise_NK_25519_ChaChaPoly_BLAKE2s"; +/// Noise protocol pattern: IK (client presents static key, server authenticates client) +/// IK = Initiator's static key is transmitted; responder's Key is pre-known. +/// This provides mutual authentication: server verifies client identity via public key. +const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; /// Generate a new Noise static keypair. /// Returns (public_key_base64, private_key_base64). @@ -22,18 +24,23 @@ pub fn generate_keypair_raw() -> Result { Ok(builder.generate_keypair()?) } -/// Create a Noise NK initiator (client side). -/// The client knows the server's static public key. -pub fn create_initiator(server_public_key: &[u8]) -> Result { +/// Create a Noise IK initiator (client side). +/// The client provides its own static keypair AND the server's public key. +/// The client's static key is transmitted (encrypted) during the handshake, +/// allowing the server to authenticate the client. +pub fn create_initiator(client_private_key: &[u8], server_public_key: &[u8]) -> Result { let builder = Builder::new(NOISE_PATTERN.parse()?); let state = builder + .local_private_key(client_private_key) .remote_public_key(server_public_key) .build_initiator()?; Ok(state) } -/// Create a Noise NK responder (server side). +/// Create a Noise IK responder (server side). /// The server uses its static private key. +/// After the handshake, call `get_remote_static()` on the HandshakeState +/// (before `into_transport_mode()`) to retrieve the client's public key. pub fn create_responder(private_key: &[u8]) -> Result { let builder = Builder::new(NOISE_PATTERN.parse()?); let state = builder @@ -42,19 +49,20 @@ pub fn create_responder(private_key: &[u8]) -> Result { Ok(state) } -/// Perform the full Noise NK handshake between initiator and responder. -/// Returns (initiator_transport, responder_transport). +/// Perform the full Noise IK handshake between initiator and responder. +/// Returns (initiator_transport, responder_transport, client_public_key). +/// The client_public_key is extracted from the responder before entering transport mode. pub fn perform_handshake( mut initiator: snow::HandshakeState, mut responder: snow::HandshakeState, -) -> Result<(snow::TransportState, snow::TransportState)> { +) -> Result<(snow::TransportState, snow::TransportState, Vec)> { let mut buf = vec![0u8; 65535]; - // -> e, es (initiator sends) + // -> e, es, s, ss (initiator sends ephemeral + encrypted static key) let len = initiator.write_message(&[], &mut buf)?; let msg1 = buf[..len].to_vec(); - // <- e, ee (responder reads and responds) + // <- e, ee, se (responder reads and responds) responder.read_message(&msg1, &mut buf)?; let len = responder.write_message(&[], &mut buf)?; let msg2 = buf[..len].to_vec(); @@ -62,10 +70,16 @@ pub fn perform_handshake( // Initiator reads response initiator.read_message(&msg2, &mut buf)?; + // Extract client's public key from responder BEFORE entering transport mode + let client_public_key = responder + .get_remote_static() + .ok_or_else(|| anyhow::anyhow!("IK handshake did not provide client static key"))? + .to_vec(); + let i_transport = initiator.into_transport_mode()?; let r_transport = responder.into_transport_mode()?; - Ok((i_transport, r_transport)) + Ok((i_transport, r_transport, client_public_key)) } /// XChaCha20-Poly1305 encryption for post-handshake data. @@ -135,15 +149,19 @@ mod tests { } #[test] - fn noise_handshake() { + fn noise_ik_handshake() { let server_kp = generate_keypair_raw().unwrap(); + let client_kp = generate_keypair_raw().unwrap(); - let initiator = create_initiator(&server_kp.public).unwrap(); + let initiator = create_initiator(&client_kp.private, &server_kp.public).unwrap(); let responder = create_responder(&server_kp.private).unwrap(); - let (mut i_transport, mut r_transport) = + let (mut i_transport, mut r_transport, remote_key) = perform_handshake(initiator, responder).unwrap(); + // Verify the server received the client's public key + assert_eq!(remote_key, client_kp.public); + // Test encrypted communication let mut buf = vec![0u8; 65535]; let plaintext = b"hello from client"; @@ -159,6 +177,20 @@ mod tests { assert_eq!(&out[..len], plaintext); } + #[test] + fn noise_ik_wrong_server_key_fails() { + let server_kp = generate_keypair_raw().unwrap(); + let wrong_server_kp = generate_keypair_raw().unwrap(); + let client_kp = generate_keypair_raw().unwrap(); + + // Client uses wrong server public key + let initiator = create_initiator(&client_kp.private, &wrong_server_kp.public).unwrap(); + let responder = create_responder(&server_kp.private).unwrap(); + + // Handshake should fail because client targeted wrong server + assert!(perform_handshake(initiator, responder).is_err()); + } + #[test] fn xchacha_encrypt_decrypt() { let key = [42u8; 32]; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0ca8a35..99851e2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -18,3 +18,5 @@ pub mod ratelimit; pub mod qos; pub mod mtu; pub mod wireguard; +pub mod client_registry; +pub mod acl; diff --git a/rust/src/management.rs b/rust/src/management.rs index ba59bea..854ce51 100644 --- a/rust/src/management.rs +++ b/rust/src/management.rs @@ -585,6 +585,103 @@ async fn handle_server_request( Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)), } } + // ── Client Registry (Hub) Commands ──────────────────────────────── + "createClient" => { + let client_partial = request.params.get("client").cloned().unwrap_or_default(); + match vpn_server.create_client(client_partial).await { + Ok(bundle) => ManagementResponse::ok(id, bundle), + Err(e) => ManagementResponse::err(id, format!("Create client failed: {}", e)), + } + } + "removeClient" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + match vpn_server.remove_registered_client(&client_id).await { + Ok(()) => ManagementResponse::ok(id, serde_json::json!({})), + Err(e) => ManagementResponse::err(id, format!("Remove client failed: {}", e)), + } + } + "getClient" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + match vpn_server.get_registered_client(&client_id).await { + Ok(entry) => ManagementResponse::ok(id, entry), + Err(e) => ManagementResponse::err(id, format!("Get client failed: {}", e)), + } + } + "listRegisteredClients" => { + let clients = vpn_server.list_registered_clients().await; + match serde_json::to_value(&clients) { + Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })), + Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)), + } + } + "updateClient" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + let update = request.params.get("update").cloned().unwrap_or_default(); + match vpn_server.update_registered_client(&client_id, update).await { + Ok(()) => ManagementResponse::ok(id, serde_json::json!({})), + Err(e) => ManagementResponse::err(id, format!("Update client failed: {}", e)), + } + } + "enableClient" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + match vpn_server.enable_client(&client_id).await { + Ok(()) => ManagementResponse::ok(id, serde_json::json!({})), + Err(e) => ManagementResponse::err(id, format!("Enable client failed: {}", e)), + } + } + "disableClient" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + match vpn_server.disable_client(&client_id).await { + Ok(()) => ManagementResponse::ok(id, serde_json::json!({})), + Err(e) => ManagementResponse::err(id, format!("Disable client failed: {}", e)), + } + } + "rotateClientKey" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + match vpn_server.rotate_client_key(&client_id).await { + Ok(bundle) => ManagementResponse::ok(id, bundle), + Err(e) => ManagementResponse::err(id, format!("Key rotation failed: {}", e)), + } + } + "exportClientConfig" => { + let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) { + Some(cid) => cid.to_string(), + None => return ManagementResponse::err(id, "Missing clientId".to_string()), + }; + let format = request.params.get("format").and_then(|v| v.as_str()).unwrap_or("smartvpn"); + match vpn_server.export_client_config(&client_id, format).await { + Ok(config) => ManagementResponse::ok(id, config), + Err(e) => ManagementResponse::err(id, format!("Export failed: {}", e)), + } + } + "generateClientKeypair" => match crypto::generate_keypair() { + Ok((public_key, private_key)) => ManagementResponse::ok( + id, + serde_json::json!({ + "publicKey": public_key, + "privateKey": private_key, + }), + ), + Err(e) => ManagementResponse::err(id, format!("Keypair generation failed: {}", e)), + }, _ => ManagementResponse::err(id, format!("Unknown server method: {}", request.method)), } } diff --git a/rust/src/server.rs b/rust/src/server.rs index 3bb72e3..7522b54 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -9,6 +9,8 @@ use tokio::net::TcpListener; use tokio::sync::{mpsc, Mutex, RwLock}; use tracing::{info, error, warn}; +use crate::acl; +use crate::client_registry::{ClientEntry, ClientRegistry}; use crate::codec::{Frame, FrameCodec, PacketType}; use crate::crypto; use crate::mtu::{MtuConfig, TunnelOverhead}; @@ -45,6 +47,8 @@ pub struct ServerConfig { pub quic_listen_addr: Option, /// QUIC idle timeout in seconds (default: 30). pub quic_idle_timeout_secs: Option, + /// Pre-registered clients for IK authentication. + pub clients: Option>, } /// Information about a connected client. @@ -62,6 +66,10 @@ pub struct ClientInfo { pub keepalives_received: u64, pub rate_limit_bytes_per_sec: Option, pub burst_bytes: Option, + /// Client's authenticated Noise IK public key (base64). + pub authenticated_key: String, + /// Registered client ID from the client registry. + pub registered_client_id: String, } /// Server statistics. @@ -88,6 +96,7 @@ pub struct ServerState { pub rate_limiters: Mutex>, pub mtu_config: MtuConfig, pub started_at: std::time::Instant, + pub client_registry: RwLock, } /// The VPN server. @@ -127,6 +136,12 @@ impl VpnServer { let overhead = TunnelOverhead::default_overhead(); let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu)); + // Build client registry from config + let registry = ClientRegistry::from_entries( + config.clients.clone().unwrap_or_default() + )?; + info!("Client registry loaded with {} entries", registry.len()); + let state = Arc::new(ServerState { config: config.clone(), ip_pool: Mutex::new(ip_pool), @@ -135,6 +150,7 @@ impl VpnServer { rate_limiters: Mutex::new(HashMap::new()), mtu_config, started_at: std::time::Instant::now(), + client_registry: RwLock::new(registry), }); let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); @@ -287,6 +303,263 @@ impl VpnServer { } Ok(()) } + + // ── Client Registry (Hub) Methods ─────────────────────────────────── + + /// Create a new client entry. Generates keypairs and assigns an IP. + /// Returns a JSON value with the full config bundle including secrets. + pub async fn create_client(&self, partial: serde_json::Value) -> Result { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + + let client_id = partial.get("clientId") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("clientId is required"))? + .to_string(); + + // Generate Noise IK keypair for the client + let (noise_pub, noise_priv) = crypto::generate_keypair()?; + + // Generate WireGuard keypair for the client + let (wg_pub, wg_priv) = crate::wireguard::generate_wg_keypair(); + + // Allocate a VPN IP + let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?; + + // Build entry from partial + generated values + let entry = ClientEntry { + client_id: client_id.clone(), + public_key: noise_pub.clone(), + wg_public_key: Some(wg_pub.clone()), + security: serde_json::from_value( + partial.get("security").cloned().unwrap_or(serde_json::Value::Null) + ).ok(), + priority: partial.get("priority").and_then(|v| v.as_u64()).map(|v| v as u32), + enabled: partial.get("enabled").and_then(|v| v.as_bool()).or(Some(true)), + tags: partial.get("tags").and_then(|v| { + v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) + }), + description: partial.get("description").and_then(|v| v.as_str()).map(String::from), + expires_at: partial.get("expiresAt").and_then(|v| v.as_str()).map(String::from), + assigned_ip: Some(assigned_ip.to_string()), + }; + + // Add to registry + state.client_registry.write().await.add(entry.clone())?; + + // Build SmartVPN client config + let smartvpn_config = serde_json::json!({ + "serverUrl": format!("wss://{}", + state.config.listen_addr.replace("0.0.0.0", "localhost")), + "serverPublicKey": state.config.public_key, + "clientPrivateKey": noise_priv, + "clientPublicKey": noise_pub, + "dns": state.config.dns, + "mtu": state.config.mtu, + "keepaliveIntervalSecs": state.config.keepalive_interval_secs, + }); + + // Build WireGuard config string + let wg_config = format!( + "[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n", + wg_priv, + assigned_ip, + state.config.dns.as_ref() + .map(|d| format!("DNS = {}", d.join(", "))) + .unwrap_or_default(), + state.config.public_key, + state.config.listen_addr, + ); + + let entry_json = serde_json::to_value(&entry)?; + + Ok(serde_json::json!({ + "entry": entry_json, + "smartvpnConfig": smartvpn_config, + "wireguardConfig": wg_config, + "secrets": { + "noisePrivateKey": noise_priv, + "wgPrivateKey": wg_priv, + } + })) + } + + /// Remove a registered client from the registry (and disconnect if connected). + pub async fn remove_registered_client(&self, client_id: &str) -> Result<()> { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + let entry = state.client_registry.write().await.remove(client_id)?; + // Release the IP if assigned + if let Some(ref ip_str) = entry.assigned_ip { + if let Ok(ip) = ip_str.parse::() { + state.ip_pool.lock().await.release(&ip); + } + } + // Disconnect if currently connected + let _ = self.disconnect_client(client_id).await; + Ok(()) + } + + /// Get a registered client by ID. + pub async fn get_registered_client(&self, client_id: &str) -> Result { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + let registry = state.client_registry.read().await; + let entry = registry.get_by_id(client_id) + .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; + Ok(serde_json::to_value(entry)?) + } + + /// List all registered clients. + pub async fn list_registered_clients(&self) -> Vec { + if let Some(ref state) = self.state { + state.client_registry.read().await.list().into_iter().cloned().collect() + } else { + Vec::new() + } + } + + /// Update a registered client's fields. + pub async fn update_registered_client(&self, client_id: &str, update: serde_json::Value) -> Result<()> { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + state.client_registry.write().await.update(client_id, |entry| { + if let Some(security) = update.get("security") { + entry.security = serde_json::from_value(security.clone()).ok(); + } + if let Some(priority) = update.get("priority").and_then(|v| v.as_u64()) { + entry.priority = Some(priority as u32); + } + if let Some(enabled) = update.get("enabled").and_then(|v| v.as_bool()) { + entry.enabled = Some(enabled); + } + if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) { + entry.tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect()); + } + if let Some(desc) = update.get("description").and_then(|v| v.as_str()) { + entry.description = Some(desc.to_string()); + } + if let Some(expires) = update.get("expiresAt").and_then(|v| v.as_str()) { + entry.expires_at = Some(expires.to_string()); + } + })?; + Ok(()) + } + + /// Enable a registered client. + pub async fn enable_client(&self, client_id: &str) -> Result<()> { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + state.client_registry.write().await.update(client_id, |entry| { + entry.enabled = Some(true); + }) + } + + /// Disable a registered client (also disconnects if connected). + pub async fn disable_client(&self, client_id: &str) -> Result<()> { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + state.client_registry.write().await.update(client_id, |entry| { + entry.enabled = Some(false); + })?; + // Disconnect if currently connected + let _ = self.disconnect_client(client_id).await; + Ok(()) + } + + /// Rotate a client's keys. Returns a new config bundle with fresh keypairs. + pub async fn rotate_client_key(&self, client_id: &str) -> Result { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + + let (noise_pub, noise_priv) = crypto::generate_keypair()?; + let (wg_pub, wg_priv) = crate::wireguard::generate_wg_keypair(); + + state.client_registry.write().await.rotate_key( + client_id, + noise_pub.clone(), + Some(wg_pub.clone()), + )?; + + // Disconnect existing connection (old key is no longer valid) + let _ = self.disconnect_client(client_id).await; + + // Get updated entry for the config bundle + let entry_json = self.get_registered_client(client_id).await?; + let assigned_ip = entry_json.get("assignedIp") + .and_then(|v| v.as_str()) + .unwrap_or("0.0.0.0"); + + let smartvpn_config = serde_json::json!({ + "serverUrl": format!("wss://{}", + state.config.listen_addr.replace("0.0.0.0", "localhost")), + "serverPublicKey": state.config.public_key, + "clientPrivateKey": noise_priv, + "clientPublicKey": noise_pub, + "dns": state.config.dns, + "mtu": state.config.mtu, + "keepaliveIntervalSecs": state.config.keepalive_interval_secs, + }); + + let wg_config = format!( + "[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n", + wg_priv, assigned_ip, + state.config.dns.as_ref() + .map(|d| format!("DNS = {}", d.join(", "))) + .unwrap_or_default(), + state.config.public_key, + state.config.listen_addr, + ); + + Ok(serde_json::json!({ + "entry": entry_json, + "smartvpnConfig": smartvpn_config, + "wireguardConfig": wg_config, + "secrets": { + "noisePrivateKey": noise_priv, + "wgPrivateKey": wg_priv, + } + })) + } + + /// Export a client config (without secrets) in the specified format. + pub async fn export_client_config(&self, client_id: &str, format: &str) -> Result { + let state = self.state.as_ref() + .ok_or_else(|| anyhow::anyhow!("Server not running"))?; + let registry = state.client_registry.read().await; + let entry = registry.get_by_id(client_id) + .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; + + match format { + "smartvpn" => { + Ok(serde_json::json!({ + "config": { + "serverUrl": format!("wss://{}", + state.config.listen_addr.replace("0.0.0.0", "localhost")), + "serverPublicKey": state.config.public_key, + "clientPublicKey": entry.public_key, + "dns": state.config.dns, + "mtu": state.config.mtu, + "keepaliveIntervalSecs": state.config.keepalive_interval_secs, + } + })) + } + "wireguard" => { + let assigned_ip = entry.assigned_ip.as_deref().unwrap_or("0.0.0.0"); + let config = format!( + "[Interface]\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n", + assigned_ip, + state.config.dns.as_ref() + .map(|d| format!("DNS = {}", d.join(", "))) + .unwrap_or_default(), + state.config.public_key, + state.config.listen_addr, + ); + Ok(serde_json::json!({ "config": config })) + } + _ => anyhow::bail!("Unknown format: {}", format), + } + } } /// WebSocket listener β€” accepts TCP connections, upgrades to WS, then hands off @@ -421,26 +694,23 @@ async fn run_quic_listener( Ok(()) } -/// Transport-agnostic client handler. Performs the Noise NK handshake, registers -/// the client, and runs the main packet forwarding loop. +/// 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, mut sink: Box, mut stream: Box, ) -> Result<()> { - let client_id = uuid_v4(); - - let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?; - let server_private_key = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &state.config.private_key, )?; + // Noise IK handshake (server side = responder) let mut responder = crypto::create_responder(&server_private_key)?; let mut buf = vec![0u8; 65535]; - // Receive handshake init + // Receive handshake init (-> e, es, s, ss) let init_msg = match stream.recv_reliable().await? { Some(data) => data, None => anyhow::bail!("Connection closed before handshake"), @@ -455,6 +725,47 @@ async fn handle_client_connection( } responder.read_message(&frame.payload, &mut buf)?; + + // Extract client's static public key BEFORE entering transport mode + let client_pub_key_bytes = responder + .get_remote_static() + .ok_or_else(|| anyhow::anyhow!("IK handshake: no client static key received"))? + .to_vec(); + let client_pub_key_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &client_pub_key_bytes, + ); + + // Verify client against registry + let (registered_client_id, client_security) = { + let registry = state.client_registry.read().await; + if !registry.is_authorized(&client_pub_key_b64) { + warn!("Rejecting unauthorized client with key {}", &client_pub_key_b64[..8]); + // Send handshake response but then disconnect + let len = responder.write_message(&[], &mut buf)?; + let response_frame = Frame { + packet_type: PacketType::HandshakeResp, + payload: buf[..len].to_vec(), + }; + let mut frame_bytes = BytesMut::new(); + >::encode(&mut FrameCodec, response_frame, &mut frame_bytes)?; + sink.send_reliable(frame_bytes.to_vec()).await?; + + // Send disconnect frame + let disconnect_frame = Frame { + packet_type: PacketType::Disconnect, + payload: Vec::new(), + }; + let mut frame_bytes = BytesMut::new(); + >::encode(&mut FrameCodec, disconnect_frame, &mut frame_bytes)?; + let _ = sink.send_reliable(frame_bytes.to_vec()).await; + anyhow::bail!("Client not authorized"); + } + let entry = registry.get_by_key(&client_pub_key_b64).unwrap(); + (entry.client_id.clone(), entry.security.clone()) + }; + + // Complete handshake (<- e, ee, se) let len = responder.write_message(&[], &mut buf)?; let response_payload = buf[..len].to_vec(); @@ -468,9 +779,24 @@ async fn handle_client_connection( let mut noise_transport = responder.into_transport_mode()?; - // Register client - let default_rate = state.config.default_rate_limit_bytes_per_sec; - let default_burst = state.config.default_burst_bytes; + // 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)?; + + // 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 { + (Some(rl.bytes_per_sec), Some(rl.burst_bytes)) + } else { + (state.config.default_rate_limit_bytes_per_sec, state.config.default_burst_bytes) + } + } else { + (state.config.default_rate_limit_bytes_per_sec, state.config.default_burst_bytes) + }; + + // Register connected client let client_info = ClientInfo { client_id: client_id.clone(), assigned_ip: assigned_ip.to_string(), @@ -481,13 +807,15 @@ async fn handle_client_connection( bytes_dropped: 0, last_keepalive_at: None, keepalives_received: 0, - rate_limit_bytes_per_sec: default_rate, - burst_bytes: default_burst, + rate_limit_bytes_per_sec: rate_limit, + burst_bytes: burst, + authenticated_key: client_pub_key_b64.clone(), + registered_client_id: registered_client_id.clone(), }; state.clients.write().await.insert(client_id.clone(), client_info); - // Set up rate limiter if defaults are configured - if let (Some(rate), Some(burst)) = (default_rate, default_burst) { + // Set up rate limiter + if let (Some(rate), Some(burst)) = (rate_limit, burst) { state .rate_limiters .lock() @@ -517,7 +845,7 @@ async fn handle_client_connection( >::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?; sink.send_reliable(frame_bytes.to_vec()).await?; - info!("Client {} connected with IP {}", client_id, assigned_ip); + info!("Client {} ({}) connected with IP {}", registered_client_id, &client_pub_key_b64[..8], assigned_ip); // Main packet loop with dead-peer detection let mut last_activity = tokio::time::Instant::now(); @@ -534,6 +862,24 @@ async fn handle_client_connection( PacketType::IpPacket => { match noise_transport.read_message(&frame.payload, &mut buf) { Ok(len) => { + // ACL check on decrypted packet + if let Some(ref sec) = client_security { + if len >= 20 { + // Extract src/dst from IPv4 header + let src = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); + let dst = Ipv4Addr::new(buf[16], buf[17], buf[18], buf[19]); + let acl_result = acl::check_acl(sec, src, dst); + if acl_result != acl::AclResult::Allow { + let mut clients = state.clients.write().await; + if let Some(info) = clients.get_mut(&client_id) { + info.packets_dropped += 1; + info.bytes_dropped += len as u64; + } + continue; + } + } + } + // Rate limiting check let allowed = { let mut limiters = state.rate_limiters.lock().await; @@ -635,20 +981,6 @@ async fn handle_client_connection( Ok(()) } -fn uuid_v4() -> String { - use rand::Rng; - let mut rng = rand::thread_rng(); - let bytes: [u8; 16] = rng.gen(); - format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], - bytes[6], bytes[7], - bytes[8], bytes[9], - bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], - ) -} - fn timestamp_now() -> String { use std::time::SystemTime; let duration = SystemTime::now() diff --git a/test/test.flowcontrol.node.ts b/test/test.flowcontrol.node.ts index e7edcfa..c7d8b19 100644 --- a/test/test.flowcontrol.node.ts +++ b/test/test.flowcontrol.node.ts @@ -1,7 +1,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { VpnClient, VpnServer } from '../ts/index.js'; -import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js'; +import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js'; // --------------------------------------------------------------------------- // Helpers @@ -40,7 +40,9 @@ let server: VpnServer; let serverPort: number; let keypair: IVpnKeypair; let client: VpnClient; +let clientBundle: IClientConfigBundle; const extraClients: VpnClient[] = []; +const extraBundles: IClientConfigBundle[] = []; // --------------------------------------------------------------------------- // Tests @@ -64,7 +66,7 @@ tap.test('setup: start VPN server', async () => { expect(keypair.publicKey).toBeTypeofString(); expect(keypair.privateKey).toBeTypeofString(); - // Phase 3: start the VPN listener + // Phase 3: start the VPN listener (empty clients, will use createClient at runtime) const serverConfig: IVpnServerConfig = { listenAddr: `127.0.0.1:${serverPort}`, privateKey: keypair.privateKey, @@ -76,6 +78,11 @@ tap.test('setup: start VPN server', async () => { // Verify server is now running const status = await server.getStatus(); expect(status.state).toEqual('connected'); + + // Phase 4: create the first client via the hub + clientBundle = await server.createClient({ clientId: 'test-client-0' }); + expect(clientBundle.secrets.noisePrivateKey).toBeTypeofString(); + expect(clientBundle.smartvpnConfig.clientPublicKey).toBeTypeofString(); }); tap.test('single client connects and gets IP', async () => { @@ -89,6 +96,8 @@ tap.test('single client connects and gets IP', async () => { const result = await client.connect({ serverUrl: `ws://127.0.0.1:${serverPort}`, serverPublicKey: keypair.publicKey, + clientPrivateKey: clientBundle.secrets.noisePrivateKey, + clientPublicKey: clientBundle.smartvpnConfig.clientPublicKey, keepaliveIntervalSecs: 3, }); @@ -175,11 +184,15 @@ tap.test('5 concurrent clients', async () => { assignedIps.add(existingClients[0].assignedIp); for (let i = 0; i < 5; i++) { + const bundle = await server.createClient({ clientId: `test-client-${i + 1}` }); + extraBundles.push(bundle); const c = new VpnClient({ transport: { transport: 'stdio' } }); await c.start(); const result = await c.connect({ serverUrl: `ws://127.0.0.1:${serverPort}`, serverPublicKey: keypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, keepaliveIntervalSecs: 3, }); expect(result.assignedIp).toStartWith('10.8.0.'); diff --git a/test/test.loadtest.node.ts b/test/test.loadtest.node.ts index c6d20c8..1b6187c 100644 --- a/test/test.loadtest.node.ts +++ b/test/test.loadtest.node.ts @@ -144,12 +144,17 @@ let keypair: IVpnKeypair; let throttle: ThrottleProxy; const allClients: VpnClient[] = []; +let clientCounter = 0; async function createConnectedClient(port: number): Promise { + clientCounter++; + const bundle = await server.createClient({ clientId: `load-client-${clientCounter}` }); const c = new VpnClient({ transport: { transport: 'stdio' } }); await c.start(); await c.connect({ serverUrl: `ws://127.0.0.1:${port}`, serverPublicKey: keypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, keepaliveIntervalSecs: 3, }); allClients.push(c); diff --git a/test/test.quic.node.ts b/test/test.quic.node.ts index 6ffc9ae..d3c63d9 100644 --- a/test/test.quic.node.ts +++ b/test/test.quic.node.ts @@ -2,7 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import * as dgram from 'dgram'; import { VpnClient, VpnServer } from '../ts/index.js'; -import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js'; +import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js'; // --------------------------------------------------------------------------- // Helpers @@ -82,6 +82,8 @@ tap.test('setup: start VPN server in QUIC mode', async () => { }); tap.test('QUIC client connects and gets IP', async () => { + const bundle = await server.createClient({ clientId: 'quic-client-1' }); + const options: IVpnClientOptions = { transport: { transport: 'stdio' }, }; @@ -92,6 +94,8 @@ tap.test('QUIC client connects and gets IP', async () => { const result = await client.connect({ serverUrl: `127.0.0.1:${quicPort}`, serverPublicKey: keypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, transport: 'quic', keepaliveIntervalSecs: 3, }); @@ -162,12 +166,16 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () = const started = await client.start(); expect(started).toBeTrue(); + const bundle = await dualServer.createClient({ clientId: 'dual-auto-client' }); + // "auto" mode (default): tries QUIC first at same host:port, falls back to WS // Since the WS port and QUIC port differ, auto will try QUIC on WS port (fail), // then fall back to WebSocket const result = await client.connect({ serverUrl: `ws://127.0.0.1:${dualWsPort}`, serverPublicKey: dualKeypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, // transport defaults to 'auto' keepaliveIntervalSecs: 3, }); @@ -187,6 +195,8 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () = }); tap.test('explicit QUIC client connects to dual-mode server', async () => { + const bundle = await dualServer.createClient({ clientId: 'dual-quic-client' }); + const options: IVpnClientOptions = { transport: { transport: 'stdio' }, }; @@ -197,6 +207,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => { const result = await client.connect({ serverUrl: `127.0.0.1:${dualQuicPort}`, serverPublicKey: dualKeypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, transport: 'quic', keepaliveIntervalSecs: 3, }); @@ -211,6 +223,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => { }); tap.test('keepalive exchange over QUIC', async () => { + const bundle = await dualServer.createClient({ clientId: 'dual-keepalive-client' }); + const options: IVpnClientOptions = { transport: { transport: 'stdio' }, }; @@ -220,6 +234,8 @@ tap.test('keepalive exchange over QUIC', async () => { await client.connect({ serverUrl: `127.0.0.1:${dualQuicPort}`, serverPublicKey: dualKeypair.publicKey, + clientPrivateKey: bundle.secrets.noisePrivateKey, + clientPublicKey: bundle.smartvpnConfig.clientPublicKey, transport: 'quic', keepaliveIntervalSecs: 3, }); diff --git a/test/test.vpnconfig.node.ts b/test/test.vpnconfig.node.ts index d346e5c..6f947ee 100644 --- a/test/test.vpnconfig.node.ts +++ b/test/test.vpnconfig.node.ts @@ -2,10 +2,17 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { VpnConfig } from '../ts/index.js'; import type { IVpnClientConfig, IVpnServerConfig } from '../ts/index.js'; +// Valid 32-byte base64 keys for testing +const TEST_KEY_A = 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE='; +const TEST_KEY_B = 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI='; +const TEST_KEY_C = 'Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M='; + tap.test('VpnConfig: validate valid client config', async () => { const config: IVpnClientConfig = { serverUrl: 'wss://vpn.example.com/tunnel', - serverPublicKey: 'dGVzdHB1YmxpY2tleQ==', + serverPublicKey: TEST_KEY_A, + clientPrivateKey: TEST_KEY_B, + clientPublicKey: TEST_KEY_C, dns: ['1.1.1.1', '8.8.8.8'], mtu: 1420, keepaliveIntervalSecs: 30, @@ -16,7 +23,9 @@ tap.test('VpnConfig: validate valid client config', async () => { tap.test('VpnConfig: reject client config without serverUrl', async () => { const config = { - serverPublicKey: 'dGVzdHB1YmxpY2tleQ==', + serverPublicKey: TEST_KEY_A, + clientPrivateKey: TEST_KEY_B, + clientPublicKey: TEST_KEY_C, } as IVpnClientConfig; let threw = false; try { @@ -31,7 +40,9 @@ tap.test('VpnConfig: reject client config without serverUrl', async () => { tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async () => { const config: IVpnClientConfig = { serverUrl: 'http://vpn.example.com/tunnel', - serverPublicKey: 'dGVzdHB1YmxpY2tleQ==', + serverPublicKey: TEST_KEY_A, + clientPrivateKey: TEST_KEY_B, + clientPublicKey: TEST_KEY_C, }; let threw = false; try { @@ -43,10 +54,28 @@ tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async expect(threw).toBeTrue(); }); +tap.test('VpnConfig: reject client config without clientPrivateKey', async () => { + const config = { + serverUrl: 'wss://vpn.example.com/tunnel', + serverPublicKey: TEST_KEY_A, + clientPublicKey: TEST_KEY_C, + } as IVpnClientConfig; + let threw = false; + try { + VpnConfig.validateClientConfig(config); + } catch (e) { + threw = true; + expect((e as Error).message).toContain('clientPrivateKey'); + } + expect(threw).toBeTrue(); +}); + tap.test('VpnConfig: reject client config with invalid MTU', async () => { const config: IVpnClientConfig = { serverUrl: 'wss://vpn.example.com/tunnel', - serverPublicKey: 'dGVzdHB1YmxpY2tleQ==', + serverPublicKey: TEST_KEY_A, + clientPrivateKey: TEST_KEY_B, + clientPublicKey: TEST_KEY_C, mtu: 100, }; let threw = false; @@ -62,7 +91,9 @@ tap.test('VpnConfig: reject client config with invalid MTU', async () => { tap.test('VpnConfig: reject client config with invalid DNS', async () => { const config: IVpnClientConfig = { serverUrl: 'wss://vpn.example.com/tunnel', - serverPublicKey: 'dGVzdHB1YmxpY2tleQ==', + serverPublicKey: TEST_KEY_A, + clientPrivateKey: TEST_KEY_B, + clientPublicKey: TEST_KEY_C, dns: ['not-an-ip'], }; let threw = false; @@ -78,12 +109,15 @@ tap.test('VpnConfig: reject client config with invalid DNS', async () => { tap.test('VpnConfig: validate valid server config', async () => { const config: IVpnServerConfig = { listenAddr: '0.0.0.0:443', - privateKey: 'dGVzdHByaXZhdGVrZXk=', - publicKey: 'dGVzdHB1YmxpY2tleQ==', + privateKey: TEST_KEY_A, + publicKey: TEST_KEY_B, subnet: '10.8.0.0/24', dns: ['1.1.1.1'], mtu: 1420, enableNat: true, + clients: [ + { clientId: 'test-client', publicKey: TEST_KEY_C }, + ], }; // Should not throw VpnConfig.validateServerConfig(config); @@ -92,8 +126,8 @@ tap.test('VpnConfig: validate valid server config', async () => { tap.test('VpnConfig: reject server config with invalid subnet', async () => { const config: IVpnServerConfig = { listenAddr: '0.0.0.0:443', - privateKey: 'dGVzdHByaXZhdGVrZXk=', - publicKey: 'dGVzdHB1YmxpY2tleQ==', + privateKey: TEST_KEY_A, + publicKey: TEST_KEY_B, subnet: 'invalid', }; let threw = false; @@ -109,7 +143,7 @@ tap.test('VpnConfig: reject server config with invalid subnet', async () => { tap.test('VpnConfig: reject server config without privateKey', async () => { const config = { listenAddr: '0.0.0.0:443', - publicKey: 'dGVzdHB1YmxpY2tleQ==', + publicKey: TEST_KEY_B, subnet: '10.8.0.0/24', } as IVpnServerConfig; let threw = false; @@ -122,4 +156,24 @@ tap.test('VpnConfig: reject server config without privateKey', async () => { expect(threw).toBeTrue(); }); +tap.test('VpnConfig: reject server config with invalid client publicKey', async () => { + const config: IVpnServerConfig = { + listenAddr: '0.0.0.0:443', + privateKey: TEST_KEY_A, + publicKey: TEST_KEY_B, + subnet: '10.8.0.0/24', + clients: [ + { clientId: 'bad-client', publicKey: 'short-key' }, + ], + }; + let threw = false; + try { + VpnConfig.validateServerConfig(config); + } catch (e) { + threw = true; + expect((e as Error).message).toContain('publicKey'); + } + expect(threw).toBeTrue(); +}); + export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 5ee388e..eb15d73 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartvpn', - version: '1.7.0', + version: '1.8.0', description: 'A VPN solution with TypeScript control plane and Rust data plane daemon' } diff --git a/ts/smartvpn.classes.vpnconfig.ts b/ts/smartvpn.classes.vpnconfig.ts index 03936b8..3a946e4 100644 --- a/ts/smartvpn.classes.vpnconfig.ts +++ b/ts/smartvpn.classes.vpnconfig.ts @@ -51,6 +51,15 @@ export class VpnConfig { if (!config.serverPublicKey) { throw new Error('VpnConfig: serverPublicKey is required'); } + // Noise IK requires client keypair + if (!config.clientPrivateKey) { + throw new Error('VpnConfig: clientPrivateKey is required for Noise IK authentication'); + } + VpnConfig.validateBase64Key(config.clientPrivateKey, 'clientPrivateKey'); + if (!config.clientPublicKey) { + throw new Error('VpnConfig: clientPublicKey is required for Noise IK authentication'); + } + VpnConfig.validateBase64Key(config.clientPublicKey, 'clientPublicKey'); } if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) { throw new Error('VpnConfig: mtu must be between 576 and 65535'); @@ -116,6 +125,18 @@ export class VpnConfig { if (!VpnConfig.isValidSubnet(config.subnet)) { throw new Error(`VpnConfig: invalid subnet: ${config.subnet}`); } + // Validate client entries if provided + if (config.clients) { + for (const client of config.clients) { + if (!client.clientId) { + throw new Error('VpnConfig: client entry must have a clientId'); + } + if (!client.publicKey) { + throw new Error(`VpnConfig: client '${client.clientId}' must have a publicKey`); + } + VpnConfig.validateBase64Key(client.publicKey, `client '${client.clientId}' publicKey`); + } + } } if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) { throw new Error('VpnConfig: mtu must be between 576 and 65535'); diff --git a/ts/smartvpn.classes.vpnserver.ts b/ts/smartvpn.classes.vpnserver.ts index abcb676..d17a701 100644 --- a/ts/smartvpn.classes.vpnserver.ts +++ b/ts/smartvpn.classes.vpnserver.ts @@ -10,6 +10,8 @@ import type { IVpnClientTelemetry, IWgPeerConfig, IWgPeerInfo, + IClientEntry, + IClientConfigBundle, TVpnServerCommands, } from './smartvpn.interfaces.js'; @@ -152,6 +154,81 @@ export class VpnServer extends plugins.events.EventEmitter { return result.peers; } + // ── Client Registry (Hub) Methods ───────────────────────────────────── + + /** + * Create a new client. Generates keypairs, assigns IP, returns full config bundle. + * The secrets (private keys) are only returned at creation time. + */ + public async createClient(opts: Partial): Promise { + return this.bridge.sendCommand('createClient', { client: opts }); + } + + /** + * Remove a registered client (also disconnects if connected). + */ + public async removeClient(clientId: string): Promise { + await this.bridge.sendCommand('removeClient', { clientId }); + } + + /** + * Get a registered client by ID. + */ + public async getClient(clientId: string): Promise { + return this.bridge.sendCommand('getClient', { clientId }); + } + + /** + * List all registered clients. + */ + public async listRegisteredClients(): Promise { + const result = await this.bridge.sendCommand('listRegisteredClients', {} as Record); + return result.clients; + } + + /** + * Update a registered client's fields (ACLs, tags, description, etc.). + */ + public async updateClient(clientId: string, update: Partial): Promise { + await this.bridge.sendCommand('updateClient', { clientId, update }); + } + + /** + * Enable a previously disabled client. + */ + public async enableClient(clientId: string): Promise { + await this.bridge.sendCommand('enableClient', { clientId }); + } + + /** + * Disable a client (also disconnects if connected). + */ + public async disableClient(clientId: string): Promise { + await this.bridge.sendCommand('disableClient', { clientId }); + } + + /** + * Rotate a client's keys. Returns a new config bundle with fresh keypairs. + */ + public async rotateClientKey(clientId: string): Promise { + return this.bridge.sendCommand('rotateClientKey', { clientId }); + } + + /** + * Export a client config (without secrets) in the specified format. + */ + public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise { + const result = await this.bridge.sendCommand('exportClientConfig', { clientId, format }); + return result.config; + } + + /** + * Generate a standalone Noise IK keypair (not tied to a client). + */ + public async generateClientKeypair(): Promise { + return this.bridge.sendCommand('generateClientKeypair', {} as Record); + } + /** * Stop the daemon bridge. */ diff --git a/ts/smartvpn.interfaces.ts b/ts/smartvpn.interfaces.ts index 471fc8a..05bfded 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -24,8 +24,12 @@ export type TVpnTransportOptions = IVpnTransportStdio | IVpnTransportSocket; export interface IVpnClientConfig { /** Server WebSocket URL, e.g. wss://vpn.example.com/tunnel */ serverUrl: string; - /** Server's static public key (base64) for Noise NK handshake */ + /** Server's static public key (base64) for Noise IK handshake */ 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; /** Optional DNS servers to use while connected */ dns?: string[]; /** Optional MTU for the TUN device */ @@ -96,6 +100,8 @@ export interface IVpnServerConfig { wgListenPort?: number; /** WireGuard: configured peers */ wgPeers?: IWgPeerConfig[]; + /** Pre-registered clients for Noise IK authentication */ + clients?: IClientEntry[]; } export interface IVpnServerOptions { @@ -146,6 +152,10 @@ export interface IVpnClientInfo { keepalivesReceived: number; rateLimitBytesPerSec?: number; burstBytes?: number; + /** Client's authenticated Noise IK public key (base64) */ + authenticatedKey: string; + /** Registered client ID from the client registry */ + registeredClientId: string; } export interface IVpnServerStatistics extends IVpnStatistics { @@ -205,6 +215,84 @@ export interface IVpnClientTelemetry { burstBytes?: number; } +// ============================================================================ +// Client Registry (Hub) types β€” aligned with SmartProxy IRouteSecurity pattern +// ============================================================================ + +/** Per-client rate limiting. */ +export interface IClientRateLimit { + /** Max throughput in bytes/sec */ + bytesPerSec: number; + /** Burst allowance in bytes */ + burstBytes: number; +} + +/** + * Per-client security settings. + * Mirrors SmartProxy's IRouteSecurity: ipAllowList/ipBlockList naming + deny-overrides-allow. + * Adds VPN-specific destination filtering. + */ +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). */ + ipAllowList?: string[]; + /** Source IPs blocked β€” overrides ipAllowList (deny wins). */ + 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; +} + +/** + * Server-side client definition β€” the central config object for the Hub. + * Naming and structure aligned with SmartProxy's IRouteConfig / IRouteSecurity. + */ +export interface IClientEntry { + /** Human-readable client ID (e.g. "alice-laptop") */ + clientId: string; + /** 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 settings (ACLs, rate limits) */ + security?: IClientSecurity; + /** Traffic priority (lower = higher priority, default: 100) */ + priority?: number; + /** 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; + /** Assigned VPN IP address (set by server) */ + assignedIp?: string; +} + +/** + * Complete client config bundle β€” returned by createClient() and rotateClientKey(). + * Contains everything the client needs to connect. + */ +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; + }; +} + // ============================================================================ // WireGuard-specific types // ============================================================================ @@ -262,6 +350,17 @@ export type TVpnServerCommands = { addWgPeer: { params: { peer: IWgPeerConfig }; result: void }; removeWgPeer: { params: { publicKey: string }; result: void }; listWgPeers: { params: Record; result: { peers: IWgPeerInfo[] } }; + // Client Registry (Hub) commands + createClient: { params: { client: Partial }; result: IClientConfigBundle }; + removeClient: { params: { clientId: string }; result: void }; + getClient: { params: { clientId: string }; result: IClientEntry }; + listRegisteredClients: { params: Record; result: { clients: IClientEntry[] } }; + updateClient: { params: { clientId: string; update: Partial }; result: void }; + enableClient: { params: { clientId: string }; result: void }; + disableClient: { params: { clientId: string }; result: void }; + rotateClientKey: { params: { clientId: string }; result: IClientConfigBundle }; + exportClientConfig: { params: { clientId: string; format: 'smartvpn' | 'wireguard' }; result: { config: string } }; + generateClientKeypair: { params: Record; result: IVpnKeypair }; }; // ============================================================================