Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51d33127bf | |||
| a4ba6806e5 | |||
| 6330921160 | |||
| e81dd377d8 | |||
| e14c357ba0 | |||
| eb30825f72 |
21
changelog.md
21
changelog.md
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-21 - 1.4.1 - fix(readme)
|
||||||
|
preserve markdown line breaks in feature list
|
||||||
|
|
||||||
|
- Adds trailing spaces to the README feature list so each highlighted capability renders on its own line.
|
||||||
|
|
||||||
|
## 2026-03-19 - 1.4.0 - feat(vpn transport)
|
||||||
|
add QUIC transport support with auto fallback to WebSocket
|
||||||
|
|
||||||
|
- introduces a transport abstraction in the Rust daemon so client and server can operate over WebSocket or QUIC
|
||||||
|
- adds dual-mode server configuration with websocket, quic, and both transport modes plus QUIC idle timeout and listen address options
|
||||||
|
- adds client transport selection with auto mode that attempts QUIC first and falls back to WebSocket
|
||||||
|
- adds QUIC certificate hash pinning support and required Rust dependencies for QUIC and TLS
|
||||||
|
- updates TypeScript interfaces, config validation, tests, and documentation to cover the new transport modes
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-15 - 1.2.0 - feat(readme)
|
||||||
document QoS, telemetry, MTU, and rate limiting capabilities in the README
|
document QoS, telemetry, MTU, and rate limiting capabilities in the README
|
||||||
|
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.2.0",
|
"version": "1.4.1",
|
||||||
"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",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
||||||
|
"test:before": "(tsrust)",
|
||||||
"test": "tstest test/ --verbose",
|
"test": "tstest test/ --verbose",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
@@ -28,15 +29,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartrust": "^1.3.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpath": "^5.0.18"
|
"@push.rocks/smartrust": "^1.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.2.12",
|
"@git.zone/tsbuild": "^4.3.0",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tstest": "^1.0.96",
|
"@git.zone/tsrust": "^1.3.0",
|
||||||
"@git.zone/tsrust": "^1.0.29",
|
"@git.zone/tstest": "^3.5.0",
|
||||||
"@types/node": "^22.0.0"
|
"@types/node": "^25.5.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
|||||||
2806
pnpm-lock.yaml
generated
2806
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
151
readme.md
151
readme.md
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
🔒 **Noise NK** handshake + **XChaCha20-Poly1305** encryption
|
||||||
|
🚀 **Dual transport**: WebSocket (Cloudflare-friendly) and raw **QUIC** (with datagram support)
|
||||||
|
📊 **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
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## 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.
|
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.
|
||||||
@@ -19,9 +25,11 @@ 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_trait (abstraction) │
|
||||||
│ (smartrust) │ │ ├─ crypto (Noise NK + XCha20) │
|
│ (smartrust) │ │ │ ├─ transport (WebSocket/TLS) │
|
||||||
└──────────────────────────┘ │ ├─ codec (binary framing) │
|
└──────────────────────────┘ │ │ └─ quic_transport (QUIC/UDP) │
|
||||||
|
│ ├─ crypto (Noise NK + XCha20) │
|
||||||
|
│ ├─ codec (binary framing) │
|
||||||
│ ├─ keepalive (adaptive state FSM) │
|
│ ├─ keepalive (adaptive state FSM) │
|
||||||
│ ├─ telemetry (RTT/jitter/loss) │
|
│ ├─ telemetry (RTT/jitter/loss) │
|
||||||
│ ├─ qos (classify + priority Q) │
|
│ ├─ qos (classify + priority Q) │
|
||||||
@@ -37,8 +45,10 @@ TypeScript (control plane) Rust (data plane)
|
|||||||
|
|
||||||
| Decision | Choice | Why |
|
| Decision | Choice | Why |
|
||||||
|----------|--------|-----|
|
|----------|--------|-----|
|
||||||
| Transport | WebSocket over HTTPS | Works through Cloudflare and other terminating proxies |
|
| Transport | WebSocket + QUIC (dual) | WS works through Cloudflare; QUIC gives lower latency + unreliable datagrams |
|
||||||
| Encryption | Noise NK + XChaCha20-Poly1305 | Strong forward secrecy, large nonce space (no counter needed) |
|
| Auto-transport | QUIC first, WS fallback | Best performance when QUIC is available, graceful degradation when it's not |
|
||||||
|
| 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) |
|
| 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 |
|
| 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 |
|
| Rate limiting | Per-client token bucket | Byte-granular, dynamically reconfigurable via IPC |
|
||||||
@@ -89,6 +99,39 @@ await client.disconnect();
|
|||||||
client.stop();
|
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 Server
|
### VPN Server
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -100,10 +143,9 @@ const server = new VpnServer({
|
|||||||
|
|
||||||
// Generate a Noise keypair first
|
// Generate a Noise keypair first
|
||||||
await server.start();
|
await server.start();
|
||||||
// If you don't have keys yet:
|
|
||||||
const keypair = await server.generateKeypair();
|
const keypair = await server.generateKeypair();
|
||||||
|
|
||||||
// Start the VPN listener (or pass config to start() directly)
|
// Start the VPN listener
|
||||||
await server.start({
|
await server.start({
|
||||||
listenAddr: '0.0.0.0:443',
|
listenAddr: '0.0.0.0:443',
|
||||||
privateKey: keypair.privateKey,
|
privateKey: keypair.privateKey,
|
||||||
@@ -112,6 +154,12 @@ await server.start({
|
|||||||
dns: ['1.1.1.1'],
|
dns: ['1.1.1.1'],
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
enableNat: true,
|
enableNat: true,
|
||||||
|
// Transport mode: 'websocket', 'quic', or 'both' (default)
|
||||||
|
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
|
// Optional: default rate limit for all new clients
|
||||||
defaultRateLimitBytesPerSec: 10_000_000, // 10 MB/s
|
defaultRateLimitBytesPerSec: 10_000_000, // 10 MB/s
|
||||||
defaultBurstBytes: 20_000_000, // 20 MB burst
|
defaultBurstBytes: 20_000_000, // 20 MB burst
|
||||||
@@ -247,11 +295,66 @@ Both `VpnClient` and `VpnServer` extend `EventEmitter`:
|
|||||||
```typescript
|
```typescript
|
||||||
client.on('exit', ({ code, signal }) => { /* daemon exited */ });
|
client.on('exit', ({ code, signal }) => { /* daemon exited */ });
|
||||||
client.on('reconnected', () => { /* socket reconnected */ });
|
client.on('reconnected', () => { /* socket reconnected */ });
|
||||||
|
client.on('status', (status) => { /* IVpnStatus update */ });
|
||||||
|
client.on('error', (error) => { /* error from daemon */ });
|
||||||
|
|
||||||
server.on('client-connected', (info) => { /* IVpnClientInfo */ });
|
server.on('client-connected', (info) => { /* IVpnClientInfo */ });
|
||||||
server.on('client-disconnected', ({ clientId, reason }) => { /* ... */ });
|
server.on('client-disconnected', ({ clientId, reason }) => { /* ... */ });
|
||||||
|
server.on('started', () => { /* server listener started */ });
|
||||||
|
server.on('stopped', () => { /* server listener stopped */ });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🌐 Transport Modes
|
||||||
|
|
||||||
|
smartvpn supports two transport protocols through a unified transport abstraction layer. Both use the same encryption, framing, and QoS pipeline — the transport is swappable without changing any application logic.
|
||||||
|
|
||||||
|
### WebSocket (default)
|
||||||
|
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
### Auto-Transport (Recommended)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.connect({
|
||||||
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
|
serverPublicKey: '...',
|
||||||
|
transport: 'auto', // default — QUIC first, WS fallback
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Dual-Mode
|
||||||
|
|
||||||
|
The server can listen on both transports simultaneously:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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' (default)
|
||||||
|
quicIdleTimeoutSecs: 30, // QUIC connection idle timeout
|
||||||
|
// ... other config
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
When using `'both'` mode, the server logs the QUIC certificate hash on startup — share this with clients for cert pinning.
|
||||||
|
|
||||||
## 📊 QoS System
|
## 📊 QoS System
|
||||||
|
|
||||||
The Rust daemon includes a full QoS stack that operates on decrypted IP packets:
|
The Rust daemon includes a full QoS stack that operates on decrypted IP packets:
|
||||||
@@ -337,9 +440,26 @@ Post-handshake, all IP packets are encrypted with **XChaCha20-Poly1305**:
|
|||||||
- 16-byte authentication tags
|
- 16-byte authentication tags
|
||||||
- Wire format: `[nonce:24B][ciphertext:var][tag:16B]`
|
- Wire format: `[nonce:24B][ciphertext:var][tag:16B]`
|
||||||
|
|
||||||
|
### 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: <BASE64_HASH>"
|
||||||
|
|
||||||
|
// Client pins it:
|
||||||
|
await client.connect({
|
||||||
|
serverUrl: 'vpn.example.com:443',
|
||||||
|
transport: 'quic',
|
||||||
|
serverCertHash: '<BASE64_HASH>',
|
||||||
|
serverPublicKey: '...',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## 📦 Binary Protocol
|
## 📦 Binary Protocol
|
||||||
|
|
||||||
Inside the WebSocket tunnel, packets use a simple binary framing:
|
Inside the tunnel (both WebSocket and QUIC reliable channels), packets use a simple binary framing:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────┬──────────┬────────────────────┐
|
┌──────────┬──────────┬────────────────────┐
|
||||||
@@ -359,6 +479,8 @@ Inside the WebSocket tunnel, packets use a simple binary framing:
|
|||||||
| `SessionResumeErr` | `0x32` | Resume rejected |
|
| `SessionResumeErr` | `0x32` | Resume rejected |
|
||||||
| `Disconnect` | `0x3F` | Graceful disconnect |
|
| `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.
|
||||||
|
|
||||||
## 🛠️ Rust Daemon CLI
|
## 🛠️ Rust Daemon CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -385,12 +507,12 @@ pnpm build
|
|||||||
# Build Rust only (debug)
|
# Build Rust only (debug)
|
||||||
cd rust && cargo build
|
cd rust && cargo build
|
||||||
|
|
||||||
# Run all tests (71 Rust + 32 TypeScript)
|
# Run all tests (77 Rust + 59 TypeScript)
|
||||||
cd rust && cargo test
|
cd rust && cargo test
|
||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
## TypeScript Interfaces
|
## 📘 TypeScript Interfaces
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Click to expand full type definitions</summary>
|
<summary>Click to expand full type definitions</summary>
|
||||||
@@ -410,8 +532,10 @@ type TVpnTransportOptions =
|
|||||||
|
|
||||||
// Client config
|
// Client config
|
||||||
interface IVpnClientConfig {
|
interface IVpnClientConfig {
|
||||||
serverUrl: string;
|
serverUrl: string; // WS: 'wss://host/path' | QUIC: 'host:port'
|
||||||
serverPublicKey: string;
|
serverPublicKey: string; // Base64-encoded Noise static key
|
||||||
|
transport?: 'auto' | 'websocket' | 'quic'; // Default: 'auto'
|
||||||
|
serverCertHash?: string; // SHA-256 cert hash (base64) for QUIC pinning
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
mtu?: number;
|
mtu?: number;
|
||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
@@ -429,6 +553,9 @@ interface IVpnServerConfig {
|
|||||||
mtu?: number;
|
mtu?: number;
|
||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
enableNat?: boolean;
|
enableNat?: boolean;
|
||||||
|
transportMode?: 'websocket' | 'quic' | 'both'; // Default: 'both'
|
||||||
|
quicListenAddr?: string; // Separate QUIC bind address
|
||||||
|
quicIdleTimeoutSecs?: number; // QUIC idle timeout (default: 30)
|
||||||
defaultRateLimitBytesPerSec?: number;
|
defaultRateLimitBytesPerSec?: number;
|
||||||
defaultBurstBytes?: number;
|
defaultBurstBytes?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
556
rust/Cargo.lock
generated
556
rust/Cargo.lock
generated
@@ -120,6 +120,17 @@ version = "4.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -169,6 +180,12 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -205,6 +222,12 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -298,6 +321,16 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -307,6 +340,22 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -374,6 +423,15 @@ version = "2.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -416,6 +474,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastbloom"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libm",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -549,8 +619,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -560,9 +632,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -624,6 +698,38 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||||
|
dependencies = [
|
||||||
|
"cesu8",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-sys",
|
||||||
|
"log",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.45.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.91"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -646,6 +752,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libmimalloc-sys"
|
name = "libmimalloc-sys"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -671,6 +783,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -727,6 +845,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -745,6 +869,12 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -774,6 +904,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -814,6 +954,12 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -832,6 +978,63 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"fastbloom",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -906,6 +1109,19 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -946,6 +1162,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -962,21 +1184,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.9"
|
||||||
@@ -988,12 +1260,59 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -1090,6 +1409,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1107,23 +1432,31 @@ name = "smartvpn_daemon"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
|
"quinn",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"rcgen",
|
||||||
|
"ring",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"snow",
|
"snow",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tun",
|
"tun",
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1175,13 +1508,33 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1204,6 +1557,40 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.49.0"
|
||||||
@@ -1277,6 +1664,7 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@@ -1346,7 +1734,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nix",
|
"nix",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@@ -1368,7 +1756,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1424,6 +1812,16 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -1439,6 +1837,70 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
@@ -1457,12 +1919,30 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -1499,6 +1979,21 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.42.2",
|
||||||
|
"windows_aarch64_msvc 0.42.2",
|
||||||
|
"windows_i686_gnu 0.42.2",
|
||||||
|
"windows_i686_msvc 0.42.2",
|
||||||
|
"windows_x86_64_gnu 0.42.2",
|
||||||
|
"windows_x86_64_gnullvm 0.42.2",
|
||||||
|
"windows_x86_64_msvc 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1532,6 +2027,12 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1544,6 +2045,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1556,6 +2063,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1580,6 +2093,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1592,6 +2111,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1604,6 +2129,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1616,6 +2147,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -1649,7 +2186,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -1660,6 +2197,15 @@ version = "0.51.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.39"
|
version = "0.8.39"
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ tun = { version = "0.7", features = ["async"] }
|
|||||||
bytes = "1"
|
bytes = "1"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
quinn = "0.11"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
rcgen = "0.13"
|
||||||
|
ring = "0.17"
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
webpki-roots = "1"
|
||||||
mimalloc = "0.1"
|
mimalloc = "0.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
use tracing::{info, error, warn, debug};
|
use tracing::{info, error, warn, debug};
|
||||||
|
|
||||||
use crate::codec::{Frame, FrameCodec, PacketType};
|
use crate::codec::{Frame, FrameCodec, PacketType};
|
||||||
@@ -12,6 +10,8 @@ use crate::crypto;
|
|||||||
use crate::keepalive::{self, KeepaliveSignal, LinkHealth};
|
use crate::keepalive::{self, KeepaliveSignal, LinkHealth};
|
||||||
use crate::telemetry::ConnectionQuality;
|
use crate::telemetry::ConnectionQuality;
|
||||||
use crate::transport;
|
use crate::transport;
|
||||||
|
use crate::transport_trait::{self, TransportSink, TransportStream};
|
||||||
|
use crate::quic_transport;
|
||||||
|
|
||||||
/// Client configuration (matches TS IVpnClientConfig).
|
/// Client configuration (matches TS IVpnClientConfig).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -22,6 +22,10 @@ pub struct ClientConfig {
|
|||||||
pub dns: Option<Vec<String>>,
|
pub dns: Option<Vec<String>>,
|
||||||
pub mtu: Option<u16>,
|
pub mtu: Option<u16>,
|
||||||
pub keepalive_interval_secs: Option<u64>,
|
pub keepalive_interval_secs: Option<u64>,
|
||||||
|
/// Transport type: "websocket" (default) or "quic".
|
||||||
|
pub transport: Option<String>,
|
||||||
|
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
|
||||||
|
pub server_cert_hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client statistics.
|
/// Client statistics.
|
||||||
@@ -106,9 +110,66 @@ impl VpnClient {
|
|||||||
&config.server_public_key,
|
&config.server_public_key,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Connect to WebSocket server
|
// Create transport based on configuration
|
||||||
|
let (mut sink, mut stream): (Box<dyn TransportSink>, Box<dyn TransportStream>) = {
|
||||||
|
let transport_type = config.transport.as_deref().unwrap_or("auto");
|
||||||
|
match transport_type {
|
||||||
|
"quic" => {
|
||||||
|
let server_addr = &config.server_url; // For QUIC, serverUrl is host:port
|
||||||
|
let cert_hash = config.server_cert_hash.as_deref();
|
||||||
|
let conn = quic_transport::connect_quic(server_addr, cert_hash).await?;
|
||||||
|
let (quic_sink, quic_stream) = quic_transport::open_quic_streams(conn).await?;
|
||||||
|
info!("Connected via QUIC");
|
||||||
|
(Box::new(quic_sink) as Box<dyn TransportSink>,
|
||||||
|
Box::new(quic_stream) as Box<dyn TransportStream>)
|
||||||
|
}
|
||||||
|
"websocket" => {
|
||||||
let ws = transport::connect_to_server(&config.server_url).await?;
|
let ws = transport::connect_to_server(&config.server_url).await?;
|
||||||
let (mut ws_sink, mut ws_stream) = ws.split();
|
let (ws_sink, ws_stream) = transport_trait::split_ws(ws);
|
||||||
|
info!("Connected via WebSocket");
|
||||||
|
(Box::new(ws_sink), Box::new(ws_stream))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// "auto" (default): try QUIC first, fall back to WebSocket
|
||||||
|
// Extract host:port from the URL for QUIC attempt
|
||||||
|
let quic_addr = extract_host_port(&config.server_url);
|
||||||
|
let cert_hash = config.server_cert_hash.as_deref();
|
||||||
|
|
||||||
|
if let Some(ref addr) = quic_addr {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
try_quic_connect(addr, cert_hash),
|
||||||
|
).await {
|
||||||
|
Ok(Ok((quic_sink, quic_stream))) => {
|
||||||
|
info!("Auto: connected via QUIC to {}", addr);
|
||||||
|
(Box::new(quic_sink) as Box<dyn TransportSink>,
|
||||||
|
Box::new(quic_stream) as Box<dyn TransportStream>)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!("Auto: QUIC failed ({}), falling back to WebSocket", e);
|
||||||
|
let ws = transport::connect_to_server(&config.server_url).await?;
|
||||||
|
let (ws_sink, ws_stream) = transport_trait::split_ws(ws);
|
||||||
|
info!("Auto: connected via WebSocket (QUIC unavailable)");
|
||||||
|
(Box::new(ws_sink), Box::new(ws_stream))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!("Auto: QUIC timed out, falling back to WebSocket");
|
||||||
|
let ws = transport::connect_to_server(&config.server_url).await?;
|
||||||
|
let (ws_sink, ws_stream) = transport_trait::split_ws(ws);
|
||||||
|
info!("Auto: connected via WebSocket (QUIC timed out)");
|
||||||
|
(Box::new(ws_sink), Box::new(ws_stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Can't extract host:port for QUIC, use WebSocket directly
|
||||||
|
let ws = transport::connect_to_server(&config.server_url).await?;
|
||||||
|
let (ws_sink, ws_stream) = transport_trait::split_ws(ws);
|
||||||
|
info!("Connected via WebSocket");
|
||||||
|
(Box::new(ws_sink), Box::new(ws_stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Noise NK handshake (client side = initiator)
|
// Noise NK handshake (client side = initiator)
|
||||||
*state.write().await = ClientState::Handshaking;
|
*state.write().await = ClientState::Handshaking;
|
||||||
@@ -123,13 +184,11 @@ impl VpnClient {
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, init_frame, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, init_frame, &mut frame_bytes)?;
|
||||||
ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
// <- e, ee
|
// <- e, ee
|
||||||
let resp_msg = match ws_stream.next().await {
|
let resp_msg = match stream.recv_reliable().await? {
|
||||||
Some(Ok(Message::Binary(data))) => data.to_vec(),
|
Some(data) => data,
|
||||||
Some(Ok(_)) => anyhow::bail!("Expected binary handshake response"),
|
|
||||||
Some(Err(e)) => anyhow::bail!("WebSocket error during handshake: {}", e),
|
|
||||||
None => anyhow::bail!("Connection closed during handshake"),
|
None => anyhow::bail!("Connection closed during handshake"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,9 +204,9 @@ impl VpnClient {
|
|||||||
let mut noise_transport = initiator.into_transport_mode()?;
|
let mut noise_transport = initiator.into_transport_mode()?;
|
||||||
|
|
||||||
// Receive assigned IP info (encrypted)
|
// Receive assigned IP info (encrypted)
|
||||||
let info_msg = match ws_stream.next().await {
|
let info_msg = match stream.recv_reliable().await? {
|
||||||
Some(Ok(Message::Binary(data))) => data.to_vec(),
|
Some(data) => data,
|
||||||
_ => anyhow::bail!("Expected IP info message"),
|
None => anyhow::bail!("Connection closed before IP info"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut frame_buf = BytesMut::from(&info_msg[..]);
|
let mut frame_buf = BytesMut::from(&info_msg[..]);
|
||||||
@@ -167,8 +226,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
|
||||||
@@ -177,8 +243,8 @@ impl VpnClient {
|
|||||||
// Spawn packet forwarding loop
|
// Spawn packet forwarding loop
|
||||||
let assigned_ip_clone = assigned_ip.clone();
|
let assigned_ip_clone = assigned_ip.clone();
|
||||||
tokio::spawn(client_loop(
|
tokio::spawn(client_loop(
|
||||||
ws_sink,
|
sink,
|
||||||
ws_stream,
|
stream,
|
||||||
noise_transport,
|
noise_transport,
|
||||||
state,
|
state,
|
||||||
stats,
|
stats,
|
||||||
@@ -273,8 +339,8 @@ impl VpnClient {
|
|||||||
|
|
||||||
/// The main client packet forwarding loop (runs in a spawned task).
|
/// The main client packet forwarding loop (runs in a spawned task).
|
||||||
async fn client_loop(
|
async fn client_loop(
|
||||||
mut ws_sink: futures_util::stream::SplitSink<transport::WsStream, Message>,
|
mut sink: Box<dyn TransportSink>,
|
||||||
mut ws_stream: futures_util::stream::SplitStream<transport::WsStream>,
|
mut stream: Box<dyn TransportStream>,
|
||||||
mut noise_transport: snow::TransportState,
|
mut noise_transport: snow::TransportState,
|
||||||
state: Arc<RwLock<ClientState>>,
|
state: Arc<RwLock<ClientState>>,
|
||||||
stats: Arc<RwLock<ClientStatistics>>,
|
stats: Arc<RwLock<ClientStatistics>>,
|
||||||
@@ -287,10 +353,10 @@ async fn client_loop(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = ws_stream.next() => {
|
msg = stream.recv_reliable() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Binary(data))) => {
|
Ok(Some(data)) => {
|
||||||
let mut frame_buf = BytesMut::from(&data[..][..]);
|
let mut frame_buf = BytesMut::from(&data[..]);
|
||||||
if let Ok(Some(frame)) = <FrameCodec as tokio_util::codec::Decoder>::decode(&mut FrameCodec, &mut frame_buf) {
|
if let Ok(Some(frame)) = <FrameCodec as tokio_util::codec::Decoder>::decode(&mut FrameCodec, &mut frame_buf) {
|
||||||
match frame.packet_type {
|
match frame.packet_type {
|
||||||
PacketType::IpPacket => {
|
PacketType::IpPacket => {
|
||||||
@@ -321,17 +387,13 @@ async fn client_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
Ok(None) => {
|
||||||
info!("Connection closed");
|
info!("Connection closed");
|
||||||
*state.write().await = ClientState::Disconnected;
|
*state.write().await = ClientState::Disconnected;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(data))) => {
|
Err(e) => {
|
||||||
let _ = ws_sink.send(Message::Pong(data)).await;
|
error!("Transport error: {}", e);
|
||||||
}
|
|
||||||
Some(Ok(_)) => continue,
|
|
||||||
Some(Err(e)) => {
|
|
||||||
error!("WebSocket error: {}", e);
|
|
||||||
*state.write().await = ClientState::Error(e.to_string());
|
*state.write().await = ClientState::Error(e.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -347,7 +409,7 @@ async fn client_loop(
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, ka_frame, &mut frame_bytes).is_ok() {
|
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, ka_frame, &mut frame_bytes).is_ok() {
|
||||||
if ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await.is_err() {
|
if sink.send_reliable(frame_bytes.to_vec()).await.is_err() {
|
||||||
warn!("Failed to send keepalive");
|
warn!("Failed to send keepalive");
|
||||||
*state.write().await = ClientState::Disconnected;
|
*state.write().await = ClientState::Disconnected;
|
||||||
break;
|
break;
|
||||||
@@ -378,12 +440,51 @@ async fn client_loop(
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, dc_frame, &mut frame_bytes).is_ok() {
|
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, dc_frame, &mut frame_bytes).is_ok() {
|
||||||
let _ = ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await;
|
let _ = sink.send_reliable(frame_bytes.to_vec()).await;
|
||||||
}
|
}
|
||||||
let _ = ws_sink.close().await;
|
let _ = sink.close().await;
|
||||||
*state.write().await = ClientState::Disconnected;
|
*state.write().await = ClientState::Disconnected;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to connect via QUIC. Returns transport halves on success.
|
||||||
|
async fn try_quic_connect(
|
||||||
|
addr: &str,
|
||||||
|
cert_hash: Option<&str>,
|
||||||
|
) -> Result<(quic_transport::QuicTransportSink, quic_transport::QuicTransportStream)> {
|
||||||
|
let conn = quic_transport::connect_quic(addr, cert_hash).await?;
|
||||||
|
let (sink, stream) = quic_transport::open_quic_streams(conn).await?;
|
||||||
|
Ok((sink, stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract host:port from a WebSocket URL for QUIC auto-fallback.
|
||||||
|
/// e.g. "ws://127.0.0.1:8080" -> Some("127.0.0.1:8080")
|
||||||
|
/// "wss://vpn.example.com/tunnel" -> Some("vpn.example.com:443")
|
||||||
|
/// "127.0.0.1:8080" -> Some("127.0.0.1:8080") (already host:port)
|
||||||
|
fn extract_host_port(url: &str) -> Option<String> {
|
||||||
|
if url.starts_with("ws://") || url.starts_with("wss://") {
|
||||||
|
// Parse as URL
|
||||||
|
let stripped = if url.starts_with("wss://") {
|
||||||
|
&url[6..]
|
||||||
|
} else {
|
||||||
|
&url[5..]
|
||||||
|
};
|
||||||
|
// Remove path
|
||||||
|
let host_port = stripped.split('/').next()?;
|
||||||
|
if host_port.contains(':') {
|
||||||
|
Some(host_port.to_string())
|
||||||
|
} else {
|
||||||
|
// Default port
|
||||||
|
let default_port = if url.starts_with("wss://") { 443 } else { 80 };
|
||||||
|
Some(format!("{}:{}", host_port, default_port))
|
||||||
|
}
|
||||||
|
} else if url.contains(':') {
|
||||||
|
// Already host:port
|
||||||
|
Some(url.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub mod management;
|
|||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod transport_trait;
|
||||||
|
pub mod quic_transport;
|
||||||
pub mod keepalive;
|
pub mod keepalive;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
|||||||
546
rust/src/quic_transport.rs
Normal file
546
rust/src/quic_transport.rs
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{info, warn, debug};
|
||||||
|
|
||||||
|
use crate::transport_trait::{TransportSink, TransportStream};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TLS / Certificate helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate and private key for QUIC.
|
||||||
|
pub fn generate_self_signed_cert() -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["smartvpn".to_string()])?;
|
||||||
|
let cert_der = CertificateDer::from(cert.cert);
|
||||||
|
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
|
||||||
|
Ok((vec![cert_der], key_der))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the SHA-256 hash of a DER-encoded certificate and return it as base64.
|
||||||
|
pub fn cert_hash(cert_der: &CertificateDer<'_>) -> String {
|
||||||
|
use ring::digest;
|
||||||
|
let hash = digest::digest(&digest::SHA256, cert_der.as_ref());
|
||||||
|
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Server-side QUIC endpoint
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Configuration for the QUIC server endpoint.
|
||||||
|
pub struct QuicServerConfig {
|
||||||
|
pub listen_addr: String,
|
||||||
|
pub cert_chain: Vec<CertificateDer<'static>>,
|
||||||
|
pub private_key: PrivateKeyDer<'static>,
|
||||||
|
pub idle_timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a QUIC server endpoint bound to the given address.
|
||||||
|
pub fn create_quic_server(config: QuicServerConfig) -> Result<quinn::Endpoint> {
|
||||||
|
let addr: SocketAddr = config.listen_addr.parse()?;
|
||||||
|
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let mut tls_config = rustls::ServerConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(config.cert_chain, config.private_key)?;
|
||||||
|
tls_config.alpn_protocols = vec![b"smartvpn".to_vec()];
|
||||||
|
|
||||||
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(
|
||||||
|
quinn::crypto::rustls::QuicServerConfig::try_from(tls_config)?,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.max_idle_timeout(Some(
|
||||||
|
quinn::IdleTimeout::try_from(Duration::from_secs(config.idle_timeout_secs))?,
|
||||||
|
));
|
||||||
|
// Enable datagrams with a generous max size
|
||||||
|
transport.datagram_receive_buffer_size(Some(65535));
|
||||||
|
transport.datagram_send_buffer_size(65535);
|
||||||
|
server_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
let endpoint = quinn::Endpoint::server(server_config, addr)?;
|
||||||
|
info!("QUIC server listening on {}", addr);
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Client-side QUIC connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A certificate verifier that accepts any server certificate.
|
||||||
|
/// Safe when Noise NK provides server authentication at the application layer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AcceptAnyCert;
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Err(rustls::Error::General("TLS 1.2 not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A certificate verifier that accepts any certificate matching a given SHA-256 hash.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CertHashVerifier {
|
||||||
|
expected_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for CertHashVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
let actual_hash = cert_hash(end_entity);
|
||||||
|
if actual_hash == self.expected_hash {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
} else {
|
||||||
|
Err(rustls::Error::General(format!(
|
||||||
|
"Certificate hash mismatch: expected {}, got {}",
|
||||||
|
self.expected_hash, actual_hash
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
// QUIC always uses TLS 1.3
|
||||||
|
Err(rustls::Error::General("TLS 1.2 not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a QUIC server.
|
||||||
|
///
|
||||||
|
/// - If `server_cert_hash` is provided, verifies the server certificate matches
|
||||||
|
/// the given SHA-256 hash (cert pinning).
|
||||||
|
/// - If `server_cert_hash` is `None`, accepts any server certificate. This is
|
||||||
|
/// safe because the Noise NK handshake (which runs over the QUIC stream)
|
||||||
|
/// authenticates the server via its pre-shared public key — the same trust
|
||||||
|
/// model as WireGuard.
|
||||||
|
pub async fn connect_quic(
|
||||||
|
addr: &str,
|
||||||
|
server_cert_hash: Option<&str>,
|
||||||
|
) -> Result<quinn::Connection> {
|
||||||
|
let remote: SocketAddr = addr.parse()?;
|
||||||
|
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let tls_config = if let Some(hash) = server_cert_hash {
|
||||||
|
// Pin to a specific certificate hash
|
||||||
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(CertHashVerifier {
|
||||||
|
expected_hash: hash.to_string(),
|
||||||
|
}))
|
||||||
|
.with_no_client_auth();
|
||||||
|
config.alpn_protocols = vec![b"smartvpn".to_vec()];
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
// Accept any cert — Noise NK provides server authentication
|
||||||
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(AcceptAnyCert))
|
||||||
|
.with_no_client_auth();
|
||||||
|
config.alpn_protocols = vec![b"smartvpn".to_vec()];
|
||||||
|
config
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_config = quinn::ClientConfig::new(Arc::new(
|
||||||
|
QuicClientConfig::try_from(tls_config)?,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
|
||||||
|
endpoint.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
info!("Connecting to QUIC server at {}", addr);
|
||||||
|
let connection = endpoint.connect(remote, "smartvpn")?.await?;
|
||||||
|
info!("QUIC connection established");
|
||||||
|
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUIC Transport Sink / Stream implementations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// QUIC transport sink — wraps a SendStream (reliable) and Connection (datagrams).
|
||||||
|
pub struct QuicTransportSink {
|
||||||
|
send_stream: quinn::SendStream,
|
||||||
|
connection: quinn::Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuicTransportSink {
|
||||||
|
pub fn new(send_stream: quinn::SendStream, connection: quinn::Connection) -> Self {
|
||||||
|
Self {
|
||||||
|
send_stream,
|
||||||
|
connection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TransportSink for QuicTransportSink {
|
||||||
|
async fn send_reliable(&mut self, data: Vec<u8>) -> Result<()> {
|
||||||
|
// Length-prefix framing: [4-byte big-endian length][payload]
|
||||||
|
let len = data.len() as u32;
|
||||||
|
self.send_stream.write_all(&len.to_be_bytes()).await?;
|
||||||
|
self.send_stream.write_all(&data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_datagram(&mut self, data: Vec<u8>) -> Result<()> {
|
||||||
|
let max_size = self.connection.max_datagram_size();
|
||||||
|
match max_size {
|
||||||
|
Some(max) if data.len() <= max => {
|
||||||
|
self.connection.send_datagram(data.into())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Datagram too large or datagrams disabled — fall back to reliable
|
||||||
|
debug!("Datagram too large ({}B), falling back to reliable stream", data.len());
|
||||||
|
self.send_reliable(data).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<()> {
|
||||||
|
self.send_stream.finish()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QUIC transport stream — wraps a RecvStream (reliable) and Connection (datagrams).
|
||||||
|
pub struct QuicTransportStream {
|
||||||
|
recv_stream: quinn::RecvStream,
|
||||||
|
connection: quinn::Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuicTransportStream {
|
||||||
|
pub fn new(recv_stream: quinn::RecvStream, connection: quinn::Connection) -> Self {
|
||||||
|
Self {
|
||||||
|
recv_stream,
|
||||||
|
connection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TransportStream for QuicTransportStream {
|
||||||
|
async fn recv_reliable(&mut self) -> Result<Option<Vec<u8>>> {
|
||||||
|
// Read length prefix
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
match self.recv_stream.read_exact(&mut len_buf).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(quinn::ReadExactError::FinishedEarly(_)) => return Ok(None),
|
||||||
|
Err(quinn::ReadExactError::ReadError(quinn::ReadError::ConnectionLost(e))) => {
|
||||||
|
warn!("QUIC connection lost: {}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(anyhow::anyhow!("QUIC read error: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
if len > 65536 {
|
||||||
|
return Err(anyhow::anyhow!("Frame too large: {} bytes", len));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = vec![0u8; len];
|
||||||
|
match self.recv_stream.read_exact(&mut data).await {
|
||||||
|
Ok(()) => Ok(Some(data)),
|
||||||
|
Err(quinn::ReadExactError::FinishedEarly(_)) => Ok(None),
|
||||||
|
Err(e) => Err(anyhow::anyhow!("QUIC read error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_datagram(&mut self) -> Result<Option<Vec<u8>>> {
|
||||||
|
match self.connection.read_datagram().await {
|
||||||
|
Ok(data) => Ok(Some(data.to_vec())),
|
||||||
|
Err(quinn::ConnectionError::ApplicationClosed(_)) => Ok(None),
|
||||||
|
Err(quinn::ConnectionError::LocallyClosed) => Ok(None),
|
||||||
|
Err(e) => Err(anyhow::anyhow!("QUIC datagram error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_datagrams(&self) -> bool {
|
||||||
|
self.connection.max_datagram_size().is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept a QUIC connection and open a bidirectional control stream.
|
||||||
|
/// Returns the transport sink/stream pair ready for the VPN handshake.
|
||||||
|
pub async fn accept_quic_connection(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
) -> Result<(QuicTransportSink, QuicTransportStream)> {
|
||||||
|
// The client opens the bidirectional control stream
|
||||||
|
let (send, recv) = conn.accept_bi().await?;
|
||||||
|
info!("QUIC bidirectional control stream accepted");
|
||||||
|
Ok((
|
||||||
|
QuicTransportSink::new(send, conn.clone()),
|
||||||
|
QuicTransportStream::new(recv, conn),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a QUIC connection's bidirectional control stream (client side).
|
||||||
|
pub async fn open_quic_streams(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
) -> Result<(QuicTransportSink, QuicTransportStream)> {
|
||||||
|
let (send, recv) = conn.open_bi().await?;
|
||||||
|
info!("QUIC bidirectional control stream opened");
|
||||||
|
Ok((
|
||||||
|
QuicTransportSink::new(send, conn.clone()),
|
||||||
|
QuicTransportStream::new(recv, conn),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cert_generation_and_hash() {
|
||||||
|
let (certs, _key) = generate_self_signed_cert().unwrap();
|
||||||
|
assert_eq!(certs.len(), 1);
|
||||||
|
let hash = cert_hash(&certs[0]);
|
||||||
|
// SHA-256 base64 is 44 characters
|
||||||
|
assert_eq!(hash.len(), 44);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cert_hash_deterministic() {
|
||||||
|
let (certs, _key) = generate_self_signed_cert().unwrap();
|
||||||
|
let hash1 = cert_hash(&certs[0]);
|
||||||
|
let hash2 = cert_hash(&certs[0]);
|
||||||
|
assert_eq!(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: create QUIC server and client endpoints.
|
||||||
|
fn create_quic_endpoints() -> (quinn::Endpoint, quinn::Endpoint, String) {
|
||||||
|
let (certs, key) = generate_self_signed_cert().unwrap();
|
||||||
|
let hash = cert_hash(&certs[0]);
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
|
||||||
|
let mut server_tls = rustls::ServerConfig::builder_with_provider(provider.clone())
|
||||||
|
.with_safe_default_protocol_versions().unwrap()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key).unwrap();
|
||||||
|
server_tls.alpn_protocols = vec![b"smartvpn".to_vec()];
|
||||||
|
let server_qcfg = quinn::ServerConfig::with_crypto(Arc::new(
|
||||||
|
quinn::crypto::rustls::QuicServerConfig::try_from(server_tls).unwrap(),
|
||||||
|
));
|
||||||
|
let server_ep = quinn::Endpoint::server(server_qcfg, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let mut client_tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions().unwrap()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(CertHashVerifier {
|
||||||
|
expected_hash: hash,
|
||||||
|
}))
|
||||||
|
.with_no_client_auth();
|
||||||
|
client_tls.alpn_protocols = vec![b"smartvpn".to_vec()];
|
||||||
|
let client_config = quinn::ClientConfig::new(Arc::new(
|
||||||
|
QuicClientConfig::try_from(client_tls).unwrap(),
|
||||||
|
));
|
||||||
|
let mut client_ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap();
|
||||||
|
client_ep.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
let server_addr = server_ep.local_addr().unwrap().to_string();
|
||||||
|
(server_ep, client_ep, server_addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_quic_server_client_roundtrip() {
|
||||||
|
let (server_ep, client_ep, server_addr) = create_quic_endpoints();
|
||||||
|
let addr: std::net::SocketAddr = server_addr.parse().unwrap();
|
||||||
|
|
||||||
|
// Server: accept, accept_bi, read, echo, finish
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let conn = server_ep.accept().await.unwrap().await.unwrap();
|
||||||
|
let (mut s_send, mut s_recv) = conn.accept_bi().await.unwrap();
|
||||||
|
let data = s_recv.read_to_end(1024).await.unwrap();
|
||||||
|
s_send.write_all(&data).await.unwrap();
|
||||||
|
s_send.finish().unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
server_ep
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect, open_bi, write, finish, read
|
||||||
|
let conn = client_ep.connect(addr, "smartvpn").unwrap().await.unwrap();
|
||||||
|
let (mut c_send, mut c_recv) = conn.open_bi().await.unwrap();
|
||||||
|
c_send.write_all(b"hello quinn").await.unwrap();
|
||||||
|
c_send.finish().unwrap();
|
||||||
|
let data = c_recv.read_to_end(1024).await.unwrap();
|
||||||
|
assert_eq!(&data[..], b"hello quinn");
|
||||||
|
|
||||||
|
let _ = server_task.await;
|
||||||
|
drop(client_ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test transport trait wrappers over QUIC.
|
||||||
|
/// Key: client must send data first (QUIC streams are opened implicitly by data).
|
||||||
|
/// The server accept_bi runs concurrently with the client's first send_reliable.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_quic_transport_trait_roundtrip() {
|
||||||
|
let (server_ep, client_ep, server_addr) = create_quic_endpoints();
|
||||||
|
let addr: std::net::SocketAddr = server_addr.parse().unwrap();
|
||||||
|
|
||||||
|
// Server task: accept connection, then accept_bi (blocks until client sends data)
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let conn = server_ep.accept().await.unwrap().await.unwrap();
|
||||||
|
let (s_sink, s_stream) = accept_quic_connection(conn).await.unwrap();
|
||||||
|
(s_sink, s_stream, server_ep)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect, open_bi via wrapper
|
||||||
|
let conn = client_ep.connect(addr, "smartvpn").unwrap().await.unwrap();
|
||||||
|
let (mut c_sink, mut c_stream) = open_quic_streams(conn).await.unwrap();
|
||||||
|
|
||||||
|
// Client sends first — this triggers the QUIC stream to become visible to the server
|
||||||
|
c_sink.send_reliable(b"hello-from-client".to_vec()).await.unwrap();
|
||||||
|
|
||||||
|
// Now server's accept_bi unblocks
|
||||||
|
let (mut s_sink, mut s_stream, _sep) = server_task.await.unwrap();
|
||||||
|
|
||||||
|
// Server reads the message
|
||||||
|
let msg = s_stream.recv_reliable().await.unwrap().unwrap();
|
||||||
|
assert_eq!(msg, b"hello-from-client");
|
||||||
|
|
||||||
|
// Server -> Client
|
||||||
|
s_sink.send_reliable(b"hello-from-server".to_vec()).await.unwrap();
|
||||||
|
let msg = c_stream.recv_reliable().await.unwrap().unwrap();
|
||||||
|
assert_eq!(msg, b"hello-from-server");
|
||||||
|
|
||||||
|
drop(client_ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test QUIC datagram support.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_quic_datagram_exchange() {
|
||||||
|
let (server_ep, client_ep, server_addr) = create_quic_endpoints();
|
||||||
|
let addr: std::net::SocketAddr = server_addr.parse().unwrap();
|
||||||
|
|
||||||
|
// Server: accept, accept_bi (opens control stream), then read datagram
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let conn = server_ep.accept().await.unwrap().await.unwrap();
|
||||||
|
// Accept bi stream (control channel)
|
||||||
|
let (_s_sink, _s_stream) = accept_quic_connection(conn.clone()).await.unwrap();
|
||||||
|
// Read datagram
|
||||||
|
let dgram = conn.read_datagram().await.unwrap();
|
||||||
|
assert_eq!(&dgram[..], b"dgram-payload");
|
||||||
|
server_ep
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect, open bi stream (triggers server accept_bi), then send datagram
|
||||||
|
let conn = client_ep.connect(addr, "smartvpn").unwrap().await.unwrap();
|
||||||
|
let (mut c_sink, _c_stream) = open_quic_streams(conn.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Send initial data to open the stream (required for QUIC)
|
||||||
|
c_sink.send_reliable(b"init".to_vec()).await.unwrap();
|
||||||
|
|
||||||
|
// Small yield to let the server process the bi stream
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
|
// Send datagram
|
||||||
|
assert!(conn.max_datagram_size().is_some());
|
||||||
|
conn.send_datagram(bytes::Bytes::from_static(b"dgram-payload")).unwrap();
|
||||||
|
|
||||||
|
let _ = server_task.await.unwrap();
|
||||||
|
drop(client_ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that supports_datagrams returns true for QUIC transports.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_quic_supports_datagrams() {
|
||||||
|
let (server_ep, client_ep, server_addr) = create_quic_endpoints();
|
||||||
|
let addr: std::net::SocketAddr = server_addr.parse().unwrap();
|
||||||
|
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let conn = server_ep.accept().await.unwrap().await.unwrap();
|
||||||
|
let (_s_sink, s_stream) = accept_quic_connection(conn).await.unwrap();
|
||||||
|
assert!(s_stream.supports_datagrams());
|
||||||
|
server_ep
|
||||||
|
});
|
||||||
|
|
||||||
|
let conn = client_ep.connect(addr, "smartvpn").unwrap().await.unwrap();
|
||||||
|
let (mut c_sink, c_stream) = open_quic_streams(conn).await.unwrap();
|
||||||
|
assert!(c_stream.supports_datagrams());
|
||||||
|
|
||||||
|
// Send data to trigger server's accept_bi
|
||||||
|
c_sink.send_reliable(b"ping".to_vec()).await.unwrap();
|
||||||
|
|
||||||
|
let _ = server_task.await.unwrap();
|
||||||
|
drop(client_ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,10 +130,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tokens_do_not_exceed_burst() {
|
fn tokens_do_not_exceed_burst() {
|
||||||
let mut tb = TokenBucket::new(1_000_000, 1_000);
|
// Use a low rate so refill between consecutive calls is negligible
|
||||||
|
let mut tb = TokenBucket::new(100, 1_000);
|
||||||
// Wait to accumulate — but should cap at burst
|
// Wait to accumulate — but should cap at burst
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
assert!(tb.try_consume(1_000));
|
assert!(tb.try_consume(1_000));
|
||||||
|
// At 100 bytes/sec, the few μs between calls add ~0 tokens
|
||||||
assert!(!tb.try_consume(1));
|
assert!(!tb.try_consume(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
@@ -8,7 +7,6 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
use tracing::{info, error, warn};
|
use tracing::{info, error, warn};
|
||||||
|
|
||||||
use crate::codec::{Frame, FrameCodec, PacketType};
|
use crate::codec::{Frame, FrameCodec, PacketType};
|
||||||
@@ -17,6 +15,8 @@ use crate::mtu::{MtuConfig, TunnelOverhead};
|
|||||||
use crate::network::IpPool;
|
use crate::network::IpPool;
|
||||||
use crate::ratelimit::TokenBucket;
|
use crate::ratelimit::TokenBucket;
|
||||||
use crate::transport;
|
use crate::transport;
|
||||||
|
use crate::transport_trait::{self, TransportSink, TransportStream};
|
||||||
|
use crate::quic_transport;
|
||||||
|
|
||||||
/// Dead-peer timeout: 3x max keepalive interval (Healthy=60s).
|
/// Dead-peer timeout: 3x max keepalive interval (Healthy=60s).
|
||||||
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
|
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
|
||||||
@@ -39,6 +39,12 @@ pub struct ServerConfig {
|
|||||||
pub default_rate_limit_bytes_per_sec: Option<u64>,
|
pub default_rate_limit_bytes_per_sec: Option<u64>,
|
||||||
/// Default burst size for new clients (bytes). None = unlimited.
|
/// Default burst size for new clients (bytes). None = unlimited.
|
||||||
pub default_burst_bytes: Option<u64>,
|
pub default_burst_bytes: Option<u64>,
|
||||||
|
/// Transport mode: "websocket" (default), "quic", or "both".
|
||||||
|
pub transport_mode: Option<String>,
|
||||||
|
/// QUIC listen address (host:port). Defaults to listen_addr.
|
||||||
|
pub quic_listen_addr: Option<String>,
|
||||||
|
/// QUIC idle timeout in seconds (default: 30).
|
||||||
|
pub quic_idle_timeout_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a connected client.
|
/// Information about a connected client.
|
||||||
@@ -135,14 +141,58 @@ impl VpnServer {
|
|||||||
self.state = Some(state.clone());
|
self.state = Some(state.clone());
|
||||||
self.shutdown_tx = Some(shutdown_tx);
|
self.shutdown_tx = Some(shutdown_tx);
|
||||||
|
|
||||||
|
let transport_mode = config.transport_mode.as_deref().unwrap_or("both");
|
||||||
let listen_addr = config.listen_addr.clone();
|
let listen_addr = config.listen_addr.clone();
|
||||||
|
|
||||||
|
match transport_mode {
|
||||||
|
"quic" => {
|
||||||
|
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone());
|
||||||
|
let idle_timeout = config.quic_idle_timeout_secs.unwrap_or(30);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = run_listener(state, listen_addr, &mut shutdown_rx).await {
|
if let Err(e) = run_quic_listener(state, quic_addr, idle_timeout, &mut shutdown_rx).await {
|
||||||
|
error!("QUIC listener error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"both" => {
|
||||||
|
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone());
|
||||||
|
let idle_timeout = config.quic_idle_timeout_secs.unwrap_or(30);
|
||||||
|
let state2 = state.clone();
|
||||||
|
let (shutdown_tx2, mut shutdown_rx2) = mpsc::channel::<()>(1);
|
||||||
|
// Store second shutdown sender so both listeners stop
|
||||||
|
let shutdown_tx_orig = self.shutdown_tx.take().unwrap();
|
||||||
|
let (combined_tx, mut combined_rx) = mpsc::channel::<()>(1);
|
||||||
|
self.shutdown_tx = Some(combined_tx);
|
||||||
|
|
||||||
|
// Forward combined shutdown to both listeners
|
||||||
|
tokio::spawn(async move {
|
||||||
|
combined_rx.recv().await;
|
||||||
|
let _ = shutdown_tx_orig.send(()).await;
|
||||||
|
let _ = shutdown_tx2.send(()).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = run_ws_listener(state, listen_addr, &mut shutdown_rx).await {
|
||||||
|
error!("WebSocket listener error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = run_quic_listener(state2, quic_addr, idle_timeout, &mut shutdown_rx2).await {
|
||||||
|
error!("QUIC listener error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// "websocket" (default)
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = run_ws_listener(state, listen_addr, &mut shutdown_rx).await {
|
||||||
error!("Server listener error: {}", e);
|
error!("Server listener error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!("VPN server started");
|
info!("VPN server started (transport: {})", transport_mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +289,9 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_listener(
|
/// WebSocket listener — accepts TCP connections, upgrades to WS, then hands off
|
||||||
|
/// to the transport-agnostic `handle_client_connection`.
|
||||||
|
async fn run_ws_listener(
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
listen_addr: String,
|
listen_addr: String,
|
||||||
shutdown_rx: &mut mpsc::Receiver<()>,
|
shutdown_rx: &mut mpsc::Receiver<()>,
|
||||||
@@ -255,9 +307,21 @@ async fn run_listener(
|
|||||||
info!("New connection from {}", addr);
|
info!("New connection from {}", addr);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_client_connection(state, stream).await {
|
match transport::accept_connection(stream).await {
|
||||||
|
Ok(ws) => {
|
||||||
|
let (sink, stream) = transport_trait::split_ws(ws);
|
||||||
|
if let Err(e) = handle_client_connection(
|
||||||
|
state,
|
||||||
|
Box::new(sink),
|
||||||
|
Box::new(stream),
|
||||||
|
).await {
|
||||||
warn!("Client connection error: {}", e);
|
warn!("Client connection error: {}", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("WebSocket upgrade failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -275,13 +339,95 @@ async fn run_listener(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// QUIC listener — accepts QUIC connections and hands off to the transport-agnostic
|
||||||
|
/// `handle_client_connection`.
|
||||||
|
async fn run_quic_listener(
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
listen_addr: String,
|
||||||
|
idle_timeout_secs: u64,
|
||||||
|
shutdown_rx: &mut mpsc::Receiver<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Generate or use configured TLS certificate for QUIC
|
||||||
|
let (cert_chain, private_key) = if let (Some(ref cert_pem), Some(ref key_pem)) =
|
||||||
|
(&state.config.tls_cert, &state.config.tls_key)
|
||||||
|
{
|
||||||
|
// Parse PEM certificates
|
||||||
|
let certs: Vec<rustls_pki_types::CertificateDer<'static>> =
|
||||||
|
rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No private key found in PEM"))?;
|
||||||
|
(certs, key)
|
||||||
|
} else {
|
||||||
|
// Generate self-signed certificate
|
||||||
|
let (certs, key) = quic_transport::generate_self_signed_cert()?;
|
||||||
|
info!("QUIC using self-signed certificate (hash: {})", quic_transport::cert_hash(&certs[0]));
|
||||||
|
(certs, key)
|
||||||
|
};
|
||||||
|
|
||||||
|
let endpoint = quic_transport::create_quic_server(quic_transport::QuicServerConfig {
|
||||||
|
listen_addr,
|
||||||
|
cert_chain,
|
||||||
|
private_key,
|
||||||
|
idle_timeout_secs,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
incoming = endpoint.accept() => {
|
||||||
|
match incoming {
|
||||||
|
Some(incoming) => {
|
||||||
|
let state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match incoming.await {
|
||||||
|
Ok(conn) => {
|
||||||
|
let remote = conn.remote_address();
|
||||||
|
info!("New QUIC connection from {}", remote);
|
||||||
|
match quic_transport::accept_quic_connection(conn).await {
|
||||||
|
Ok((sink, stream)) => {
|
||||||
|
if let Err(e) = handle_client_connection(
|
||||||
|
state,
|
||||||
|
Box::new(sink),
|
||||||
|
Box::new(stream),
|
||||||
|
).await {
|
||||||
|
warn!("QUIC client error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("QUIC stream accept failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("QUIC handshake failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("QUIC endpoint closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("QUIC shutdown signal received");
|
||||||
|
endpoint.close(0u32.into(), b"shutdown");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport-agnostic client handler. Performs the Noise NK handshake, registers
|
||||||
|
/// the client, and runs the main packet forwarding loop.
|
||||||
async fn handle_client_connection(
|
async fn handle_client_connection(
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
stream: tokio::net::TcpStream,
|
mut sink: Box<dyn TransportSink>,
|
||||||
|
mut stream: Box<dyn TransportStream>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ws = transport::accept_connection(stream).await?;
|
|
||||||
let (mut ws_sink, mut ws_stream) = ws.split();
|
|
||||||
|
|
||||||
let client_id = uuid_v4();
|
let client_id = uuid_v4();
|
||||||
|
|
||||||
let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?;
|
let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?;
|
||||||
@@ -295,9 +441,9 @@ async fn handle_client_connection(
|
|||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
|
|
||||||
// Receive handshake init
|
// Receive handshake init
|
||||||
let init_msg = match ws_stream.next().await {
|
let init_msg = match stream.recv_reliable().await? {
|
||||||
Some(Ok(Message::Binary(data))) => data.to_vec(),
|
Some(data) => data,
|
||||||
_ => anyhow::bail!("Expected handshake init message"),
|
None => anyhow::bail!("Connection closed before handshake"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut frame_buf = BytesMut::from(&init_msg[..]);
|
let mut frame_buf = BytesMut::from(&init_msg[..]);
|
||||||
@@ -318,7 +464,7 @@ async fn handle_client_connection(
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, response_frame, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, response_frame, &mut frame_bytes)?;
|
||||||
ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
let mut noise_transport = responder.into_transport_mode()?;
|
let mut noise_transport = responder.into_transport_mode()?;
|
||||||
|
|
||||||
@@ -369,7 +515,7 @@ async fn handle_client_connection(
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?;
|
||||||
ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
info!("Client {} connected with IP {}", client_id, assigned_ip);
|
info!("Client {} connected with IP {}", client_id, assigned_ip);
|
||||||
|
|
||||||
@@ -378,11 +524,11 @@ async fn handle_client_connection(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = ws_stream.next() => {
|
msg = stream.recv_reliable() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Binary(data))) => {
|
Ok(Some(data)) => {
|
||||||
last_activity = tokio::time::Instant::now();
|
last_activity = tokio::time::Instant::now();
|
||||||
let mut frame_buf = BytesMut::from(&data[..][..]);
|
let mut frame_buf = BytesMut::from(&data[..]);
|
||||||
match <FrameCodec as tokio_util::codec::Decoder>::decode(&mut FrameCodec, &mut frame_buf) {
|
match <FrameCodec as tokio_util::codec::Decoder>::decode(&mut FrameCodec, &mut frame_buf) {
|
||||||
Ok(Some(frame)) => match frame.packet_type {
|
Ok(Some(frame)) => match frame.packet_type {
|
||||||
PacketType::IpPacket => {
|
PacketType::IpPacket => {
|
||||||
@@ -432,7 +578,7 @@ async fn handle_client_connection(
|
|||||||
};
|
};
|
||||||
let mut frame_bytes = BytesMut::new();
|
let mut frame_bytes = BytesMut::new();
|
||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, ack_frame, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, ack_frame, &mut frame_bytes)?;
|
||||||
ws_sink.send(Message::Binary(frame_bytes.to_vec().into())).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
let mut stats = state.stats.write().await;
|
let mut stats = state.stats.write().await;
|
||||||
stats.keepalives_received += 1;
|
stats.keepalives_received += 1;
|
||||||
@@ -463,20 +609,12 @@ async fn handle_client_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
Ok(None) => {
|
||||||
info!("Client {} connection closed", client_id);
|
info!("Client {} connection closed", client_id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(data))) => {
|
Err(e) => {
|
||||||
last_activity = tokio::time::Instant::now();
|
warn!("Transport error from {}: {}", client_id, e);
|
||||||
ws_sink.send(Message::Pong(data)).await?;
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => {
|
|
||||||
last_activity = tokio::time::Instant::now();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
warn!("WebSocket error from {}: {}", client_id, e);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
rust/src/transport_trait.rs
Normal file
116
rust/src/transport_trait.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
use crate::transport::WsStream;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transport trait abstraction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Outbound half of a VPN transport connection.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TransportSink: Send + 'static {
|
||||||
|
/// Send a framed binary message on the reliable channel.
|
||||||
|
async fn send_reliable(&mut self, data: Vec<u8>) -> Result<()>;
|
||||||
|
|
||||||
|
/// Send a datagram (unreliable, best-effort).
|
||||||
|
/// Falls back to reliable if the transport does not support datagrams.
|
||||||
|
async fn send_datagram(&mut self, data: Vec<u8>) -> Result<()>;
|
||||||
|
|
||||||
|
/// Gracefully close the transport.
|
||||||
|
async fn close(&mut self) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbound half of a VPN transport connection.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TransportStream: Send + 'static {
|
||||||
|
/// Receive the next reliable binary message. Returns `None` on close.
|
||||||
|
async fn recv_reliable(&mut self) -> Result<Option<Vec<u8>>>;
|
||||||
|
|
||||||
|
/// Receive the next datagram. Returns `None` if datagrams are unsupported
|
||||||
|
/// or the connection is closed.
|
||||||
|
async fn recv_datagram(&mut self) -> Result<Option<Vec<u8>>>;
|
||||||
|
|
||||||
|
/// Whether this transport supports unreliable datagrams.
|
||||||
|
fn supports_datagrams(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// WebSocket transport sink (wraps the write half of a split WsStream).
|
||||||
|
pub struct WsTransportSink {
|
||||||
|
inner: futures_util::stream::SplitSink<WsStream, Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsTransportSink {
|
||||||
|
pub fn new(inner: futures_util::stream::SplitSink<WsStream, Message>) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TransportSink for WsTransportSink {
|
||||||
|
async fn send_reliable(&mut self, data: Vec<u8>) -> Result<()> {
|
||||||
|
self.inner.send(Message::Binary(data.into())).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_datagram(&mut self, data: Vec<u8>) -> Result<()> {
|
||||||
|
// WebSocket has no datagram support — fall back to reliable.
|
||||||
|
self.send_reliable(data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<()> {
|
||||||
|
self.inner.close().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket transport stream (wraps the read half of a split WsStream).
|
||||||
|
pub struct WsTransportStream {
|
||||||
|
inner: futures_util::stream::SplitStream<WsStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsTransportStream {
|
||||||
|
pub fn new(inner: futures_util::stream::SplitStream<WsStream>) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TransportStream for WsTransportStream {
|
||||||
|
async fn recv_reliable(&mut self) -> Result<Option<Vec<u8>>> {
|
||||||
|
loop {
|
||||||
|
match self.inner.next().await {
|
||||||
|
Some(Ok(Message::Binary(data))) => return Ok(Some(data.to_vec())),
|
||||||
|
Some(Ok(Message::Close(_))) | None => return Ok(None),
|
||||||
|
Some(Ok(Message::Ping(_))) => {
|
||||||
|
// Ping handling is done at the tungstenite layer automatically
|
||||||
|
// when the sink side is alive. Just skip here.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => continue,
|
||||||
|
Some(Err(e)) => return Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_datagram(&mut self) -> Result<Option<Vec<u8>>> {
|
||||||
|
// WebSocket does not support datagrams.
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_datagrams(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a WebSocket stream into transport sink and stream halves.
|
||||||
|
pub fn split_ws(ws: WsStream) -> (WsTransportSink, WsTransportStream) {
|
||||||
|
let (sink, stream) = ws.split();
|
||||||
|
(WsTransportSink::new(sink), WsTransportStream::new(stream))
|
||||||
|
}
|
||||||
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();
|
||||||
242
test/test.quic.node.ts
Normal file
242
test/test.quic.node.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreeUdpPort(): Promise<number> {
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
await new Promise<void>((resolve) => sock.bind(0, '127.0.0.1', resolve));
|
||||||
|
const port = (sock.address() as net.AddressInfo).port;
|
||||||
|
await new Promise<void>((resolve) => sock.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 wsPort: number;
|
||||||
|
let quicPort: number;
|
||||||
|
let keypair: IVpnKeypair;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: QUIC-only server + QUIC client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tap.test('setup: start VPN server in QUIC mode', async () => {
|
||||||
|
quicPort = await findFreeUdpPort();
|
||||||
|
|
||||||
|
const options: IVpnServerOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
server = new VpnServer(options);
|
||||||
|
|
||||||
|
const started = await server['bridge'].start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
keypair = await server.generateKeypair();
|
||||||
|
|
||||||
|
const serverConfig: IVpnServerConfig = {
|
||||||
|
listenAddr: `127.0.0.1:${quicPort}`,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
publicKey: keypair.publicKey,
|
||||||
|
subnet: '10.9.0.0/24',
|
||||||
|
transportMode: 'quic',
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
};
|
||||||
|
await server['bridge'].sendCommand('start', { config: serverConfig });
|
||||||
|
|
||||||
|
const status = await server.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('QUIC client connects and gets IP', async () => {
|
||||||
|
const options: IVpnClientOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
const client = new VpnClient(options);
|
||||||
|
const started = await client.start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
const result = await client.connect({
|
||||||
|
serverUrl: `127.0.0.1:${quicPort}`,
|
||||||
|
serverPublicKey: keypair.publicKey,
|
||||||
|
transport: 'quic',
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignedIp).toBeTypeofString();
|
||||||
|
expect(result.assignedIp).toStartWith('10.9.0.');
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('teardown: stop QUIC server', async () => {
|
||||||
|
await server.stop();
|
||||||
|
await delay(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: dual-mode server (both) + auto client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let dualServer: VpnServer;
|
||||||
|
let dualWsPort: number;
|
||||||
|
let dualQuicPort: number;
|
||||||
|
let dualKeypair: IVpnKeypair;
|
||||||
|
|
||||||
|
tap.test('setup: start VPN server in both mode', async () => {
|
||||||
|
dualWsPort = await findFreePort();
|
||||||
|
dualQuicPort = await findFreeUdpPort();
|
||||||
|
|
||||||
|
const options: IVpnServerOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
dualServer = new VpnServer(options);
|
||||||
|
|
||||||
|
const started = await dualServer['bridge'].start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
dualKeypair = await dualServer.generateKeypair();
|
||||||
|
|
||||||
|
const serverConfig: IVpnServerConfig = {
|
||||||
|
listenAddr: `127.0.0.1:${dualWsPort}`,
|
||||||
|
privateKey: dualKeypair.privateKey,
|
||||||
|
publicKey: dualKeypair.publicKey,
|
||||||
|
subnet: '10.10.0.0/24',
|
||||||
|
transportMode: 'both',
|
||||||
|
quicListenAddr: `127.0.0.1:${dualQuicPort}`,
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
};
|
||||||
|
await dualServer['bridge'].sendCommand('start', { config: serverConfig });
|
||||||
|
|
||||||
|
const status = await dualServer.getStatus();
|
||||||
|
expect(status.state).toEqual('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('auto client connects to dual-mode server (QUIC preferred)', async () => {
|
||||||
|
const options: IVpnClientOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
const client = new VpnClient(options);
|
||||||
|
const started = await client.start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
// "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,
|
||||||
|
// transport defaults to 'auto'
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignedIp).toBeTypeofString();
|
||||||
|
expect(result.assignedIp).toStartWith('10.10.0.');
|
||||||
|
|
||||||
|
const clientStatus = await client.getStatus();
|
||||||
|
expect(clientStatus.state).toEqual('connected');
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const clients = await dualServer.listClients();
|
||||||
|
return clients.length >= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('explicit QUIC client connects to dual-mode server', async () => {
|
||||||
|
const options: IVpnClientOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
const client = new VpnClient(options);
|
||||||
|
const started = await client.start();
|
||||||
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
const result = await client.connect({
|
||||||
|
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
||||||
|
serverPublicKey: dualKeypair.publicKey,
|
||||||
|
transport: 'quic',
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignedIp).toBeTypeofString();
|
||||||
|
expect(result.assignedIp).toStartWith('10.10.0.');
|
||||||
|
|
||||||
|
const clientStatus = await client.getStatus();
|
||||||
|
expect(clientStatus.state).toEqual('connected');
|
||||||
|
|
||||||
|
await client.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('keepalive exchange over QUIC', async () => {
|
||||||
|
const options: IVpnClientOptions = {
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
};
|
||||||
|
const client = new VpnClient(options);
|
||||||
|
await client.start();
|
||||||
|
|
||||||
|
await client.connect({
|
||||||
|
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
||||||
|
serverPublicKey: dualKeypair.publicKey,
|
||||||
|
transport: 'quic',
|
||||||
|
keepaliveIntervalSecs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for keepalive exchange
|
||||||
|
await delay(8000);
|
||||||
|
|
||||||
|
const clientStats = await client.getStatistics();
|
||||||
|
expect(clientStats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(clientStats.keepalivesReceived).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await client.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('teardown: stop dual-mode server', async () => {
|
||||||
|
await dualServer.stop();
|
||||||
|
await delay(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.2.0',
|
version: '1.4.1',
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ export class VpnConfig {
|
|||||||
if (!config.serverUrl) {
|
if (!config.serverUrl) {
|
||||||
throw new Error('VpnConfig: serverUrl is required');
|
throw new Error('VpnConfig: serverUrl is required');
|
||||||
}
|
}
|
||||||
|
// For QUIC-only transport, serverUrl is a host:port address; for WebSocket/auto it must be ws:// or wss://
|
||||||
|
if (config.transport !== 'quic') {
|
||||||
if (!config.serverUrl.startsWith('wss://') && !config.serverUrl.startsWith('ws://')) {
|
if (!config.serverUrl.startsWith('wss://') && !config.serverUrl.startsWith('ws://')) {
|
||||||
throw new Error('VpnConfig: serverUrl must start with wss:// or ws://');
|
throw new Error('VpnConfig: serverUrl must start with wss:// or ws:// (for WebSocket transport)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!config.serverPublicKey) {
|
if (!config.serverPublicKey) {
|
||||||
throw new Error('VpnConfig: serverPublicKey is required');
|
throw new Error('VpnConfig: serverPublicKey is required');
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface IVpnClientConfig {
|
|||||||
mtu?: number;
|
mtu?: number;
|
||||||
/** Keepalive interval in seconds (default: 30) */
|
/** Keepalive interval in seconds (default: 30) */
|
||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
|
/** Transport protocol: 'auto' (default, tries QUIC then WS), 'websocket', or 'quic' */
|
||||||
|
transport?: 'auto' | 'websocket' | 'quic';
|
||||||
|
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
|
||||||
|
serverCertHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnClientOptions {
|
export interface IVpnClientOptions {
|
||||||
@@ -68,6 +72,12 @@ export interface IVpnServerConfig {
|
|||||||
defaultRateLimitBytesPerSec?: number;
|
defaultRateLimitBytesPerSec?: number;
|
||||||
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
||||||
defaultBurstBytes?: number;
|
defaultBurstBytes?: number;
|
||||||
|
/** Transport mode: 'both' (default, WS+QUIC), 'websocket', or 'quic' */
|
||||||
|
transportMode?: 'websocket' | 'quic' | 'both';
|
||||||
|
/** QUIC listen address (host:port). Defaults to listenAddr. */
|
||||||
|
quicListenAddr?: string;
|
||||||
|
/** QUIC idle timeout in seconds (default: 30) */
|
||||||
|
quicIdleTimeoutSecs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnServerOptions {
|
export interface IVpnServerOptions {
|
||||||
|
|||||||
Reference in New Issue
Block a user