Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e14c357ba0 | |||
| eb30825f72 | |||
| 835f0f791d | |||
| aec545fe8c |
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-17 - 1.3.0 - feat(tests,client)
|
||||||
|
add flow control and load test coverage and honor configured keepalive intervals
|
||||||
|
|
||||||
|
- Adds end-to-end node tests for client/server flow control, keepalive exchange, connection quality telemetry, rate limiting, concurrent clients, and disconnect tracking.
|
||||||
|
- Adds load testing with throttled proxy scenarios to validate behavior under constrained bandwidth and repeated client churn.
|
||||||
|
- Updates the Rust client to pass configured keepaliveIntervalSecs into the adaptive keepalive monitor instead of always using defaults.
|
||||||
|
|
||||||
|
## 2026-03-15 - 1.2.0 - feat(readme)
|
||||||
|
document QoS, telemetry, MTU, and rate limiting capabilities in the README
|
||||||
|
|
||||||
|
- Expand the architecture and feature overview to cover adaptive keepalive, telemetry, QoS, rate limiting, and MTU handling
|
||||||
|
- Update client and server examples to show new APIs such as getConnectionQuality(), getMtuInfo(), setClientRateLimit(), and getClientTelemetry()
|
||||||
|
- Add TypeScript interface documentation for connection quality, MTU info, enriched client statistics, and per-client telemetry
|
||||||
|
|
||||||
## 2026-03-15 - 1.1.0 - feat(rust-core)
|
## 2026-03-15 - 1.1.0 - feat(rust-core)
|
||||||
add adaptive keepalive telemetry, MTU handling, and per-client rate limiting APIs
|
add adaptive keepalive telemetry, MTU handling, and per-client rate limiting APIs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
257
readme.md
257
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartvpn
|
# @push.rocks/smartvpn
|
||||||
|
|
||||||
A high-performance VPN solution with a **TypeScript control plane** and a **Rust data plane daemon**. Manage VPN connections with clean, typed APIs while all networking heavy lifting — encryption, tunneling, packet forwarding — runs at native speed in Rust.
|
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.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -9,8 +9,6 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @push.rocks/smartvpn
|
|
||||||
# or
|
|
||||||
pnpm install @push.rocks/smartvpn
|
pnpm install @push.rocks/smartvpn
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -18,17 +16,21 @@ pnpm install @push.rocks/smartvpn
|
|||||||
|
|
||||||
```
|
```
|
||||||
TypeScript (control plane) Rust (data plane)
|
TypeScript (control plane) Rust (data plane)
|
||||||
┌──────────────────────────┐ ┌───────────────────────────────┐
|
┌──────────────────────────┐ ┌────────────────────────────────────┐
|
||||||
│ VpnClient / VpnServer │ │ smartvpn_daemon │
|
│ VpnClient / VpnServer │ │ smartvpn_daemon │
|
||||||
│ └─ VpnBridge │──stdio/──▶ │ ├─ management (JSON IPC) │
|
│ └─ VpnBridge │──stdio/──▶ │ ├─ management (JSON IPC) │
|
||||||
│ └─ RustBridge │ socket │ ├─ transport (WebSocket/TLS) │
|
│ └─ RustBridge │ socket │ ├─ transport (WebSocket/TLS) │
|
||||||
│ (smartrust) │ │ ├─ crypto (Noise NK + XCha) │
|
│ (smartrust) │ │ ├─ crypto (Noise NK + XCha20) │
|
||||||
└──────────────────────────┘ │ ├─ codec (binary framing) │
|
└──────────────────────────┘ │ ├─ codec (binary framing) │
|
||||||
│ ├─ keepalive (app-level) │
|
│ ├─ keepalive (adaptive state FSM) │
|
||||||
|
│ ├─ telemetry (RTT/jitter/loss) │
|
||||||
|
│ ├─ qos (classify + priority Q) │
|
||||||
|
│ ├─ ratelimit (token bucket) │
|
||||||
|
│ ├─ mtu (overhead calc + ICMP) │
|
||||||
│ ├─ tunnel (TUN device) │
|
│ ├─ tunnel (TUN device) │
|
||||||
│ ├─ network (NAT/IP pool) │
|
│ ├─ network (NAT/IP pool) │
|
||||||
│ └─ reconnect (backoff) │
|
│ └─ reconnect (exp. backoff) │
|
||||||
└───────────────────────────────┘
|
└────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key design decisions:**
|
**Key design decisions:**
|
||||||
@@ -37,8 +39,10 @@ TypeScript (control plane) Rust (data plane)
|
|||||||
|----------|--------|-----|
|
|----------|--------|-----|
|
||||||
| Transport | WebSocket over HTTPS | Works through Cloudflare and other terminating proxies |
|
| Transport | WebSocket over HTTPS | Works through Cloudflare and other terminating proxies |
|
||||||
| Encryption | Noise NK + XChaCha20-Poly1305 | Strong forward secrecy, large nonce space (no counter needed) |
|
| Encryption | Noise NK + XChaCha20-Poly1305 | Strong forward secrecy, large nonce space (no counter needed) |
|
||||||
| Keepalive | App-level (not WS pings) | Cloudflare drops WS ping frames; app-level pings survive |
|
| Keepalive | Adaptive app-level pings | Cloudflare drops WS pings; interval adapts to link health (10–60s) |
|
||||||
| IPC | JSON lines over stdio/Unix socket | `stdio` for dev, `socket` for production (daemon stays alive) |
|
| 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 |
|
| Binary protocol | `[type:1B][length:4B][payload:NB]` | Minimal overhead, easy to parse at wire speed |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -48,15 +52,12 @@ TypeScript (control plane) Rust (data plane)
|
|||||||
```typescript
|
```typescript
|
||||||
import { VpnClient } from '@push.rocks/smartvpn';
|
import { VpnClient } from '@push.rocks/smartvpn';
|
||||||
|
|
||||||
// Development: spawn the Rust daemon as a child process
|
|
||||||
const client = new VpnClient({
|
const client = new VpnClient({
|
||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the daemon bridge
|
|
||||||
await client.start();
|
await client.start();
|
||||||
|
|
||||||
// Connect to a VPN server
|
|
||||||
const { assignedIp } = await client.connect({
|
const { assignedIp } = await client.connect({
|
||||||
serverUrl: 'wss://vpn.example.com/tunnel',
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
serverPublicKey: 'BASE64_SERVER_PUBLIC_KEY',
|
serverPublicKey: 'BASE64_SERVER_PUBLIC_KEY',
|
||||||
@@ -67,15 +68,23 @@ const { assignedIp } = await client.connect({
|
|||||||
|
|
||||||
console.log(`Connected! Assigned IP: ${assignedIp}`);
|
console.log(`Connected! Assigned IP: ${assignedIp}`);
|
||||||
|
|
||||||
// Check status
|
// Connection quality (adaptive keepalive + telemetry)
|
||||||
const status = await client.getStatus();
|
const quality = await client.getConnectionQuality();
|
||||||
console.log(status); // { state: 'connected', assignedIp: '10.8.0.2', ... }
|
console.log(quality);
|
||||||
|
// {
|
||||||
|
// srttMs: 42.5, jitterMs: 3.2, minRttMs: 38.0, maxRttMs: 67.0,
|
||||||
|
// lossRatio: 0.0, consecutiveTimeouts: 0,
|
||||||
|
// linkHealth: 'healthy', currentKeepaliveIntervalSecs: 60
|
||||||
|
// }
|
||||||
|
|
||||||
// Get traffic stats
|
// 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();
|
const stats = await client.getStatistics();
|
||||||
console.log(stats); // { bytesSent, bytesReceived, packetsSent, ... }
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
await client.disconnect();
|
await client.disconnect();
|
||||||
client.stop();
|
client.stop();
|
||||||
```
|
```
|
||||||
@@ -89,33 +98,44 @@ const server = new VpnServer({
|
|||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the daemon and the VPN server
|
// Generate a Noise keypair first
|
||||||
|
await server.start();
|
||||||
|
// If you don't have keys yet:
|
||||||
|
const keypair = await server.generateKeypair();
|
||||||
|
|
||||||
|
// Start the VPN listener (or pass config to start() directly)
|
||||||
await server.start({
|
await server.start({
|
||||||
listenAddr: '0.0.0.0:443',
|
listenAddr: '0.0.0.0:443',
|
||||||
privateKey: 'BASE64_PRIVATE_KEY',
|
privateKey: keypair.privateKey,
|
||||||
publicKey: 'BASE64_PUBLIC_KEY',
|
publicKey: keypair.publicKey,
|
||||||
subnet: '10.8.0.0/24',
|
subnet: '10.8.0.0/24',
|
||||||
dns: ['1.1.1.1'],
|
dns: ['1.1.1.1'],
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
enableNat: true,
|
enableNat: true,
|
||||||
|
// Optional: default rate limit for all new clients
|
||||||
|
defaultRateLimitBytesPerSec: 10_000_000, // 10 MB/s
|
||||||
|
defaultBurstBytes: 20_000_000, // 20 MB burst
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate a Noise keypair
|
|
||||||
const keypair = await server.generateKeypair();
|
|
||||||
console.log(keypair); // { publicKey: '...', privateKey: '...' }
|
|
||||||
|
|
||||||
// List connected clients
|
// List connected clients
|
||||||
const clients = await server.listClients();
|
const clients = await server.listClients();
|
||||||
// [{ clientId, assignedIp, connectedSince, bytesSent, bytesReceived }]
|
|
||||||
|
|
||||||
// Disconnect a specific client
|
// Per-client rate limiting (live, no reconnect needed)
|
||||||
await server.disconnectClient('some-client-id');
|
await server.setClientRateLimit('client-id', 5_000_000, 10_000_000);
|
||||||
|
await server.removeClientRateLimit('client-id'); // unlimited
|
||||||
|
|
||||||
// Get server stats
|
// Per-client telemetry
|
||||||
const stats = await server.getStatistics();
|
const telemetry = await server.getClientTelemetry('client-id');
|
||||||
// { bytesSent, bytesReceived, activeClients, totalConnections, ... }
|
console.log(telemetry);
|
||||||
|
// {
|
||||||
|
// clientId, assignedIp, lastKeepaliveAt, keepalivesReceived,
|
||||||
|
// packetsDropped, bytesDropped, bytesReceived, bytesSent,
|
||||||
|
// rateLimitBytesPerSec, burstBytes
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Kick a client
|
||||||
|
await server.disconnectClient('client-id');
|
||||||
|
|
||||||
// Stop
|
|
||||||
await server.stopServer();
|
await server.stopServer();
|
||||||
server.stop();
|
server.stop();
|
||||||
```
|
```
|
||||||
@@ -151,7 +171,9 @@ When using socket transport, `client.stop()` closes the socket but **does not ki
|
|||||||
| `connect(config?)` | `Promise<{ assignedIp }>` | Connect to VPN server |
|
| `connect(config?)` | `Promise<{ assignedIp }>` | Connect to VPN server |
|
||||||
| `disconnect()` | `Promise<void>` | Disconnect from VPN |
|
| `disconnect()` | `Promise<void>` | Disconnect from VPN |
|
||||||
| `getStatus()` | `Promise<IVpnStatus>` | Current connection state |
|
| `getStatus()` | `Promise<IVpnStatus>` | Current connection state |
|
||||||
| `getStatistics()` | `Promise<IVpnStatistics>` | Traffic statistics |
|
| `getStatistics()` | `Promise<IVpnStatistics>` | Traffic stats + connection quality |
|
||||||
|
| `getConnectionQuality()` | `Promise<IVpnConnectionQuality>` | RTT, jitter, loss, link health |
|
||||||
|
| `getMtuInfo()` | `Promise<IVpnMtuInfo>` | MTU info and overhead breakdown |
|
||||||
| `stop()` | `void` | Kill/close the daemon bridge |
|
| `stop()` | `void` | Kill/close the daemon bridge |
|
||||||
| `running` | `boolean` | Whether bridge is active |
|
| `running` | `boolean` | Whether bridge is active |
|
||||||
|
|
||||||
@@ -163,9 +185,12 @@ When using socket transport, `client.stop()` closes the socket but **does not ki
|
|||||||
| `stopServer()` | `Promise<void>` | Stop the VPN server |
|
| `stopServer()` | `Promise<void>` | Stop the VPN server |
|
||||||
| `getStatus()` | `Promise<IVpnStatus>` | Server connection state |
|
| `getStatus()` | `Promise<IVpnStatus>` | Server connection state |
|
||||||
| `getStatistics()` | `Promise<IVpnServerStatistics>` | Server stats (includes client counts) |
|
| `getStatistics()` | `Promise<IVpnServerStatistics>` | Server stats (includes client counts) |
|
||||||
| `listClients()` | `Promise<IVpnClientInfo[]>` | Connected clients |
|
| `listClients()` | `Promise<IVpnClientInfo[]>` | Connected clients with QoS stats |
|
||||||
| `disconnectClient(id)` | `Promise<void>` | Kick a client |
|
| `disconnectClient(id)` | `Promise<void>` | Kick a client |
|
||||||
| `generateKeypair()` | `Promise<IVpnKeypair>` | Generate Noise NK keypair |
|
| `generateKeypair()` | `Promise<IVpnKeypair>` | Generate Noise NK keypair |
|
||||||
|
| `setClientRateLimit(id, rate, burst)` | `Promise<void>` | Set per-client rate limit (bytes/sec) |
|
||||||
|
| `removeClientRateLimit(id)` | `Promise<void>` | Remove rate limit (unlimited) |
|
||||||
|
| `getClientTelemetry(id)` | `Promise<IVpnClientTelemetry>` | Per-client telemetry + drop stats |
|
||||||
| `stop()` | `void` | Kill/close the daemon bridge |
|
| `stop()` | `void` | Kill/close the daemon bridge |
|
||||||
|
|
||||||
### `VpnConfig`
|
### `VpnConfig`
|
||||||
@@ -191,26 +216,23 @@ Generate system service units for the daemon:
|
|||||||
```typescript
|
```typescript
|
||||||
import { VpnInstaller } from '@push.rocks/smartvpn';
|
import { VpnInstaller } from '@push.rocks/smartvpn';
|
||||||
|
|
||||||
// Auto-detect platform
|
|
||||||
const platform = VpnInstaller.detectPlatform(); // 'linux' | 'macos' | 'windows' | 'unknown'
|
const platform = VpnInstaller.detectPlatform(); // 'linux' | 'macos' | 'windows' | 'unknown'
|
||||||
|
|
||||||
// Generate systemd unit (Linux)
|
// Linux (systemd)
|
||||||
const unit = VpnInstaller.generateSystemdUnit({
|
const unit = VpnInstaller.generateSystemdUnit({
|
||||||
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
||||||
socketPath: '/var/run/smartvpn.sock',
|
socketPath: '/var/run/smartvpn.sock',
|
||||||
mode: 'server',
|
mode: 'server',
|
||||||
});
|
});
|
||||||
// unit.content = full systemd .service file
|
|
||||||
// unit.installPath = '/etc/systemd/system/smartvpn-server.service'
|
|
||||||
|
|
||||||
// Generate launchd plist (macOS)
|
// macOS (launchd)
|
||||||
const plist = VpnInstaller.generateLaunchdPlist({
|
const plist = VpnInstaller.generateLaunchdPlist({
|
||||||
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
||||||
socketPath: '/var/run/smartvpn.sock',
|
socketPath: '/var/run/smartvpn.sock',
|
||||||
mode: 'client',
|
mode: 'client',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect and generate
|
// Auto-detect platform
|
||||||
const serviceUnit = VpnInstaller.generateServiceUnit({
|
const serviceUnit = VpnInstaller.generateServiceUnit({
|
||||||
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
||||||
socketPath: '/var/run/smartvpn.sock',
|
socketPath: '/var/run/smartvpn.sock',
|
||||||
@@ -223,8 +245,6 @@ const serviceUnit = VpnInstaller.generateServiceUnit({
|
|||||||
Both `VpnClient` and `VpnServer` extend `EventEmitter`:
|
Both `VpnClient` and `VpnServer` extend `EventEmitter`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
client.on('status', (status) => { /* IVpnStatus */ });
|
|
||||||
client.on('error', (err) => { /* { message, code? } */ });
|
|
||||||
client.on('exit', ({ code, signal }) => { /* daemon exited */ });
|
client.on('exit', ({ code, signal }) => { /* daemon exited */ });
|
||||||
client.on('reconnected', () => { /* socket reconnected */ });
|
client.on('reconnected', () => { /* socket reconnected */ });
|
||||||
|
|
||||||
@@ -232,13 +252,84 @@ server.on('client-connected', (info) => { /* IVpnClientInfo */ });
|
|||||||
server.on('client-disconnected', ({ clientId, reason }) => { /* ... */ });
|
server.on('client-disconnected', ({ clientId, reason }) => { /* ... */ });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📊 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
|
## 🔐 Security Model
|
||||||
|
|
||||||
The VPN uses a **Noise NK** handshake pattern:
|
The VPN uses a **Noise NK** handshake pattern:
|
||||||
|
|
||||||
1. **NK** = client does **N**ot authenticate, but **K**nows the server's static public key
|
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` (Diffie-Hellman with server's static key)
|
2. The client generates an ephemeral keypair, performs `e, es` (DH with server's static key)
|
||||||
3. Server responds with `e, ee` (Diffie-Hellman with both ephemeral keys)
|
3. Server responds with `e, ee` (DH with both ephemeral keys)
|
||||||
4. Result: forward-secret transport keys derived from both DH operations
|
4. Result: forward-secret transport keys derived from both DH operations
|
||||||
|
|
||||||
Post-handshake, all IP packets are encrypted with **XChaCha20-Poly1305**:
|
Post-handshake, all IP packets are encrypted with **XChaCha20-Poly1305**:
|
||||||
@@ -261,8 +352,8 @@ Inside the WebSocket tunnel, packets use a simple binary framing:
|
|||||||
| `HandshakeInit` | `0x01` | Client → Server handshake |
|
| `HandshakeInit` | `0x01` | Client → Server handshake |
|
||||||
| `HandshakeResp` | `0x02` | Server → Client handshake |
|
| `HandshakeResp` | `0x02` | Server → Client handshake |
|
||||||
| `IpPacket` | `0x10` | Encrypted IP packet |
|
| `IpPacket` | `0x10` | Encrypted IP packet |
|
||||||
| `Keepalive` | `0x20` | App-level ping |
|
| `Keepalive` | `0x20` | App-level ping (8-byte timestamp payload) |
|
||||||
| `KeepaliveAck` | `0x21` | App-level pong |
|
| `KeepaliveAck` | `0x21` | App-level pong (echoes timestamp for RTT) |
|
||||||
| `SessionResume` | `0x30` | Resume a dropped session |
|
| `SessionResume` | `0x30` | Resume a dropped session |
|
||||||
| `SessionResumeOk` | `0x31` | Resume accepted |
|
| `SessionResumeOk` | `0x31` | Resume accepted |
|
||||||
| `SessionResumeErr` | `0x32` | Resume rejected |
|
| `SessionResumeErr` | `0x32` | Resume rejected |
|
||||||
@@ -270,8 +361,6 @@ Inside the WebSocket tunnel, packets use a simple binary framing:
|
|||||||
|
|
||||||
## 🛠️ Rust Daemon CLI
|
## 🛠️ Rust Daemon CLI
|
||||||
|
|
||||||
The Rust binary supports several modes:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development: stdio management (JSON lines on stdin/stdout)
|
# Development: stdio management (JSON lines on stdin/stdout)
|
||||||
smartvpn_daemon --management --mode client
|
smartvpn_daemon --management --mode client
|
||||||
@@ -290,16 +379,14 @@ smartvpn_daemon --generate-keypair
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Build TypeScript + cross-compile Rust
|
# Build TypeScript + cross-compile Rust (amd64 + arm64)
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Build Rust only (debug)
|
# Build Rust only (debug)
|
||||||
cd rust && cargo build
|
cd rust && cargo build
|
||||||
|
|
||||||
# Run Rust tests
|
# Run all tests (71 Rust + 32 TypeScript)
|
||||||
cd rust && cargo test
|
cd rust && cargo test
|
||||||
|
|
||||||
# Run TypeScript tests
|
|
||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -323,25 +410,27 @@ type TVpnTransportOptions =
|
|||||||
|
|
||||||
// Client config
|
// Client config
|
||||||
interface IVpnClientConfig {
|
interface IVpnClientConfig {
|
||||||
serverUrl: string; // e.g. 'wss://vpn.example.com/tunnel'
|
serverUrl: string;
|
||||||
serverPublicKey: string; // base64-encoded Noise static key
|
serverPublicKey: string;
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
mtu?: number; // default: 1420
|
mtu?: number;
|
||||||
keepaliveIntervalSecs?: number; // default: 30
|
keepaliveIntervalSecs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server config
|
// Server config
|
||||||
interface IVpnServerConfig {
|
interface IVpnServerConfig {
|
||||||
listenAddr: string; // e.g. '0.0.0.0:443'
|
listenAddr: string;
|
||||||
privateKey: string; // base64 Noise static private key
|
privateKey: string;
|
||||||
publicKey: string; // base64 Noise static public key
|
publicKey: string;
|
||||||
subnet: string; // e.g. '10.8.0.0/24'
|
subnet: string;
|
||||||
tlsCert?: string;
|
tlsCert?: string;
|
||||||
tlsKey?: string;
|
tlsKey?: string;
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
mtu?: number;
|
mtu?: number;
|
||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
enableNat?: boolean;
|
enableNat?: boolean;
|
||||||
|
defaultRateLimitBytesPerSec?: number;
|
||||||
|
defaultBurstBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
@@ -365,6 +454,7 @@ interface IVpnStatistics {
|
|||||||
keepalivesSent: number;
|
keepalivesSent: number;
|
||||||
keepalivesReceived: number;
|
keepalivesReceived: number;
|
||||||
uptimeSeconds: number;
|
uptimeSeconds: number;
|
||||||
|
quality?: IVpnConnectionQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IVpnServerStatistics extends IVpnStatistics {
|
interface IVpnServerStatistics extends IVpnStatistics {
|
||||||
@@ -372,12 +462,57 @@ interface IVpnServerStatistics extends IVpnStatistics {
|
|||||||
totalConnections: 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 {
|
interface IVpnClientInfo {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
assignedIp: string;
|
assignedIp: string;
|
||||||
connectedSince: string;
|
connectedSince: string;
|
||||||
bytesSent: number;
|
bytesSent: number;
|
||||||
bytesReceived: 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 {
|
interface IVpnKeypair {
|
||||||
|
|||||||
@@ -167,8 +167,15 @@ impl VpnClient {
|
|||||||
|
|
||||||
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
||||||
|
|
||||||
// Create adaptive keepalive monitor
|
// Create adaptive keepalive monitor (use custom interval if configured)
|
||||||
let (monitor, handle) = keepalive::create_keepalive(None);
|
let ka_config = config.keepalive_interval_secs.map(|secs| {
|
||||||
|
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
|
||||||
|
cfg.degraded_interval = std::time::Duration::from_secs(secs);
|
||||||
|
cfg.healthy_interval = std::time::Duration::from_secs(secs * 2);
|
||||||
|
cfg.critical_interval = std::time::Duration::from_secs((secs / 3).max(1));
|
||||||
|
cfg
|
||||||
|
});
|
||||||
|
let (monitor, handle) = keepalive::create_keepalive(ka_config);
|
||||||
self.quality_rx = Some(handle.quality_rx);
|
self.quality_rx = Some(handle.quality_rx);
|
||||||
|
|
||||||
// Spawn the keepalive monitor
|
// Spawn the keepalive monitor
|
||||||
|
|||||||
271
test/test.flowcontrol.node.ts
Normal file
271
test/test.flowcontrol.node.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function findFreePort(): Promise<number> {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
fn: () => Promise<boolean>,
|
||||||
|
timeoutMs: number = 10000,
|
||||||
|
pollMs: number = 500,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await fn()) return;
|
||||||
|
await delay(pollMs);
|
||||||
|
}
|
||||||
|
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let server: VpnServer;
|
||||||
|
let serverPort: number;
|
||||||
|
let keypair: IVpnKeypair;
|
||||||
|
let client: VpnClient;
|
||||||
|
const extraClients: VpnClient[] = [];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tap.test('setup: start VPN server', async () => {
|
||||||
|
serverPort = await findFreePort();
|
||||||
|
|
||||||
|
const options: IVpnServerOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
server = new VpnServer(options);
|
||||||
|
|
||||||
|
// Phase 1: start the daemon bridge
|
||||||
|
const started = await server['bridge'].start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
expect(server.running).toBeTrue();
|
||||||
|
|
||||||
|
// Phase 2: generate a keypair
|
||||||
|
keypair = await server.generateKeypair();
|
||||||
|
expect(keypair.publicKey).toBeTypeofString();
|
||||||
|
expect(keypair.privateKey).toBeTypeofString();
|
||||||
|
|
||||||
|
// Phase 3: start the VPN listener
|
||||||
|
const serverConfig: IVpnServerConfig = {
|
||||||
|
listenAddr: `127.0.0.1:${serverPort}`,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
publicKey: keypair.publicKey,
|
||||||
|
subnet: '10.8.0.0/24',
|
||||||
|
};
|
||||||
|
await server['bridge'].sendCommand('start', { config: serverConfig });
|
||||||
|
|
||||||
|
// Verify server is now running
|
||||||
|
const status = await server.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('single client connects and gets IP', async () => {
|
||||||
|
const options: IVpnClientOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
client = new VpnClient(options);
|
||||||
|
const started = await client.start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
const result = await client.connect({
|
||||||
|
serverUrl: `ws://127.0.0.1:${serverPort}`,
|
||||||
|
serverPublicKey: keypair.publicKey,
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignedIp).toBeTypeofString();
|
||||||
|
expect(result.assignedIp).toStartWith('10.8.0.');
|
||||||
|
|
||||||
|
// Verify client status
|
||||||
|
const clientStatus = await client.getStatus();
|
||||||
|
expect(clientStatus.state).toEqual('connected');
|
||||||
|
|
||||||
|
// Verify server sees the client
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
return clients.length === 1;
|
||||||
|
});
|
||||||
|
const clients = await server.listClients();
|
||||||
|
expect(clients.length).toEqual(1);
|
||||||
|
expect(clients[0].assignedIp).toEqual(result.assignedIp);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('keepalive exchange', async () => {
|
||||||
|
// Wait for at least 2 keepalive cycles (interval=3s, so 8s should be enough)
|
||||||
|
await delay(8000);
|
||||||
|
|
||||||
|
const clientStats = await client.getStatistics();
|
||||||
|
expect(clientStats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(clientStats.keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const serverStats = await server.getStatistics();
|
||||||
|
expect(serverStats.keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(serverStats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Verify per-client keepalive tracking
|
||||||
|
const clients = await server.listClients();
|
||||||
|
expect(clients[0].keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('connection quality telemetry', async () => {
|
||||||
|
const quality = await client.getConnectionQuality();
|
||||||
|
|
||||||
|
expect(quality.srttMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(quality.jitterMs).toBeTypeofNumber();
|
||||||
|
expect(quality.minRttMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(quality.maxRttMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(quality.lossRatio).toBeTypeofNumber();
|
||||||
|
expect(['healthy', 'degraded', 'critical']).toContain(quality.linkHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rate limiting: set and verify', async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
const clientId = clients[0].clientId;
|
||||||
|
|
||||||
|
// Set a tight rate limit
|
||||||
|
await server.setClientRateLimit(clientId, 100, 100);
|
||||||
|
|
||||||
|
// Verify via telemetry
|
||||||
|
const telemetry = await server.getClientTelemetry(clientId);
|
||||||
|
expect(telemetry.rateLimitBytesPerSec).toEqual(100);
|
||||||
|
expect(telemetry.burstBytes).toEqual(100);
|
||||||
|
expect(telemetry.clientId).toEqual(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rate limiting: removal', async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
const clientId = clients[0].clientId;
|
||||||
|
|
||||||
|
await server.removeClientRateLimit(clientId);
|
||||||
|
|
||||||
|
// Verify telemetry no longer shows rate limit
|
||||||
|
const telemetry = await server.getClientTelemetry(clientId);
|
||||||
|
expect(telemetry.rateLimitBytesPerSec).toBeNullOrUndefined();
|
||||||
|
expect(telemetry.burstBytes).toBeNullOrUndefined();
|
||||||
|
|
||||||
|
// Connection still healthy
|
||||||
|
const status = await client.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('5 concurrent clients', async () => {
|
||||||
|
const assignedIps = new Set<string>();
|
||||||
|
|
||||||
|
// Get the first client's IP
|
||||||
|
const existingClients = await server.listClients();
|
||||||
|
assignedIps.add(existingClients[0].assignedIp);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
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,
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
expect(result.assignedIp).toStartWith('10.8.0.');
|
||||||
|
assignedIps.add(result.assignedIp);
|
||||||
|
extraClients.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All IPs should be unique (6 total: original + 5 new)
|
||||||
|
expect(assignedIps.size).toEqual(6);
|
||||||
|
|
||||||
|
// Server should see 6 clients
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
return clients.length === 6;
|
||||||
|
});
|
||||||
|
const allClients = await server.listClients();
|
||||||
|
expect(allClients.length).toEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('client disconnect tracking', async () => {
|
||||||
|
// Disconnect 3 of the 5 extra clients
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const c = extraClients[i];
|
||||||
|
await c.disconnect();
|
||||||
|
c.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for server to detect disconnections
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
return clients.length === 3;
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
const clients = await server.listClients();
|
||||||
|
expect(clients.length).toEqual(3);
|
||||||
|
|
||||||
|
const stats = await server.getStatistics();
|
||||||
|
expect(stats.totalConnections).toBeGreaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('server-side client disconnection', async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
// Pick one of the remaining extra clients (not the original)
|
||||||
|
const targetClient = clients.find((c) => {
|
||||||
|
// Find a client that belongs to extraClients[3] or extraClients[4]
|
||||||
|
return c.clientId !== clients[0].clientId;
|
||||||
|
});
|
||||||
|
expect(targetClient).toBeTruthy();
|
||||||
|
|
||||||
|
await server.disconnectClient(targetClient!.clientId);
|
||||||
|
|
||||||
|
// Wait for server to update
|
||||||
|
await waitFor(async () => {
|
||||||
|
const remaining = await server.listClients();
|
||||||
|
return remaining.length === 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await server.listClients();
|
||||||
|
expect(remaining.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('teardown: stop all', async () => {
|
||||||
|
// Stop the original client
|
||||||
|
await client.disconnect();
|
||||||
|
client.stop();
|
||||||
|
|
||||||
|
// Stop remaining extra clients
|
||||||
|
for (const c of extraClients) {
|
||||||
|
if (c.running) {
|
||||||
|
try {
|
||||||
|
await c.disconnect();
|
||||||
|
} catch {
|
||||||
|
// May already be disconnected
|
||||||
|
}
|
||||||
|
c.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
// Stop the server
|
||||||
|
await server.stopServer();
|
||||||
|
server.stop();
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
expect(server.running).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
357
test/test.loadtest.node.ts
Normal file
357
test/test.loadtest.node.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as stream from 'stream';
|
||||||
|
import { VpnClient, VpnServer } from '../ts/index.js';
|
||||||
|
import type { IVpnKeypair, IVpnServerConfig } from '../ts/index.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function findFreePort(): Promise<number> {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
fn: () => Promise<boolean>,
|
||||||
|
timeoutMs: number = 10000,
|
||||||
|
pollMs: number = 500,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await fn()) return;
|
||||||
|
await delay(pollMs);
|
||||||
|
}
|
||||||
|
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ThrottleProxy (adapted from remoteingress)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ThrottleTransform extends stream.Transform {
|
||||||
|
private bytesPerSec: number;
|
||||||
|
private bucket: number;
|
||||||
|
private lastRefill: number;
|
||||||
|
private destroyed_: boolean = false;
|
||||||
|
|
||||||
|
constructor(bytesPerSecond: number) {
|
||||||
|
super();
|
||||||
|
this.bytesPerSec = bytesPerSecond;
|
||||||
|
this.bucket = bytesPerSecond;
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: stream.TransformCallback) {
|
||||||
|
if (this.destroyed_) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - this.lastRefill) / 1000;
|
||||||
|
this.bucket = Math.min(this.bytesPerSec, this.bucket + elapsed * this.bytesPerSec);
|
||||||
|
this.lastRefill = now;
|
||||||
|
|
||||||
|
if (chunk.length <= this.bucket) {
|
||||||
|
this.bucket -= chunk.length;
|
||||||
|
callback(null, chunk);
|
||||||
|
} else {
|
||||||
|
const deficit = chunk.length - this.bucket;
|
||||||
|
this.bucket = 0;
|
||||||
|
const delayMs = Math.min((deficit / this.bytesPerSec) * 1000, 1000);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.destroyed_) { callback(); return; }
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
this.bucket = 0;
|
||||||
|
callback(null, chunk);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroy(err: Error | null, callback: (error: Error | null) => void) {
|
||||||
|
this.destroyed_ = true;
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThrottleProxy {
|
||||||
|
server: net.Server;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startThrottleProxy(
|
||||||
|
listenPort: number,
|
||||||
|
targetHost: string,
|
||||||
|
targetPort: number,
|
||||||
|
bytesPerSecond: number,
|
||||||
|
): Promise<ThrottleProxy> {
|
||||||
|
const connections = new Set<net.Socket>();
|
||||||
|
const server = net.createServer((clientSock) => {
|
||||||
|
connections.add(clientSock);
|
||||||
|
const upstream = net.createConnection({ host: targetHost, port: targetPort });
|
||||||
|
connections.add(upstream);
|
||||||
|
|
||||||
|
const throttleUp = new ThrottleTransform(bytesPerSecond);
|
||||||
|
const throttleDown = new ThrottleTransform(bytesPerSecond);
|
||||||
|
|
||||||
|
clientSock.pipe(throttleUp).pipe(upstream);
|
||||||
|
upstream.pipe(throttleDown).pipe(clientSock);
|
||||||
|
|
||||||
|
let cleaned = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
throttleUp.destroy();
|
||||||
|
throttleDown.destroy();
|
||||||
|
clientSock.destroy();
|
||||||
|
upstream.destroy();
|
||||||
|
connections.delete(clientSock);
|
||||||
|
connections.delete(upstream);
|
||||||
|
};
|
||||||
|
clientSock.on('error', () => cleanup());
|
||||||
|
upstream.on('error', () => cleanup());
|
||||||
|
throttleUp.on('error', () => cleanup());
|
||||||
|
throttleDown.on('error', () => cleanup());
|
||||||
|
clientSock.on('close', () => cleanup());
|
||||||
|
upstream.on('close', () => cleanup());
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(listenPort, '127.0.0.1', resolve));
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
close: async () => {
|
||||||
|
for (const c of connections) c.destroy();
|
||||||
|
connections.clear();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let server: VpnServer;
|
||||||
|
let serverPort: number;
|
||||||
|
let proxyPort: number;
|
||||||
|
let keypair: IVpnKeypair;
|
||||||
|
let throttle: ThrottleProxy;
|
||||||
|
const allClients: VpnClient[] = [];
|
||||||
|
|
||||||
|
async function createConnectedClient(port: number): Promise<VpnClient> {
|
||||||
|
const c = new VpnClient({ transport: { transport: 'stdio' } });
|
||||||
|
await c.start();
|
||||||
|
await c.connect({
|
||||||
|
serverUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
serverPublicKey: keypair.publicKey,
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
allClients.push(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopClient(c: VpnClient): Promise<void> {
|
||||||
|
if (c.running) {
|
||||||
|
try { await c.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
c.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tap.test('setup: start throttled VPN tunnel (1 MB/s)', async () => {
|
||||||
|
serverPort = await findFreePort();
|
||||||
|
proxyPort = await findFreePort();
|
||||||
|
|
||||||
|
// Start VPN server
|
||||||
|
server = new VpnServer({ transport: { transport: 'stdio' } });
|
||||||
|
const started = await server['bridge'].start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
keypair = await server.generateKeypair();
|
||||||
|
const serverConfig: IVpnServerConfig = {
|
||||||
|
listenAddr: `127.0.0.1:${serverPort}`,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
publicKey: keypair.publicKey,
|
||||||
|
subnet: '10.8.0.0/24',
|
||||||
|
};
|
||||||
|
await server['bridge'].sendCommand('start', { config: serverConfig });
|
||||||
|
|
||||||
|
const status = await server.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
|
// Start throttle proxy: 1 MB/s
|
||||||
|
throttle = await startThrottleProxy(proxyPort, '127.0.0.1', serverPort, 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('throttled connection: handshake succeeds through throttle', async () => {
|
||||||
|
const client = await createConnectedClient(proxyPort);
|
||||||
|
|
||||||
|
const status = await client.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
expect(status.assignedIp).toStartWith('10.8.0.');
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
return clients.length === 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('sustained keepalive under throttle', async () => {
|
||||||
|
// Wait for at least 2 keepalive cycles (3s interval)
|
||||||
|
await delay(8000);
|
||||||
|
|
||||||
|
const client = allClients[0];
|
||||||
|
const stats = await client.getStatistics();
|
||||||
|
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(stats.keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Throttle adds latency — RTT should be measurable
|
||||||
|
const quality = await client.getConnectionQuality();
|
||||||
|
expect(quality.srttMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(quality.jitterMs).toBeTypeofNumber();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('3 concurrent throttled clients', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await createConnectedClient(proxyPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 4 clients should be visible
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
return clients.length === 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clients = await server.listClients();
|
||||||
|
expect(clients.length).toEqual(4);
|
||||||
|
|
||||||
|
// Verify all IPs are unique
|
||||||
|
const ips = new Set(clients.map((c) => c.assignedIp));
|
||||||
|
expect(ips.size).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rate limiting combined with network throttle', async () => {
|
||||||
|
const clients = await server.listClients();
|
||||||
|
const targetId = clients[0].clientId;
|
||||||
|
|
||||||
|
// Set rate limit on first client
|
||||||
|
await server.setClientRateLimit(targetId, 500, 500);
|
||||||
|
const telemetry = await server.getClientTelemetry(targetId);
|
||||||
|
expect(telemetry.rateLimitBytesPerSec).toEqual(500);
|
||||||
|
expect(telemetry.burstBytes).toEqual(500);
|
||||||
|
|
||||||
|
// Verify another client has no rate limit
|
||||||
|
const otherTelemetry = await server.getClientTelemetry(clients[1].clientId);
|
||||||
|
expect(otherTelemetry.rateLimitBytesPerSec).toBeNullOrUndefined();
|
||||||
|
|
||||||
|
// Clean up the rate limit
|
||||||
|
await server.removeClientRateLimit(targetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('burst waves: 3 waves of 3 clients', async () => {
|
||||||
|
const initialCount = (await server.listClients()).length;
|
||||||
|
|
||||||
|
for (let wave = 0; wave < 3; wave++) {
|
||||||
|
const waveClients: VpnClient[] = [];
|
||||||
|
|
||||||
|
// Connect 3 clients
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const c = await createConnectedClient(proxyPort);
|
||||||
|
waveClients.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all connected
|
||||||
|
await waitFor(async () => {
|
||||||
|
const all = await server.listClients();
|
||||||
|
return all.length === initialCount + 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect all wave clients
|
||||||
|
for (const c of waveClients) {
|
||||||
|
await stopClient(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for server to detect disconnections
|
||||||
|
await waitFor(async () => {
|
||||||
|
const all = await server.listClients();
|
||||||
|
return all.length === initialCount;
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total connections accumulated
|
||||||
|
const stats = await server.getStatistics();
|
||||||
|
expect(stats.totalConnections).toBeGreaterThanOrEqual(9 + initialCount);
|
||||||
|
|
||||||
|
// Original clients still connected
|
||||||
|
const remaining = await server.listClients();
|
||||||
|
expect(remaining.length).toEqual(initialCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('aggressive throttle: 10 KB/s', async () => {
|
||||||
|
// Close current throttle proxy and start an aggressive one
|
||||||
|
await throttle.close();
|
||||||
|
const aggressivePort = await findFreePort();
|
||||||
|
throttle = await startThrottleProxy(aggressivePort, '127.0.0.1', serverPort, 10 * 1024);
|
||||||
|
|
||||||
|
// Connect a client through the aggressive throttle
|
||||||
|
const client = await createConnectedClient(aggressivePort);
|
||||||
|
const status = await client.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
|
// Wait for keepalive exchange (might take longer due to throttle)
|
||||||
|
await delay(10000);
|
||||||
|
|
||||||
|
const stats = await client.getStatistics();
|
||||||
|
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(stats.keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('post-load health: direct connection still works', async () => {
|
||||||
|
// Server should still be healthy after all load tests
|
||||||
|
const serverStatus = await server.getStatus();
|
||||||
|
expect(serverStatus.state).toEqual('connected');
|
||||||
|
|
||||||
|
// Connect one more client directly (no throttle)
|
||||||
|
const directClient = await createConnectedClient(serverPort);
|
||||||
|
const status = await directClient.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
|
await delay(5000);
|
||||||
|
|
||||||
|
const stats = await directClient.getStatistics();
|
||||||
|
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('teardown: stop all', async () => {
|
||||||
|
// Stop all clients
|
||||||
|
for (const c of allClients) {
|
||||||
|
await stopClient(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
// Close throttle proxy
|
||||||
|
if (throttle) {
|
||||||
|
await throttle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop server
|
||||||
|
await server.stopServer();
|
||||||
|
server.stop();
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
expect(server.running).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.1.0',
|
version: '1.3.0',
|
||||||
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user