Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c27a92d6 | |||
| 9d105e8034 | |||
| e9cf575271 | |||
| 229db4be38 |
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.10.0 - feat(rust-server, rust-client, ts-interfaces)
|
||||||
|
add configurable packet forwarding with TUN and userspace NAT modes
|
||||||
|
|
||||||
|
- introduce forwardingMode options for client and server configuration interfaces
|
||||||
|
- add server-side forwarding engines for kernel TUN, userspace socket NAT, and testing mode
|
||||||
|
- add a smoltcp-based userspace NAT implementation for packet forwarding without root-only TUN routing
|
||||||
|
- enable client-side TUN forwarding support with route setup, packet I/O, and cleanup
|
||||||
|
- centralize raw packet destination IP extraction in tunnel utilities for shared routing logic
|
||||||
|
- update test command timeout and logging flags
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.9.0 - feat(server)
|
||||||
|
add PROXY protocol v2 support for real client IP handling and connection ACLs
|
||||||
|
|
||||||
|
- add PROXY protocol v2 parsing for WebSocket connections, including IPv4/IPv6 support, LOCAL command handling, and header read timeout protection
|
||||||
|
- apply server-level connection IP block lists before the Noise handshake and enforce per-client source IP allow/block lists using the resolved remote address
|
||||||
|
- expose proxy protocol configuration and remote client address fields in Rust and TypeScript interfaces, and document reverse-proxy usage in the README
|
||||||
|
|
||||||
## 2026-03-29 - 1.8.0 - feat(auth,client-registry)
|
## 2026-03-29 - 1.8.0 - feat(auth,client-registry)
|
||||||
add Noise IK client authentication with managed client registry and per-client ACL controls
|
add Noise IK client authentication with managed client registry and per-client ACL controls
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.8.0",
|
"version": "1.10.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "(tsbuild tsfolders) && (tsrust)",
|
"build": "(tsbuild tsfolders) && (tsrust)",
|
||||||
"test:before": "(tsrust)",
|
"test:before": "(tsrust)",
|
||||||
"test": "tstest test/ --verbose",
|
"test": "tstest test/ --verbose--verbose --logfile --timeout 60",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
34
readme.md
34
readme.md
@@ -5,6 +5,7 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust
|
|||||||
🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry
|
🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry
|
||||||
🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol)
|
🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol)
|
||||||
🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions
|
🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions
|
||||||
|
🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum)
|
||||||
📊 **Adaptive QoS**: per-client rate limiting, priority queues, connection quality tracking
|
📊 **Adaptive QoS**: per-client rate limiting, priority queues, connection quality tracking
|
||||||
🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs
|
🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs
|
||||||
📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs
|
📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs
|
||||||
@@ -125,6 +126,32 @@ security: {
|
|||||||
|
|
||||||
Supports exact IPs, CIDR, wildcards (`192.168.1.*`), and ranges (`1.1.1.1-1.1.1.100`).
|
Supports exact IPs, CIDR, wildcards (`192.168.1.*`), and ranges (`1.1.1.1-1.1.1.100`).
|
||||||
|
|
||||||
|
### 🔀 PROXY Protocol v2
|
||||||
|
|
||||||
|
When the VPN server sits behind a reverse proxy, enable PROXY protocol v2 to receive the **real client IP** instead of the proxy's address. This makes `ipAllowList` / `ipBlockList` ACLs work correctly through load balancers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await server.start({
|
||||||
|
// ... other config ...
|
||||||
|
proxyProtocol: true, // parse PP v2 headers on WS connections
|
||||||
|
connectionIpBlockList: ['198.51.100.0/24'], // server-wide block list (pre-handshake)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two-phase ACL with real IPs:**
|
||||||
|
|
||||||
|
| Phase | When | What Happens |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| **Pre-handshake** | After TCP accept | Server-level `connectionIpBlockList` rejects known-bad IPs — zero crypto cost |
|
||||||
|
| **Post-handshake** | After Noise IK identifies client | Per-client `ipAllowList` / `ipBlockList` checked against real source IP |
|
||||||
|
|
||||||
|
- Parses the PP v2 binary header from raw TCP before WebSocket upgrade
|
||||||
|
- 5-second timeout protects against stalling attacks
|
||||||
|
- LOCAL command (proxy health checks) handled gracefully
|
||||||
|
- IPv4 and IPv6 addresses supported
|
||||||
|
- `remoteAddr` field on `IVpnClientInfo` exposes the real client IP for monitoring
|
||||||
|
- **Security**: must be `false` (default) when accepting direct connections — only enable behind a trusted proxy
|
||||||
|
|
||||||
### 📊 Telemetry & QoS
|
### 📊 Telemetry & QoS
|
||||||
|
|
||||||
- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`)
|
- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`)
|
||||||
@@ -217,13 +244,13 @@ const unit = VpnInstaller.generateServiceUnit({
|
|||||||
|
|
||||||
| Interface | Purpose |
|
| Interface | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients) |
|
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients, proxy protocol) |
|
||||||
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, WG options) |
|
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, WG options) |
|
||||||
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) |
|
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) |
|
||||||
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) |
|
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) |
|
||||||
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
|
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
|
||||||
| `IClientConfigBundle` | Full config bundle returned by `createClient()` |
|
| `IClientConfigBundle` | Full config bundle returned by `createClient()` |
|
||||||
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key) |
|
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr) |
|
||||||
| `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health |
|
| `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health |
|
||||||
| `IVpnKeypair` | Base64-encoded public/private key pair |
|
| `IVpnKeypair` | Base64-encoded public/private key pair |
|
||||||
|
|
||||||
@@ -314,7 +341,7 @@ pnpm install
|
|||||||
# Build (TypeScript + Rust cross-compile)
|
# Build (TypeScript + Rust cross-compile)
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Run all tests (79 TS + 121 Rust = 200 tests)
|
# Run all tests (79 TS + 129 Rust = 208 tests)
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|
||||||
# Run Rust tests directly
|
# Run Rust tests directly
|
||||||
@@ -345,6 +372,7 @@ smartvpn/
|
|||||||
│ ├── crypto.rs # Noise IK + XChaCha20
|
│ ├── crypto.rs # Noise IK + XChaCha20
|
||||||
│ ├── client_registry.rs # Client database
|
│ ├── client_registry.rs # Client database
|
||||||
│ ├── acl.rs # ACL engine
|
│ ├── acl.rs # ACL engine
|
||||||
|
│ ├── proxy_protocol.rs # PROXY protocol v2 parser
|
||||||
│ ├── management.rs # JSON-lines IPC
|
│ ├── management.rs # JSON-lines IPC
|
||||||
│ ├── transport.rs # WebSocket transport
|
│ ├── transport.rs # WebSocket transport
|
||||||
│ ├── quic_transport.rs # QUIC transport
|
│ ├── quic_transport.rs # QUIC transport
|
||||||
|
|||||||
582
readme.plan.md
582
readme.plan.md
@@ -1,444 +1,236 @@
|
|||||||
# Enterprise Auth & Client Management for SmartVPN
|
# PROXY Protocol v2 Support for SmartVPN WebSocket Transport
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
SmartVPN's Noise NK mode currently allows **any client that knows the server's public key** to connect — no per-client identity or access control. The goal is to make SmartVPN enterprise-ready with:
|
SmartVPN's WebSocket transport is designed to sit behind reverse proxies (Cloudflare, HAProxy, SmartProxy). The recently added ACL engine has `ipAllowList`/`ipBlockList` per client, but without PROXY protocol support the server only sees the proxy's IP — not the real client's. This makes source-IP ACLs useless behind a proxy.
|
||||||
|
|
||||||
1. **Per-client cryptographic authentication** (Noise IK handshake)
|
PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade.
|
||||||
2. **Rich client definitions** with ACLs, rate limits, and priority
|
|
||||||
3. **Hub-generated configs** — server generates typed SmartVPN client configs AND WireGuard .conf files from the same client definition
|
|
||||||
4. **Top-notch DX** — one `createClient()` call gives you everything
|
|
||||||
|
|
||||||
**This is a breaking change.** No backward compatibility with the old NK anonymous mode.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Design Overview
|
## Design
|
||||||
|
|
||||||
### The Hub Model
|
### Two-Phase ACL with Real Client IP
|
||||||
|
|
||||||
The server acts as a **hub** that manages client definitions. Each client definition is the **single source of truth** from which both SmartVPN native configs and WireGuard configs are generated.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Hub (Server)
|
TCP accept → Read PP v2 header → Extract real IP
|
||||||
└── Client Registry
|
│
|
||||||
├── "alice-laptop" → SmartVPN config OR WireGuard .conf
|
├─ Phase 1 (pre-handshake): Check server-level connectionIpBlockList → reject early
|
||||||
├── "bob-phone" → SmartVPN config OR WireGuard .conf
|
│
|
||||||
└── "office-gw" → SmartVPN config OR WireGuard .conf
|
├─ WebSocket upgrade → Noise IK handshake → Client identity known
|
||||||
|
│
|
||||||
|
└─ Phase 2 (post-handshake): Check per-client ipAllowList/ipBlockList → reject if denied
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication: NK → IK (Breaking Change)
|
- **Phase 1**: Server-wide block list (`connectionIpBlockList` on `IVpnServerConfig`). Rejects before any crypto work. Protects server resources.
|
||||||
|
- **Phase 2**: Per-client ACL from `IClientSecurity.ipAllowList`/`ipBlockList`. Applied after the Noise IK handshake identifies the client.
|
||||||
|
|
||||||
**Old (removed):** `Noise_NK_25519_ChaChaPoly_BLAKE2s` — client is anonymous
|
### No New Dependencies
|
||||||
**New (always):** `Noise_IK_25519_ChaChaPoly_BLAKE2s` — client presents its static key during handshake
|
|
||||||
|
|
||||||
IK is a 2-message handshake (same count as NK), so **the frame protocol stays identical**. Changes:
|
PROXY protocol v2 is a fixed-format binary header (16-byte signature + variable address block). Manual parsing (~80 lines) follows the same pattern as `codec.rs`. No crate needed.
|
||||||
- `create_initiator()` now requires `(client_private_key, server_public_key)` — always
|
|
||||||
- `create_responder()` remains `(server_private_key)` — but now uses IK pattern
|
|
||||||
- After handshake, server extracts client's public key via `get_remote_static()` and verifies against registry
|
|
||||||
- Old NK functions are replaced, not kept alongside
|
|
||||||
|
|
||||||
**Every client must have a keypair. Every server must have a client registry.**
|
### Scope: WebSocket Only
|
||||||
|
|
||||||
|
- **WebSocket**: Needs PP v2 (sits behind reverse proxies)
|
||||||
|
- **QUIC**: Direct UDP, just use `conn.remote_address()`
|
||||||
|
- **WireGuard**: Direct UDP, uses boringtun peer tracking
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Interface: `IClientEntry`
|
## Implementation
|
||||||
|
|
||||||
This is the server-side client definition — the central config object.
|
### Phase 1: New Rust module `proxy_protocol.rs`
|
||||||
Naming and structure are aligned with SmartProxy's `IRouteConfig` / `IRouteSecurity` patterns.
|
|
||||||
|
|
||||||
```typescript
|
**New file: `rust/src/proxy_protocol.rs`**
|
||||||
export interface IClientEntry {
|
|
||||||
/** Human-readable client ID (e.g. "alice-laptop") */
|
|
||||||
clientId: string;
|
|
||||||
|
|
||||||
/** Client's Noise IK public key (base64) — for SmartVPN native transport */
|
PP v2 binary format:
|
||||||
publicKey: string;
|
|
||||||
/** Client's WireGuard public key (base64) — for WireGuard transport */
|
|
||||||
wgPublicKey?: string;
|
|
||||||
|
|
||||||
// ── Security (aligned with SmartProxy IRouteSecurity pattern) ─────────
|
|
||||||
|
|
||||||
security?: IClientSecurity;
|
|
||||||
|
|
||||||
// ── QoS ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Traffic priority (lower = higher priority, default: 100) */
|
|
||||||
priority?: number;
|
|
||||||
|
|
||||||
// ── Metadata (aligned with SmartProxy IRouteConfig pattern) ────────────
|
|
||||||
|
|
||||||
/** Whether this client is enabled (default: true) */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Tags for grouping (e.g. ["engineering", "office"]) */
|
|
||||||
tags?: string[];
|
|
||||||
/** Optional description */
|
|
||||||
description?: string;
|
|
||||||
/** Optional expiry (ISO 8601 timestamp, omit = never expires) */
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security settings per client — mirrors SmartProxy's IRouteSecurity structure.
|
|
||||||
* Uses the same ipAllowList/ipBlockList naming convention.
|
|
||||||
* Adds VPN-specific destination filtering (destinationAllowList/destinationBlockList).
|
|
||||||
*/
|
|
||||||
export interface IClientSecurity {
|
|
||||||
/** Source IPs/CIDRs the client may connect FROM (empty = any).
|
|
||||||
* Supports: exact IP, CIDR, wildcard (192.168.1.*), ranges (1.1.1.1-1.1.1.5).
|
|
||||||
* Same format as SmartProxy's ipAllowList. */
|
|
||||||
ipAllowList?: string[];
|
|
||||||
/** Source IPs blocked — overrides ipAllowList (deny wins).
|
|
||||||
* Same format as SmartProxy's ipBlockList. */
|
|
||||||
ipBlockList?: string[];
|
|
||||||
/** Destination IPs/CIDRs the client may reach through the VPN (empty = all) */
|
|
||||||
destinationAllowList?: string[];
|
|
||||||
/** Destination IPs blocked — overrides destinationAllowList (deny wins) */
|
|
||||||
destinationBlockList?: string[];
|
|
||||||
/** Max concurrent connections from this client */
|
|
||||||
maxConnections?: number;
|
|
||||||
/** Per-client rate limiting */
|
|
||||||
rateLimit?: IClientRateLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IClientRateLimit {
|
|
||||||
/** Max throughput in bytes/sec */
|
|
||||||
bytesPerSec: number;
|
|
||||||
/** Burst allowance in bytes */
|
|
||||||
burstBytes: number;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
Bytes 0-11: Signature \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
|
||||||
### SmartProxy Alignment Notes
|
Byte 12: Version (high nibble = 0x2) | Command (low nibble: 0x0=LOCAL, 0x1=PROXY)
|
||||||
|
Byte 13: Address family | Protocol (0x11 = IPv4/TCP, 0x21 = IPv6/TCP)
|
||||||
| Pattern | SmartProxy | SmartVPN |
|
Bytes 14-15: Address data length (big-endian u16)
|
||||||
|---------|-----------|---------|
|
Bytes 16+: IPv4: 4 src_ip + 4 dst_ip + 2 src_port + 2 dst_port (12 bytes)
|
||||||
| ACL naming | `ipAllowList` / `ipBlockList` | Same — `ipAllowList` / `ipBlockList` |
|
IPv6: 16 src_ip + 16 dst_ip + 2 src_port + 2 dst_port (36 bytes)
|
||||||
| Security grouping | `security: IRouteSecurity` sub-object | Same — `security: IClientSecurity` sub-object |
|
|
||||||
| Rate limit structure | `rateLimit: IRouteRateLimit` object | Same pattern — `rateLimit: IClientRateLimit` object |
|
|
||||||
| IP format support | Exact, CIDR, wildcard, ranges | Same formats |
|
|
||||||
| Metadata fields | `priority`, `tags`, `enabled`, `description` | Same fields |
|
|
||||||
| ACL evaluation | Block-first, then allow-list | Same — deny overrides allow |
|
|
||||||
|
|
||||||
### ACL Evaluation Order
|
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Check ipBlockList / destinationBlockList first (explicit deny wins)
|
|
||||||
2. If denied, DROP
|
|
||||||
3. Check ipAllowList / destinationAllowList (explicit allow)
|
|
||||||
4. If ipAllowList is empty → allow any source
|
|
||||||
5. If destinationAllowList is empty → allow all destinations
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hub Config Generation
|
|
||||||
|
|
||||||
### `createClient()` — The One-Call DX
|
|
||||||
|
|
||||||
When the hub creates a client, it:
|
|
||||||
1. Generates a Noise IK keypair for the client
|
|
||||||
2. Generates a WireGuard keypair for the client
|
|
||||||
3. Allocates a VPN IP address
|
|
||||||
4. Stores the `IClientEntry` in the registry
|
|
||||||
5. Returns a **complete config bundle** with everything the client needs
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface IClientConfigBundle {
|
|
||||||
/** The server-side client entry */
|
|
||||||
entry: IClientEntry;
|
|
||||||
/** Ready-to-use SmartVPN client config (typed object) */
|
|
||||||
smartvpnConfig: IVpnClientConfig;
|
|
||||||
/** Ready-to-use WireGuard .conf file content (string) */
|
|
||||||
wireguardConfig: string;
|
|
||||||
/** Client's private keys (ONLY returned at creation time, not stored server-side) */
|
|
||||||
secrets: {
|
|
||||||
noisePrivateKey: string;
|
|
||||||
wgPrivateKey: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `secrets` are returned **only at creation time** — the server stores only public keys.
|
|
||||||
|
|
||||||
### `exportClientConfig()` — Re-export (without secrets)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): IVpnClientConfig | string
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updated `IVpnServerConfig`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface IVpnServerConfig {
|
|
||||||
listenAddr: string;
|
|
||||||
tlsCert?: string;
|
|
||||||
tlsKey?: string;
|
|
||||||
privateKey: string; // Server's Noise static private key (base64)
|
|
||||||
publicKey: string; // Server's Noise static public key (base64)
|
|
||||||
subnet: string;
|
|
||||||
dns?: string[];
|
|
||||||
mtu?: number;
|
|
||||||
keepaliveIntervalSecs?: number;
|
|
||||||
enableNat?: boolean;
|
|
||||||
defaultRateLimitBytesPerSec?: number;
|
|
||||||
defaultBurstBytes?: number;
|
|
||||||
transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard';
|
|
||||||
quicListenAddr?: string;
|
|
||||||
quicIdleTimeoutSecs?: number;
|
|
||||||
wgListenPort?: number;
|
|
||||||
wgPeers?: IWgPeerConfig[]; // Keep for raw WG mode
|
|
||||||
|
|
||||||
/** Pre-registered clients — REQUIRED for SmartVPN native transport */
|
|
||||||
clients: IClientEntry[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `clients` is now **required** (not optional), and there is no `authMode` field — IK is always used.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updated `IVpnClientConfig`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface IVpnClientConfig {
|
|
||||||
serverUrl: string;
|
|
||||||
serverPublicKey: string;
|
|
||||||
/** Client's Noise IK private key (base64) — REQUIRED for SmartVPN native transport */
|
|
||||||
clientPrivateKey: string;
|
|
||||||
/** Client's Noise IK public key (base64) — for reference/display */
|
|
||||||
clientPublicKey: string;
|
|
||||||
dns?: string[];
|
|
||||||
mtu?: number;
|
|
||||||
keepaliveIntervalSecs?: number;
|
|
||||||
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
|
|
||||||
serverCertHash?: string;
|
|
||||||
// WireGuard fields unchanged...
|
|
||||||
wgPrivateKey?: string;
|
|
||||||
wgAddress?: string;
|
|
||||||
wgAddressPrefix?: number;
|
|
||||||
wgPresharedKey?: string;
|
|
||||||
wgPersistentKeepalive?: number;
|
|
||||||
wgEndpoint?: string;
|
|
||||||
wgAllowedIps?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `clientPrivateKey` and `clientPublicKey` are now **required** (not optional) for non-WireGuard transports.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New IPC Commands
|
|
||||||
|
|
||||||
Added to `TVpnServerCommands`:
|
|
||||||
|
|
||||||
| Command | Params | Result | Description |
|
|
||||||
|---------|--------|--------|-------------|
|
|
||||||
| `createClient` | `{ client: Partial<IClientEntry> }` | `IClientConfigBundle` | Create client, generate keypairs, assign IP, return full config bundle |
|
|
||||||
| `removeClient` | `{ clientId: string }` | `void` | Remove from registry + disconnect if connected |
|
|
||||||
| `getClient` | `{ clientId: string }` | `IClientEntry` | Get a single client entry |
|
|
||||||
| `listRegisteredClients` | `{}` | `{ clients: IClientEntry[] }` | List all registered clients |
|
|
||||||
| `updateClient` | `{ clientId: string, update: Partial<IClientEntry> }` | `void` | Update ACLs, rate limits, tags, etc. |
|
|
||||||
| `enableClient` | `{ clientId: string }` | `void` | Enable a disabled client |
|
|
||||||
| `disableClient` | `{ clientId: string }` | `void` | Disable (but don't delete) |
|
|
||||||
| `rotateClientKey` | `{ clientId: string }` | `IClientConfigBundle` | New keypairs, return fresh config bundle |
|
|
||||||
| `exportClientConfig` | `{ clientId: string, format: 'smartvpn' \| 'wireguard' }` | `{ config: string }` | Re-export config (without secrets) |
|
|
||||||
| `generateClientKeypair` | `{}` | `IVpnKeypair` | Generate a standalone Noise IK keypair |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Rust — Crypto (Replace NK with IK)
|
|
||||||
|
|
||||||
**File: `rust/src/crypto.rs`**
|
|
||||||
|
|
||||||
- Change `NOISE_PATTERN` from NK to IK: `"Noise_IK_25519_ChaChaPoly_BLAKE2s"`
|
|
||||||
- Replace `create_initiator(server_public_key)` → `create_initiator(client_private_key, server_public_key)`
|
|
||||||
- `create_responder(private_key)` stays the same signature (IK responder only needs its own key)
|
|
||||||
- After handshake, `get_remote_static()` on the responder returns the client's public key
|
|
||||||
- Update `perform_handshake()` to pass client keypair
|
|
||||||
- Update all tests
|
|
||||||
|
|
||||||
### Phase 2: Rust — Client Registry module
|
|
||||||
|
|
||||||
**New file: `rust/src/client_registry.rs`**
|
|
||||||
**Modify: `rust/src/lib.rs`** — add `pub mod client_registry;`
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct ClientEntry {
|
pub struct ProxyHeader {
|
||||||
pub client_id: String,
|
pub src_addr: SocketAddr,
|
||||||
pub public_key: String,
|
pub dst_addr: SocketAddr,
|
||||||
pub wg_public_key: Option<String>,
|
pub is_local: bool, // LOCAL command = health check probe
|
||||||
pub security: Option<ClientSecurity>,
|
|
||||||
pub priority: Option<u32>,
|
|
||||||
pub enabled: Option<bool>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub expires_at: Option<String>,
|
|
||||||
pub assigned_ip: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors IClientSecurity — aligned with SmartProxy's IRouteSecurity
|
/// Read and parse a PROXY protocol v2 header from a TCP stream.
|
||||||
pub struct ClientSecurity {
|
/// Reads exactly the header bytes — the stream is clean for WS upgrade after.
|
||||||
pub ip_allow_list: Option<Vec<String>>,
|
pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader>
|
||||||
pub ip_block_list: Option<Vec<String>>,
|
```
|
||||||
pub destination_allow_list: Option<Vec<String>>,
|
|
||||||
pub destination_block_list: Option<Vec<String>>,
|
|
||||||
pub max_connections: Option<u32>,
|
|
||||||
pub rate_limit: Option<ClientRateLimit>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ClientRateLimit {
|
- 5-second timeout on header read (constant `PROXY_HEADER_TIMEOUT`)
|
||||||
pub bytes_per_sec: u64,
|
- Validates 12-byte signature, version nibble, command type
|
||||||
pub burst_bytes: u64,
|
- Parses IPv4 and IPv6 address blocks
|
||||||
}
|
- LOCAL command returns `is_local: true` (caller closes connection gracefully)
|
||||||
|
- Unit tests: valid IPv4/IPv6 headers, LOCAL command, invalid signature, truncated data
|
||||||
|
|
||||||
pub struct ClientRegistry {
|
**Modify: `rust/src/lib.rs`** — add `pub mod proxy_protocol;`
|
||||||
entries: HashMap<String, ClientEntry>, // keyed by clientId
|
|
||||||
key_index: HashMap<String, String>, // publicKey → clientId (fast lookup)
|
### Phase 2: Server config + client info fields
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `ServerConfig`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```rust
|
||||||
|
/// Enable PROXY protocol v2 parsing on WebSocket connections.
|
||||||
|
/// SECURITY: Must be false when accepting direct client connections.
|
||||||
|
pub proxy_protocol: Option<bool>,
|
||||||
|
/// Server-level IP block list — applied at TCP accept time, before Noise handshake.
|
||||||
|
pub connection_ip_block_list: Option<Vec<String>>,
|
||||||
|
```
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `ClientInfo`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```rust
|
||||||
|
/// Real client IP:port (from PROXY protocol header or direct TCP connection).
|
||||||
|
pub remote_addr: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: ACL helper
|
||||||
|
|
||||||
|
**File: `rust/src/acl.rs`**
|
||||||
|
|
||||||
|
Add a public function for the server-level pre-handshake check:
|
||||||
|
```rust
|
||||||
|
/// Check whether a connection source IP is in a block list.
|
||||||
|
pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
|
||||||
|
ip_matches_any(ip, block_list)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Methods: `add`, `remove`, `get_by_id`, `get_by_key`, `update`, `list`, `is_authorized` (enabled + not expired + key exists), `rotate_key`.
|
(Keeps `ip_matches_any` private; exposes only the specific check needed.)
|
||||||
|
|
||||||
### Phase 3: Rust — ACL enforcement module
|
### Phase 4: WebSocket listener integration
|
||||||
|
|
||||||
**New file: `rust/src/acl.rs`**
|
**File: `rust/src/server.rs` — `run_ws_listener()`**
|
||||||
**Modify: `rust/src/lib.rs`** — add `pub mod acl;`
|
|
||||||
|
Between `listener.accept()` and `transport::accept_connection()`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
/// IP matching supports: exact, CIDR, wildcard, ranges — same as SmartProxy's IpMatcher
|
// Determine real client address
|
||||||
pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult {
|
let remote_addr = if state.config.proxy_protocol.unwrap_or(false) {
|
||||||
// 1. Check ip_block_list / destination_block_list (deny overrides)
|
match proxy_protocol::read_proxy_header(&mut tcp_stream).await {
|
||||||
// 2. Check ip_allow_list / destination_allow_list (explicit allow)
|
Ok(header) if header.is_local => {
|
||||||
// 3. Empty list = allow all
|
// Health check probe — close gracefully
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(header) => {
|
||||||
|
info!("PP v2: real client {} -> {}", header.src_addr, header.dst_addr);
|
||||||
|
Some(header.src_addr)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("PP v2 parse failed from {}: {}", tcp_addr, e);
|
||||||
|
return; // Drop connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(tcp_addr) // Direct connection — use TCP SocketAddr
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-handshake server-level block list check
|
||||||
|
if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if acl::is_connection_blocked(v4, block_list) {
|
||||||
|
warn!("Connection blocked by server IP block list: {}", addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Then proceed with WS upgrade + handle_client_connection as before
|
||||||
```
|
```
|
||||||
|
|
||||||
Called in `server.rs` packet loop after decryption, before forwarding.
|
Key correctness note: `read_proxy_header` reads *exactly* the PP header bytes via `read_exact`. The `TcpStream` is then in a clean state for the WS HTTP upgrade. No buffered wrapper needed.
|
||||||
|
|
||||||
### Phase 4: Rust — Server changes
|
### Phase 5: Update `handle_client_connection` signature
|
||||||
|
|
||||||
**File: `rust/src/server.rs`**
|
**File: `rust/src/server.rs`**
|
||||||
|
|
||||||
- Add `clients: Option<Vec<ClientEntry>>` to `ServerConfig`
|
Change signature:
|
||||||
- Add `client_registry: RwLock<ClientRegistry>` to `ServerState` (no `auth_mode` — always IK)
|
```rust
|
||||||
- Modify `handle_client_connection()`:
|
async fn handle_client_connection(
|
||||||
- Always use `create_responder()` (now IK pattern)
|
state: Arc<ServerState>,
|
||||||
- Call `get_remote_static()` **before** `into_transport_mode()` to get client's public key
|
mut sink: Box<dyn TransportSink>,
|
||||||
- Verify against registry — reject unauthorized clients with Disconnect frame
|
mut stream: Box<dyn TransportStream>,
|
||||||
- Use registry entry for rate limits (overrides server defaults)
|
remote_addr: Option<std::net::SocketAddr>, // NEW
|
||||||
- In packet loop: call `acl::check_acl()` on decrypted packets
|
) -> Result<()>
|
||||||
- Add `ClientInfo.authenticated_key: String` and `ClientInfo.registered_client_id: String` (no longer optional)
|
```
|
||||||
- Add methods: `create_client()`, `remove_client()`, `update_client()`, `list_registered_clients()`, `rotate_client_key()`, `export_client_config()`
|
|
||||||
|
|
||||||
### Phase 5: Rust — Client changes
|
After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL:
|
||||||
|
|
||||||
**File: `rust/src/client.rs`**
|
```rust
|
||||||
|
if let (Some(ref sec), Some(addr)) = (&client_security, &remote_addr) {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if acl::is_connection_blocked(v4, sec.ip_block_list.as_deref().unwrap_or(&[])) {
|
||||||
|
anyhow::bail!("Client {} connection denied: source IP {} blocked", registered_client_id, addr);
|
||||||
|
}
|
||||||
|
if let Some(ref allow) = sec.ip_allow_list {
|
||||||
|
if !allow.is_empty() && !acl::is_ip_allowed(v4, allow) {
|
||||||
|
anyhow::bail!("Client {} connection denied: source IP {} not in allow list", registered_client_id, addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- Add `client_private_key: String` to `ClientConfig` (required, not optional)
|
Populate `remote_addr` when building `ClientInfo`:
|
||||||
- `connect()` always uses `create_initiator(client_private_key, server_public_key)` (IK)
|
```rust
|
||||||
|
remote_addr: remote_addr.map(|a| a.to_string()),
|
||||||
|
```
|
||||||
|
|
||||||
### Phase 6: Rust — Management IPC handlers
|
### Phase 6: QUIC listener — pass remote addr through
|
||||||
|
|
||||||
**File: `rust/src/management.rs`**
|
**File: `rust/src/server.rs` — `run_quic_listener()`**
|
||||||
|
|
||||||
Add handlers for all 10 new IPC commands following existing patterns.
|
QUIC doesn't use PROXY protocol. Just pass `conn.remote_address()` through:
|
||||||
|
```rust
|
||||||
|
let remote = conn.remote_address();
|
||||||
|
// ...
|
||||||
|
handle_client_connection(state, Box::new(sink), Box::new(stream), Some(remote)).await
|
||||||
|
```
|
||||||
|
|
||||||
### Phase 7: TypeScript — Interfaces
|
### Phase 7: TypeScript interface updates
|
||||||
|
|
||||||
**File: `ts/smartvpn.interfaces.ts`**
|
**File: `ts/smartvpn.interfaces.ts`**
|
||||||
|
|
||||||
- Add `IClientEntry` interface
|
Add to `IVpnServerConfig`:
|
||||||
- Add `IClientConfigBundle` interface
|
```typescript
|
||||||
- Update `IVpnServerConfig`: add required `clients: IClientEntry[]`
|
/** Enable PROXY protocol v2 on incoming WebSocket connections.
|
||||||
- Update `IVpnClientConfig`: add required `clientPrivateKey: string`, `clientPublicKey: string`
|
* Required when behind a reverse proxy that sends PP v2 headers. */
|
||||||
- Update `IVpnClientInfo`: add `authenticatedKey: string`, `registeredClientId: string`
|
proxyProtocol?: boolean;
|
||||||
- Add new commands to `TVpnServerCommands`
|
/** Server-level IP block list — applied at TCP accept time, before Noise handshake. */
|
||||||
|
connectionIpBlockList?: string[];
|
||||||
|
```
|
||||||
|
|
||||||
### Phase 8: TypeScript — VpnServer class methods
|
Add to `IVpnClientInfo`:
|
||||||
|
```typescript
|
||||||
|
/** Real client IP:port (from PROXY protocol or direct TCP). */
|
||||||
|
remoteAddr?: string;
|
||||||
|
```
|
||||||
|
|
||||||
**File: `ts/smartvpn.classes.vpnserver.ts`**
|
### Phase 8: Tests
|
||||||
|
|
||||||
Add methods:
|
**Rust unit tests in `proxy_protocol.rs`:**
|
||||||
- `createClient(opts)` → `IClientConfigBundle`
|
- `parse_valid_ipv4_header` — construct a valid PP v2 header with known IPs, verify parsed correctly
|
||||||
- `removeClient(clientId)` → `void`
|
- `parse_valid_ipv6_header` — same for IPv6
|
||||||
- `getClient(clientId)` → `IClientEntry`
|
- `parse_local_command` — health check probe returns `is_local: true`
|
||||||
- `listRegisteredClients()` → `IClientEntry[]`
|
- `reject_invalid_signature` — random bytes rejected
|
||||||
- `updateClient(clientId, update)` → `void`
|
- `reject_truncated_header` — short reads fail gracefully
|
||||||
- `enableClient(clientId)` / `disableClient(clientId)`
|
- `reject_v1_header` — PROXY v1 text format rejected (we only support v2)
|
||||||
- `rotateClientKey(clientId)` → `IClientConfigBundle`
|
|
||||||
- `exportClientConfig(clientId, format)` → `string | IVpnClientConfig`
|
|
||||||
|
|
||||||
### Phase 9: TypeScript — Config validation
|
**Rust unit tests in `acl.rs`:**
|
||||||
|
- `is_connection_blocked` with various IP patterns
|
||||||
|
|
||||||
**File: `ts/smartvpn.classes.vpnconfig.ts`**
|
**TypeScript tests:**
|
||||||
|
- Config validation accepts `proxyProtocol: true` + `connectionIpBlockList`
|
||||||
- Server config: validate `clients` present, each entry has valid `clientId` + `publicKey`
|
|
||||||
- Client config: validate `clientPrivateKey` and `clientPublicKey` present for non-WG transports
|
|
||||||
- Validate CIDRs in ACL fields
|
|
||||||
|
|
||||||
### Phase 10: TypeScript — Hub config generation
|
|
||||||
|
|
||||||
**File: `ts/smartvpn.classes.wgconfig.ts`** (extend existing)
|
|
||||||
|
|
||||||
Add `generateClientConfigFromEntry(entry, serverConfig)` — produces WireGuard .conf from `IClientEntry`.
|
|
||||||
|
|
||||||
### Phase 11: Update existing tests
|
|
||||||
|
|
||||||
All existing tests that use the old NK handshake or old config shapes need updating:
|
|
||||||
- Rust tests in `crypto.rs`, `server.rs`, `client.rs`
|
|
||||||
- TS tests in `test/test.vpnconfig.node.ts`, `test/test.flowcontrol.node.ts`, etc.
|
|
||||||
- Tests now must provide client keypairs and client registry entries
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DX Highlights
|
|
||||||
|
|
||||||
1. **One call to create a client:**
|
|
||||||
```typescript
|
|
||||||
const bundle = await server.createClient({ clientId: 'alice-laptop', tags: ['engineering'] });
|
|
||||||
// bundle.smartvpnConfig — typed SmartVPN client config
|
|
||||||
// bundle.wireguardConfig — standard WireGuard .conf string
|
|
||||||
// bundle.secrets — private keys, shown only at creation time
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Typed config objects throughout** — no raw strings or JSON blobs
|
|
||||||
|
|
||||||
3. **Dual transport from same definition** — register once, connect via SmartVPN or WireGuard
|
|
||||||
|
|
||||||
4. **ACLs are deny-overrides-allow** — intuitive enterprise model
|
|
||||||
|
|
||||||
5. **Hot management** — add/remove/update/disable clients at runtime
|
|
||||||
|
|
||||||
6. **Key rotation** — `rotateClientKey()` generates new keys and returns a fresh config bundle
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Plan
|
|
||||||
|
|
||||||
1. **Rust unit tests:**
|
|
||||||
- `crypto.rs`: IK handshake roundtrip, `get_remote_static()` returns correct key, wrong key fails
|
|
||||||
- `client_registry.rs`: CRUD, `is_authorized` with enabled/disabled/expired
|
|
||||||
- `acl.rs`: allow/deny logic, empty lists, deny-overrides-allow
|
|
||||||
|
|
||||||
2. **Rust integration tests:**
|
|
||||||
- Server accepts authorized client
|
|
||||||
- Server rejects unknown client public key
|
|
||||||
- ACL filtering drops packets to blocked destinations
|
|
||||||
- Runtime `createClient` / `removeClient` works
|
|
||||||
- Disabled client rejected at handshake
|
|
||||||
|
|
||||||
3. **TypeScript tests:**
|
|
||||||
- Config validation with required client fields
|
|
||||||
- `createClient()` returns valid bundle with both formats
|
|
||||||
- `exportClientConfig()` generates valid WireGuard .conf
|
|
||||||
- Full IPC roundtrip: create client → connect → traffic → disconnect
|
|
||||||
|
|
||||||
4. **Build:** `pnpm build` (TS + Rust), `cargo test`, `pnpm test`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -446,14 +238,16 @@ All existing tests that use the old NK handshake or old config shapes need updat
|
|||||||
|
|
||||||
| File | Changes |
|
| File | Changes |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `rust/src/crypto.rs` | Replace NK with IK pattern, update initiator signature |
|
| `rust/src/proxy_protocol.rs` | **NEW** — PP v2 parser + tests |
|
||||||
| `rust/src/client_registry.rs` | **NEW** — client registry module |
|
| `rust/src/lib.rs` | Add `pub mod proxy_protocol;` |
|
||||||
| `rust/src/acl.rs` | **NEW** — ACL evaluation module |
|
| `rust/src/server.rs` | `ServerConfig` + `ClientInfo` fields, `run_ws_listener` PP integration, `handle_client_connection` signature + connection ACL, `run_quic_listener` pass-through |
|
||||||
| `rust/src/server.rs` | Registry integration, IK auth in handshake, ACL in packet loop |
|
| `rust/src/acl.rs` | Add `is_connection_blocked` public function |
|
||||||
| `rust/src/client.rs` | Required `client_private_key`, IK initiator |
|
| `ts/smartvpn.interfaces.ts` | `proxyProtocol`, `connectionIpBlockList`, `remoteAddr` |
|
||||||
| `rust/src/management.rs` | 10 new IPC command handlers |
|
|
||||||
| `rust/src/lib.rs` | Register new modules |
|
---
|
||||||
| `ts/smartvpn.interfaces.ts` | `IClientEntry`, `IClientConfigBundle`, updated configs & commands |
|
|
||||||
| `ts/smartvpn.classes.vpnserver.ts` | New hub methods |
|
## Verification
|
||||||
| `ts/smartvpn.classes.vpnconfig.ts` | Updated validation rules |
|
|
||||||
| `ts/smartvpn.classes.wgconfig.ts` | Config generation from client entries |
|
1. `cargo test` — all existing 121 tests + new PP parser tests pass
|
||||||
|
2. `pnpm test` — all 79 TS tests pass (no PP in test setup, just config validation)
|
||||||
|
3. Manual: `socat` or test harness to send a PP v2 header before WS upgrade, verify server logs real IP
|
||||||
|
|||||||
115
rust/Cargo.lock
generated
115
rust/Cargo.lock
generated
@@ -237,6 +237,12 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -488,6 +494,47 @@ 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 = "defmt"
|
||||||
|
version = "0.3.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||||
|
dependencies = [
|
||||||
|
"defmt 1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro-error2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -714,6 +761,25 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hash32"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heapless"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||||
|
dependencies = [
|
||||||
|
"hash32",
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -915,6 +981,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "managed"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1116,6 +1188,28 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error-attr2"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-error-attr2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1598,6 +1692,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"smoltcp",
|
||||||
"snow",
|
"snow",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1609,6 +1704,20 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smoltcp"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"byteorder",
|
||||||
|
"cfg-if",
|
||||||
|
"defmt 0.3.100",
|
||||||
|
"heapless",
|
||||||
|
"managed",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snow"
|
name = "snow"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -1635,6 +1744,12 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ rustls-pemfile = "2"
|
|||||||
webpki-roots = "1"
|
webpki-roots = "1"
|
||||||
mimalloc = "0.1"
|
mimalloc = "0.1"
|
||||||
boringtun = "0.7"
|
boringtun = "0.7"
|
||||||
|
smoltcp = { version = "0.13", default-features = false, features = ["medium-ip", "proto-ipv4", "socket-tcp", "socket-udp", "alloc"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
ipnet = "2"
|
ipnet = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ pub enum AclResult {
|
|||||||
DenyDst,
|
DenyDst,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether a connection source IP is in a server-level block list.
|
||||||
|
/// Used for pre-handshake rejection of known-bad IPs.
|
||||||
|
pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
|
||||||
|
ip_matches_any(ip, block_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a source IP is allowed by allow/block lists.
|
||||||
|
/// Returns true if the IP is permitted (not blocked and passes allow check).
|
||||||
|
pub fn is_source_allowed(ip: Ipv4Addr, allow_list: Option<&[String]>, block_list: Option<&[String]>) -> bool {
|
||||||
|
// Deny overrides allow
|
||||||
|
if let Some(bl) = block_list {
|
||||||
|
if ip_matches_any(ip, bl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If allow list exists and is non-empty, IP must match
|
||||||
|
if let Some(al) = allow_list {
|
||||||
|
if !al.is_empty() && !ip_matches_any(ip, al) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Check whether a packet from `src_ip` to `dst_ip` is allowed by the client's security policy.
|
/// Check whether a packet from `src_ip` to `dst_ip` is allowed by the client's security policy.
|
||||||
///
|
///
|
||||||
/// Evaluation order (deny overrides allow):
|
/// Evaluation order (deny overrides allow):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
use tracing::{info, error, warn, debug};
|
use tracing::{info, error, warn, debug};
|
||||||
@@ -12,6 +13,7 @@ use crate::telemetry::ConnectionQuality;
|
|||||||
use crate::transport;
|
use crate::transport;
|
||||||
use crate::transport_trait::{self, TransportSink, TransportStream};
|
use crate::transport_trait::{self, TransportSink, TransportStream};
|
||||||
use crate::quic_transport;
|
use crate::quic_transport;
|
||||||
|
use crate::tunnel::{self, TunConfig};
|
||||||
|
|
||||||
/// Client configuration (matches TS IVpnClientConfig).
|
/// Client configuration (matches TS IVpnClientConfig).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -30,6 +32,9 @@ pub struct ClientConfig {
|
|||||||
pub transport: Option<String>,
|
pub transport: Option<String>,
|
||||||
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
|
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
|
||||||
pub server_cert_hash: Option<String>,
|
pub server_cert_hash: Option<String>,
|
||||||
|
/// Forwarding mode: "tun" (TUN device, requires root) or "testing" (no TUN).
|
||||||
|
/// Default: "testing".
|
||||||
|
pub forwarding_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client statistics.
|
/// Client statistics.
|
||||||
@@ -234,6 +239,31 @@ impl VpnClient {
|
|||||||
|
|
||||||
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
||||||
|
|
||||||
|
// Optionally create TUN device for IP packet forwarding (requires root)
|
||||||
|
let tun_enabled = config.forwarding_mode.as_deref() == Some("tun");
|
||||||
|
let (tun_reader, tun_writer, tun_subnet) = if tun_enabled {
|
||||||
|
let client_tun_ip: Ipv4Addr = assigned_ip.parse()?;
|
||||||
|
let mtu = ip_info["mtu"].as_u64().unwrap_or(1420) as u16;
|
||||||
|
let tun_config = TunConfig {
|
||||||
|
name: "svpn-client0".to_string(),
|
||||||
|
address: client_tun_ip,
|
||||||
|
netmask: Ipv4Addr::new(255, 255, 255, 0),
|
||||||
|
mtu,
|
||||||
|
};
|
||||||
|
let tun_device = tunnel::create_tun(&tun_config)?;
|
||||||
|
|
||||||
|
// Add route for VPN subnet through the TUN device
|
||||||
|
let gateway_str = ip_info["gateway"].as_str().unwrap_or("10.8.0.1");
|
||||||
|
let gateway: Ipv4Addr = gateway_str.parse().unwrap_or(Ipv4Addr::new(10, 8, 0, 1));
|
||||||
|
let subnet = format!("{}/24", Ipv4Addr::from(u32::from(gateway) & 0xFFFFFF00));
|
||||||
|
tunnel::add_route(&subnet, &tun_config.name).await?;
|
||||||
|
|
||||||
|
let (reader, writer) = tokio::io::split(tun_device);
|
||||||
|
(Some(reader), Some(writer), Some(subnet))
|
||||||
|
} else {
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Create adaptive keepalive monitor (use custom interval if configured)
|
// Create adaptive keepalive monitor (use custom interval if configured)
|
||||||
let ka_config = config.keepalive_interval_secs.map(|secs| {
|
let ka_config = config.keepalive_interval_secs.map(|secs| {
|
||||||
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
|
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
|
||||||
@@ -260,6 +290,9 @@ impl VpnClient {
|
|||||||
handle.signal_rx,
|
handle.signal_rx,
|
||||||
handle.ack_tx,
|
handle.ack_tx,
|
||||||
link_health,
|
link_health,
|
||||||
|
tun_reader,
|
||||||
|
tun_writer,
|
||||||
|
tun_subnet,
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(assigned_ip_clone)
|
Ok(assigned_ip_clone)
|
||||||
@@ -356,8 +389,14 @@ async fn client_loop(
|
|||||||
mut signal_rx: mpsc::Receiver<KeepaliveSignal>,
|
mut signal_rx: mpsc::Receiver<KeepaliveSignal>,
|
||||||
ack_tx: mpsc::Sender<()>,
|
ack_tx: mpsc::Sender<()>,
|
||||||
link_health: Arc<RwLock<LinkHealth>>,
|
link_health: Arc<RwLock<LinkHealth>>,
|
||||||
|
mut tun_reader: Option<tokio::io::ReadHalf<tun::AsyncDevice>>,
|
||||||
|
mut tun_writer: Option<tokio::io::WriteHalf<tun::AsyncDevice>>,
|
||||||
|
tun_subnet: Option<String>,
|
||||||
) {
|
) {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
|
let mut tun_buf = vec![0u8; 65536];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -373,6 +412,14 @@ async fn client_loop(
|
|||||||
let mut s = stats.write().await;
|
let mut s = stats.write().await;
|
||||||
s.bytes_received += len as u64;
|
s.bytes_received += len as u64;
|
||||||
s.packets_received += 1;
|
s.packets_received += 1;
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
// Write decrypted packet to TUN device (if enabled)
|
||||||
|
if let Some(ref mut writer) = tun_writer {
|
||||||
|
if let Err(e) = writer.write_all(&buf[..len]).await {
|
||||||
|
warn!("TUN write error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Decrypt error: {}", e);
|
warn!("Decrypt error: {}", e);
|
||||||
@@ -407,6 +454,50 @@ async fn client_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Read outbound packets from TUN and send to server (only when TUN enabled)
|
||||||
|
result = async {
|
||||||
|
match tun_reader {
|
||||||
|
Some(ref mut reader) => reader.read(&mut tun_buf).await,
|
||||||
|
None => std::future::pending::<std::io::Result<usize>>().await,
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
match result {
|
||||||
|
Ok(0) => {
|
||||||
|
info!("TUN device closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
match noise_transport.write_message(&tun_buf[..n], &mut buf) {
|
||||||
|
Ok(len) => {
|
||||||
|
let frame = Frame {
|
||||||
|
packet_type: PacketType::IpPacket,
|
||||||
|
payload: buf[..len].to_vec(),
|
||||||
|
};
|
||||||
|
let mut frame_bytes = BytesMut::new();
|
||||||
|
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(
|
||||||
|
&mut FrameCodec, frame, &mut frame_bytes
|
||||||
|
).is_ok() {
|
||||||
|
if sink.send_reliable(frame_bytes.to_vec()).await.is_err() {
|
||||||
|
warn!("Failed to send TUN packet to server");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut s = stats.write().await;
|
||||||
|
s.bytes_sent += n as u64;
|
||||||
|
s.packets_sent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Noise encrypt error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("TUN read error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
signal = signal_rx.recv() => {
|
signal = signal_rx.recv() => {
|
||||||
match signal {
|
match signal {
|
||||||
Some(KeepaliveSignal::SendPing(timestamp_ms)) => {
|
Some(KeepaliveSignal::SendPing(timestamp_ms)) => {
|
||||||
@@ -456,6 +547,13 @@ async fn client_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup: remove TUN route if enabled
|
||||||
|
if let Some(ref subnet) = tun_subnet {
|
||||||
|
if let Err(e) = tunnel::remove_route(subnet, "svpn-client0").await {
|
||||||
|
warn!("Failed to remove client TUN route: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to connect via QUIC. Returns transport halves on success.
|
/// Try to connect via QUIC. Returns transport halves on success.
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ pub mod mtu;
|
|||||||
pub mod wireguard;
|
pub mod wireguard;
|
||||||
pub mod client_registry;
|
pub mod client_registry;
|
||||||
pub mod acl;
|
pub mod acl;
|
||||||
|
pub mod proxy_protocol;
|
||||||
|
pub mod userspace_nat;
|
||||||
|
|||||||
261
rust/src/proxy_protocol.rs
Normal file
261
rust/src/proxy_protocol.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
//! PROXY protocol v2 parser for extracting real client addresses
|
||||||
|
//! when SmartVPN sits behind a reverse proxy (HAProxy, SmartProxy, etc.).
|
||||||
|
//!
|
||||||
|
//! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt>
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
/// Timeout for reading the PROXY protocol header from a new connection.
|
||||||
|
const PROXY_HEADER_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// The 12-byte PP v2 signature.
|
||||||
|
const PP_V2_SIGNATURE: [u8; 12] = [
|
||||||
|
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parsed PROXY protocol v2 header.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProxyHeader {
|
||||||
|
/// Real client source address.
|
||||||
|
pub src_addr: SocketAddr,
|
||||||
|
/// Proxy-to-server destination address.
|
||||||
|
pub dst_addr: SocketAddr,
|
||||||
|
/// True if this is a LOCAL command (health check probe from proxy).
|
||||||
|
pub is_local: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse a PROXY protocol v2 header from a TCP stream.
|
||||||
|
///
|
||||||
|
/// Reads exactly the header bytes — the stream is in a clean state for
|
||||||
|
/// WebSocket upgrade afterward. Returns an error on timeout, invalid
|
||||||
|
/// signature, or malformed header.
|
||||||
|
pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader> {
|
||||||
|
tokio::time::timeout(PROXY_HEADER_TIMEOUT, read_proxy_header_inner(stream))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("PROXY protocol header read timed out ({}s)", PROXY_HEADER_TIMEOUT.as_secs()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_proxy_header_inner(stream: &mut TcpStream) -> Result<ProxyHeader> {
|
||||||
|
// Read the 16-byte fixed prefix
|
||||||
|
let mut prefix = [0u8; 16];
|
||||||
|
stream.read_exact(&mut prefix).await?;
|
||||||
|
|
||||||
|
// Validate the 12-byte signature
|
||||||
|
if prefix[..12] != PP_V2_SIGNATURE {
|
||||||
|
anyhow::bail!("Invalid PROXY protocol v2 signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte 12: version (high nibble) | command (low nibble)
|
||||||
|
let version = (prefix[12] & 0xF0) >> 4;
|
||||||
|
let command = prefix[12] & 0x0F;
|
||||||
|
|
||||||
|
if version != 2 {
|
||||||
|
anyhow::bail!("Unsupported PROXY protocol version: {}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte 13: address family (high nibble) | protocol (low nibble)
|
||||||
|
let addr_family = (prefix[13] & 0xF0) >> 4;
|
||||||
|
let _protocol = prefix[13] & 0x0F; // 1 = STREAM (TCP)
|
||||||
|
|
||||||
|
// Bytes 14-15: address data length (big-endian)
|
||||||
|
let addr_len = u16::from_be_bytes([prefix[14], prefix[15]]) as usize;
|
||||||
|
|
||||||
|
// Read the address data
|
||||||
|
let mut addr_data = vec![0u8; addr_len];
|
||||||
|
if addr_len > 0 {
|
||||||
|
stream.read_exact(&mut addr_data).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOCAL command (0x00) = health check, no real address
|
||||||
|
if command == 0x00 {
|
||||||
|
return Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)),
|
||||||
|
dst_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)),
|
||||||
|
is_local: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROXY command (0x01) — parse address block
|
||||||
|
if command != 0x01 {
|
||||||
|
anyhow::bail!("Unknown PROXY protocol command: {}", command);
|
||||||
|
}
|
||||||
|
|
||||||
|
match addr_family {
|
||||||
|
// AF_INET (IPv4): 4 src + 4 dst + 2 src_port + 2 dst_port = 12 bytes
|
||||||
|
1 => {
|
||||||
|
if addr_data.len() < 12 {
|
||||||
|
anyhow::bail!("IPv4 address block too short: {} bytes", addr_data.len());
|
||||||
|
}
|
||||||
|
let src_ip = Ipv4Addr::new(addr_data[0], addr_data[1], addr_data[2], addr_data[3]);
|
||||||
|
let dst_ip = Ipv4Addr::new(addr_data[4], addr_data[5], addr_data[6], addr_data[7]);
|
||||||
|
let src_port = u16::from_be_bytes([addr_data[8], addr_data[9]]);
|
||||||
|
let dst_port = u16::from_be_bytes([addr_data[10], addr_data[11]]);
|
||||||
|
Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V4(SocketAddrV4::new(src_ip, src_port)),
|
||||||
|
dst_addr: SocketAddr::V4(SocketAddrV4::new(dst_ip, dst_port)),
|
||||||
|
is_local: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// AF_INET6 (IPv6): 16 src + 16 dst + 2 src_port + 2 dst_port = 36 bytes
|
||||||
|
2 => {
|
||||||
|
if addr_data.len() < 36 {
|
||||||
|
anyhow::bail!("IPv6 address block too short: {} bytes", addr_data.len());
|
||||||
|
}
|
||||||
|
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[0..16]).unwrap());
|
||||||
|
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[16..32]).unwrap());
|
||||||
|
let src_port = u16::from_be_bytes([addr_data[32], addr_data[33]]);
|
||||||
|
let dst_port = u16::from_be_bytes([addr_data[34], addr_data[35]]);
|
||||||
|
Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V6(SocketAddrV6::new(src_ip, src_port, 0, 0)),
|
||||||
|
dst_addr: SocketAddr::V6(SocketAddrV6::new(dst_ip, dst_port, 0, 0)),
|
||||||
|
is_local: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// AF_UNSPEC or unknown
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!("Unsupported address family: {}", addr_family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a PROXY protocol v2 header (for testing / proxy implementations).
|
||||||
|
pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&PP_V2_SIGNATURE);
|
||||||
|
|
||||||
|
match (src, dst) {
|
||||||
|
(SocketAddr::V4(s), SocketAddr::V4(d)) => {
|
||||||
|
buf.push(0x21); // version 2 | PROXY command
|
||||||
|
buf.push(0x11); // AF_INET | STREAM
|
||||||
|
buf.extend_from_slice(&12u16.to_be_bytes()); // addr length
|
||||||
|
buf.extend_from_slice(&s.ip().octets());
|
||||||
|
buf.extend_from_slice(&d.ip().octets());
|
||||||
|
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||||
|
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||||
|
}
|
||||||
|
(SocketAddr::V6(s), SocketAddr::V6(d)) => {
|
||||||
|
buf.push(0x21); // version 2 | PROXY command
|
||||||
|
buf.push(0x21); // AF_INET6 | STREAM
|
||||||
|
buf.extend_from_slice(&36u16.to_be_bytes()); // addr length
|
||||||
|
buf.extend_from_slice(&s.ip().octets());
|
||||||
|
buf.extend_from_slice(&d.ip().octets());
|
||||||
|
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||||
|
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||||
|
}
|
||||||
|
_ => panic!("Mismatched address families"),
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a PROXY protocol v2 LOCAL header (health check probe).
|
||||||
|
pub fn build_pp_v2_local() -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&PP_V2_SIGNATURE);
|
||||||
|
buf.push(0x20); // version 2 | LOCAL command
|
||||||
|
buf.push(0x00); // AF_UNSPEC
|
||||||
|
buf.extend_from_slice(&0u16.to_be_bytes()); // no address data
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Helper: create a TCP pair and write data to the client side, then parse from server side.
|
||||||
|
async fn parse_header_from_bytes(header_bytes: &[u8]) -> Result<ProxyHeader> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let data = header_bytes.to_vec();
|
||||||
|
let client_task = tokio::spawn(async move {
|
||||||
|
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||||
|
client.write_all(&data).await.unwrap();
|
||||||
|
client // keep alive
|
||||||
|
});
|
||||||
|
|
||||||
|
let (mut server_stream, _) = listener.accept().await.unwrap();
|
||||||
|
let result = read_proxy_header(&mut server_stream).await;
|
||||||
|
let _client = client_task.await.unwrap();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_valid_ipv4_header() {
|
||||||
|
let src = "203.0.113.50:12345".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "10.0.0.1:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(!parsed.is_local);
|
||||||
|
assert_eq!(parsed.src_addr, src);
|
||||||
|
assert_eq!(parsed.dst_addr, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_valid_ipv6_header() {
|
||||||
|
let src = "[2001:db8::1]:54321".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "[2001:db8::2]:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(!parsed.is_local);
|
||||||
|
assert_eq!(parsed.src_addr, src);
|
||||||
|
assert_eq!(parsed.dst_addr, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_local_command() {
|
||||||
|
let header = build_pp_v2_local();
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(parsed.is_local);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_invalid_signature() {
|
||||||
|
let mut header = build_pp_v2_local();
|
||||||
|
header[0] = 0xFF; // corrupt signature
|
||||||
|
let result = parse_header_from_bytes(&header).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_wrong_version() {
|
||||||
|
let mut header = build_pp_v2_local();
|
||||||
|
header[12] = 0x10; // version 1 instead of 2
|
||||||
|
let result = parse_header_from_bytes(&header).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_truncated_header() {
|
||||||
|
// Only 10 bytes — not even the full signature
|
||||||
|
let result = parse_header_from_bytes(&[0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49]).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipv4_header_is_exactly_28_bytes() {
|
||||||
|
let src = "1.2.3.4:80".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "5.6.7.8:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
// 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 12 addrs = 28
|
||||||
|
assert_eq!(header.len(), 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipv6_header_is_exactly_52_bytes() {
|
||||||
|
let src = "[::1]:80".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "[::2]:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
// 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 36 addrs = 52
|
||||||
|
assert_eq!(header.len(), 52);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ use crate::ratelimit::TokenBucket;
|
|||||||
use crate::transport;
|
use crate::transport;
|
||||||
use crate::transport_trait::{self, TransportSink, TransportStream};
|
use crate::transport_trait::{self, TransportSink, TransportStream};
|
||||||
use crate::quic_transport;
|
use crate::quic_transport;
|
||||||
|
use crate::tunnel::{self, TunConfig};
|
||||||
|
|
||||||
/// 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);
|
||||||
@@ -37,6 +38,9 @@ pub struct ServerConfig {
|
|||||||
pub mtu: Option<u16>,
|
pub mtu: Option<u16>,
|
||||||
pub keepalive_interval_secs: Option<u64>,
|
pub keepalive_interval_secs: Option<u64>,
|
||||||
pub enable_nat: Option<bool>,
|
pub enable_nat: Option<bool>,
|
||||||
|
/// Forwarding mode: "tun" (kernel TUN, requires root), "socket" (userspace NAT),
|
||||||
|
/// or "testing" (monitoring only, no forwarding). Default: "testing".
|
||||||
|
pub forwarding_mode: Option<String>,
|
||||||
/// Default rate limit for new clients (bytes/sec). None = unlimited.
|
/// Default rate limit for new clients (bytes/sec). None = unlimited.
|
||||||
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.
|
||||||
@@ -49,6 +53,11 @@ pub struct ServerConfig {
|
|||||||
pub quic_idle_timeout_secs: Option<u64>,
|
pub quic_idle_timeout_secs: Option<u64>,
|
||||||
/// Pre-registered clients for IK authentication.
|
/// Pre-registered clients for IK authentication.
|
||||||
pub clients: Option<Vec<ClientEntry>>,
|
pub clients: Option<Vec<ClientEntry>>,
|
||||||
|
/// Enable PROXY protocol v2 parsing on incoming WebSocket connections.
|
||||||
|
/// SECURITY: Must be false when accepting direct client connections.
|
||||||
|
pub proxy_protocol: Option<bool>,
|
||||||
|
/// Server-level IP block list — applied at TCP accept, before Noise handshake.
|
||||||
|
pub connection_ip_block_list: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a connected client.
|
/// Information about a connected client.
|
||||||
@@ -70,6 +79,8 @@ pub struct ClientInfo {
|
|||||||
pub authenticated_key: String,
|
pub authenticated_key: String,
|
||||||
/// Registered client ID from the client registry.
|
/// Registered client ID from the client registry.
|
||||||
pub registered_client_id: String,
|
pub registered_client_id: String,
|
||||||
|
/// Real client IP:port (from PROXY protocol header or direct TCP connection).
|
||||||
|
pub remote_addr: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server statistics.
|
/// Server statistics.
|
||||||
@@ -87,6 +98,16 @@ pub struct ServerStatistics {
|
|||||||
pub total_connections: u64,
|
pub total_connections: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The forwarding engine determines how decrypted IP packets are routed.
|
||||||
|
pub enum ForwardingEngine {
|
||||||
|
/// Kernel TUN device — packets written to the TUN, kernel handles routing.
|
||||||
|
Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
|
||||||
|
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
|
||||||
|
Socket(mpsc::Sender<Vec<u8>>),
|
||||||
|
/// Testing/monitoring — packets are counted but not forwarded.
|
||||||
|
Testing,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared server state.
|
/// Shared server state.
|
||||||
pub struct ServerState {
|
pub struct ServerState {
|
||||||
pub config: ServerConfig,
|
pub config: ServerConfig,
|
||||||
@@ -97,6 +118,12 @@ pub struct ServerState {
|
|||||||
pub mtu_config: MtuConfig,
|
pub mtu_config: MtuConfig,
|
||||||
pub started_at: std::time::Instant,
|
pub started_at: std::time::Instant,
|
||||||
pub client_registry: RwLock<ClientRegistry>,
|
pub client_registry: RwLock<ClientRegistry>,
|
||||||
|
/// The forwarding engine for decrypted IP packets.
|
||||||
|
pub forwarding_engine: Mutex<ForwardingEngine>,
|
||||||
|
/// Routing table: assigned VPN IP → channel sender for return packets.
|
||||||
|
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
|
||||||
|
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
|
||||||
|
pub tun_shutdown: mpsc::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The VPN server.
|
/// The VPN server.
|
||||||
@@ -132,6 +159,51 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let link_mtu = config.mtu.unwrap_or(1420);
|
let link_mtu = config.mtu.unwrap_or(1420);
|
||||||
|
let mode = config.forwarding_mode.as_deref().unwrap_or("testing");
|
||||||
|
let gateway_ip = ip_pool.gateway_addr();
|
||||||
|
|
||||||
|
// Create forwarding engine based on mode
|
||||||
|
enum ForwardingSetup {
|
||||||
|
Tun {
|
||||||
|
writer: tokio::io::WriteHalf<tun::AsyncDevice>,
|
||||||
|
reader: tokio::io::ReadHalf<tun::AsyncDevice>,
|
||||||
|
shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
},
|
||||||
|
Socket {
|
||||||
|
packet_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
},
|
||||||
|
Testing,
|
||||||
|
}
|
||||||
|
|
||||||
|
let (setup, fwd_shutdown_tx) = match mode {
|
||||||
|
"tun" => {
|
||||||
|
let tun_config = TunConfig {
|
||||||
|
name: "svpn0".to_string(),
|
||||||
|
address: gateway_ip,
|
||||||
|
netmask: Ipv4Addr::new(255, 255, 255, 0),
|
||||||
|
mtu: link_mtu,
|
||||||
|
};
|
||||||
|
let tun_device = tunnel::create_tun(&tun_config)?;
|
||||||
|
tunnel::add_route(&config.subnet, &tun_config.name).await?;
|
||||||
|
let (reader, writer) = tokio::io::split(tun_device);
|
||||||
|
let (tx, rx) = mpsc::channel::<()>(1);
|
||||||
|
(ForwardingSetup::Tun { writer, reader, shutdown_rx: rx }, tx)
|
||||||
|
}
|
||||||
|
"socket" => {
|
||||||
|
info!("Starting userspace NAT forwarding (no root required)");
|
||||||
|
let (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||||
|
let (tx, rx) = mpsc::channel::<()>(1);
|
||||||
|
(ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
info!("Forwarding disabled (testing/monitoring mode)");
|
||||||
|
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||||
|
(ForwardingSetup::Testing, tx)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Compute effective MTU from overhead
|
// Compute effective MTU from overhead
|
||||||
let overhead = TunnelOverhead::default_overhead();
|
let overhead = TunnelOverhead::default_overhead();
|
||||||
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu));
|
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu));
|
||||||
@@ -151,8 +223,38 @@ impl VpnServer {
|
|||||||
mtu_config,
|
mtu_config,
|
||||||
started_at: std::time::Instant::now(),
|
started_at: std::time::Instant::now(),
|
||||||
client_registry: RwLock::new(registry),
|
client_registry: RwLock::new(registry),
|
||||||
|
forwarding_engine: Mutex::new(ForwardingEngine::Testing),
|
||||||
|
tun_routes: RwLock::new(HashMap::new()),
|
||||||
|
tun_shutdown: fwd_shutdown_tx,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn the forwarding background task and set the engine
|
||||||
|
match setup {
|
||||||
|
ForwardingSetup::Tun { writer, reader, shutdown_rx } => {
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Tun(writer);
|
||||||
|
let tun_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = run_tun_reader(tun_state, reader, shutdown_rx).await {
|
||||||
|
error!("TUN reader error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx);
|
||||||
|
let nat_engine = crate::userspace_nat::NatEngine::new(
|
||||||
|
gateway_ip,
|
||||||
|
link_mtu as usize,
|
||||||
|
state.clone(),
|
||||||
|
);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await {
|
||||||
|
error!("NAT engine error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ForwardingSetup::Testing => {}
|
||||||
|
}
|
||||||
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
self.state = Some(state.clone());
|
self.state = Some(state.clone());
|
||||||
self.shutdown_tx = Some(shutdown_tx);
|
self.shutdown_tx = Some(shutdown_tx);
|
||||||
@@ -213,6 +315,34 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&mut self) -> Result<()> {
|
pub async fn stop(&mut self) -> Result<()> {
|
||||||
|
if let Some(ref state) = self.state {
|
||||||
|
let mode = state.config.forwarding_mode.as_deref().unwrap_or("testing");
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
"tun" => {
|
||||||
|
let _ = state.tun_shutdown.send(()).await;
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
||||||
|
if let Err(e) = tunnel::remove_route(&state.config.subnet, "svpn0").await {
|
||||||
|
warn!("Failed to remove TUN route: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"socket" => {
|
||||||
|
let _ = state.tun_shutdown.send(()).await;
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up NAT rules
|
||||||
|
if state.config.enable_nat.unwrap_or(false) {
|
||||||
|
if let Ok(iface) = crate::network::get_default_interface() {
|
||||||
|
if let Err(e) = crate::network::remove_nat(&state.config.subnet, &iface).await {
|
||||||
|
warn!("Failed to remove NAT rules: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
let _ = tx.send(()).await;
|
let _ = tx.send(()).await;
|
||||||
}
|
}
|
||||||
@@ -562,8 +692,8 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket listener — accepts TCP connections, upgrades to WS, then hands off
|
/// WebSocket listener — accepts TCP connections, optionally parses PROXY protocol v2,
|
||||||
/// to the transport-agnostic `handle_client_connection`.
|
/// upgrades to WS, then hands off to `handle_client_connection`.
|
||||||
async fn run_ws_listener(
|
async fn run_ws_listener(
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
listen_addr: String,
|
listen_addr: String,
|
||||||
@@ -576,17 +706,51 @@ async fn run_ws_listener(
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
accept = listener.accept() => {
|
accept = listener.accept() => {
|
||||||
match accept {
|
match accept {
|
||||||
Ok((stream, addr)) => {
|
Ok((mut tcp_stream, tcp_addr)) => {
|
||||||
info!("New connection from {}", addr);
|
info!("New connection from {}", tcp_addr);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match transport::accept_connection(stream).await {
|
// Phase 0: Parse PROXY protocol v2 header if enabled
|
||||||
|
let remote_addr = if state.config.proxy_protocol.unwrap_or(false) {
|
||||||
|
match crate::proxy_protocol::read_proxy_header(&mut tcp_stream).await {
|
||||||
|
Ok(header) if header.is_local => {
|
||||||
|
info!("PP v2 LOCAL probe from {}", tcp_addr);
|
||||||
|
return; // Health check — close gracefully
|
||||||
|
}
|
||||||
|
Ok(header) => {
|
||||||
|
info!("PP v2: real client {} (via {})", header.src_addr, tcp_addr);
|
||||||
|
Some(header.src_addr)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("PP v2 parse failed from {}: {}", tcp_addr, e);
|
||||||
|
return; // Drop connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(tcp_addr) // Direct connection — use TCP SocketAddr
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 1: Server-level connection IP block list (pre-handshake)
|
||||||
|
if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) {
|
||||||
|
if !block_list.is_empty() {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if acl::is_connection_blocked(v4, block_list) {
|
||||||
|
warn!("Connection blocked by server IP block list: {}", addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: WebSocket upgrade + VPN handshake
|
||||||
|
match transport::accept_connection(tcp_stream).await {
|
||||||
Ok(ws) => {
|
Ok(ws) => {
|
||||||
let (sink, stream) = transport_trait::split_ws(ws);
|
let (sink, stream) = transport_trait::split_ws(ws);
|
||||||
if let Err(e) = handle_client_connection(
|
if let Err(e) = handle_client_connection(
|
||||||
state,
|
state,
|
||||||
Box::new(sink),
|
Box::new(sink),
|
||||||
Box::new(stream),
|
Box::new(stream),
|
||||||
|
remote_addr,
|
||||||
).await {
|
).await {
|
||||||
warn!("Client connection error: {}", e);
|
warn!("Client connection error: {}", e);
|
||||||
}
|
}
|
||||||
@@ -662,6 +826,7 @@ async fn run_quic_listener(
|
|||||||
state,
|
state,
|
||||||
Box::new(sink),
|
Box::new(sink),
|
||||||
Box::new(stream),
|
Box::new(stream),
|
||||||
|
Some(remote),
|
||||||
).await {
|
).await {
|
||||||
warn!("QUIC client error: {}", e);
|
warn!("QUIC client error: {}", e);
|
||||||
}
|
}
|
||||||
@@ -694,12 +859,63 @@ async fn run_quic_listener(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TUN reader task: reads IP packets from the TUN device and dispatches them
|
||||||
|
/// to the correct client via the routing table.
|
||||||
|
async fn run_tun_reader(
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
mut tun_reader: tokio::io::ReadHalf<tun::AsyncDevice>,
|
||||||
|
mut shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = tun_reader.read(&mut buf) => {
|
||||||
|
let n = match result {
|
||||||
|
Ok(0) => {
|
||||||
|
info!("TUN reader: device closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
error!("TUN reader error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract destination IP from the raw IP packet
|
||||||
|
let dst_ip = match tunnel::extract_dst_ip(&buf[..n]) {
|
||||||
|
Some(std::net::IpAddr::V4(v4)) => v4,
|
||||||
|
_ => continue, // IPv6 or malformed — skip
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up client by destination IP
|
||||||
|
let routes = state.tun_routes.read().await;
|
||||||
|
if let Some(sender) = routes.get(&dst_ip) {
|
||||||
|
if sender.try_send(buf[..n].to_vec()).is_err() {
|
||||||
|
// Channel full or closed — drop packet (correct for IP best-effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("TUN reader shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Transport-agnostic client handler. Performs the Noise IK handshake, authenticates
|
/// Transport-agnostic client handler. Performs the Noise IK handshake, authenticates
|
||||||
/// the client against the registry, and runs the main packet forwarding loop.
|
/// the client against the registry, and runs the main packet forwarding loop.
|
||||||
async fn handle_client_connection(
|
async fn handle_client_connection(
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
mut sink: Box<dyn TransportSink>,
|
mut sink: Box<dyn TransportSink>,
|
||||||
mut stream: Box<dyn TransportStream>,
|
mut stream: Box<dyn TransportStream>,
|
||||||
|
remote_addr: Option<std::net::SocketAddr>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let server_private_key = base64::Engine::decode(
|
let server_private_key = base64::Engine::decode(
|
||||||
&base64::engine::general_purpose::STANDARD,
|
&base64::engine::general_purpose::STANDARD,
|
||||||
@@ -779,12 +995,38 @@ async fn handle_client_connection(
|
|||||||
|
|
||||||
let mut noise_transport = responder.into_transport_mode()?;
|
let mut noise_transport = responder.into_transport_mode()?;
|
||||||
|
|
||||||
|
// Connection-level ACL: check real client IP against per-client ipAllowList/ipBlockList
|
||||||
|
if let (Some(ref sec), Some(ref addr)) = (&client_security, &remote_addr) {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if !acl::is_source_allowed(
|
||||||
|
v4,
|
||||||
|
sec.ip_allow_list.as_deref(),
|
||||||
|
sec.ip_block_list.as_deref(),
|
||||||
|
) {
|
||||||
|
warn!("Connection-level ACL denied client {} from IP {}", registered_client_id, addr);
|
||||||
|
let disconnect_frame = Frame { packet_type: PacketType::Disconnect, payload: Vec::new() };
|
||||||
|
let mut frame_bytes = BytesMut::new();
|
||||||
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, disconnect_frame, &mut frame_bytes)?;
|
||||||
|
let _ = sink.send_reliable(frame_bytes.to_vec()).await;
|
||||||
|
anyhow::bail!("Connection denied: source IP {} not allowed for client {}", addr, registered_client_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use the registered client ID as the connection ID
|
// Use the registered client ID as the connection ID
|
||||||
let client_id = registered_client_id.clone();
|
let client_id = registered_client_id.clone();
|
||||||
|
|
||||||
// Allocate IP
|
// Allocate IP
|
||||||
let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?;
|
let assigned_ip = state.ip_pool.lock().await.allocate(&client_id)?;
|
||||||
|
|
||||||
|
// Create return-packet channel for forwarding engine -> client
|
||||||
|
let (tun_return_tx, mut tun_return_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
let fwd_mode = state.config.forwarding_mode.as_deref().unwrap_or("testing");
|
||||||
|
let forwarding_active = fwd_mode == "tun" || fwd_mode == "socket";
|
||||||
|
if forwarding_active {
|
||||||
|
state.tun_routes.write().await.insert(assigned_ip, tun_return_tx);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine rate limits: per-client security overrides server defaults
|
// Determine rate limits: per-client security overrides server defaults
|
||||||
let (rate_limit, burst) = if let Some(ref sec) = client_security {
|
let (rate_limit, burst) = if let Some(ref sec) = client_security {
|
||||||
if let Some(ref rl) = sec.rate_limit {
|
if let Some(ref rl) = sec.rate_limit {
|
||||||
@@ -811,6 +1053,7 @@ async fn handle_client_connection(
|
|||||||
burst_bytes: burst,
|
burst_bytes: burst,
|
||||||
authenticated_key: client_pub_key_b64.clone(),
|
authenticated_key: client_pub_key_b64.clone(),
|
||||||
registered_client_id: registered_client_id.clone(),
|
registered_client_id: registered_client_id.clone(),
|
||||||
|
remote_addr: remote_addr.map(|a| a.to_string()),
|
||||||
};
|
};
|
||||||
state.clients.write().await.insert(client_id.clone(), client_info);
|
state.clients.write().await.insert(client_id.clone(), client_info);
|
||||||
|
|
||||||
@@ -845,7 +1088,9 @@ async fn handle_client_connection(
|
|||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, encrypted_info, &mut frame_bytes)?;
|
||||||
sink.send_reliable(frame_bytes.to_vec()).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
info!("Client {} ({}) connected with IP {}", registered_client_id, &client_pub_key_b64[..8], assigned_ip);
|
info!("Client {} ({}) connected with IP {} from {}",
|
||||||
|
registered_client_id, &client_pub_key_b64[..8], assigned_ip,
|
||||||
|
remote_addr.map(|a| a.to_string()).unwrap_or_else(|| "unknown".to_string()));
|
||||||
|
|
||||||
// Main packet loop with dead-peer detection
|
// Main packet loop with dead-peer detection
|
||||||
let mut last_activity = tokio::time::Instant::now();
|
let mut last_activity = tokio::time::Instant::now();
|
||||||
@@ -909,6 +1154,24 @@ async fn handle_client_connection(
|
|||||||
if let Some(info) = clients.get_mut(&client_id) {
|
if let Some(info) = clients.get_mut(&client_id) {
|
||||||
info.bytes_received += len as u64;
|
info.bytes_received += len as u64;
|
||||||
}
|
}
|
||||||
|
drop(clients);
|
||||||
|
|
||||||
|
// Forward decrypted packet via the active engine
|
||||||
|
{
|
||||||
|
let mut engine = state.forwarding_engine.lock().await;
|
||||||
|
match &mut *engine {
|
||||||
|
ForwardingEngine::Tun(writer) => {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
if let Err(e) = writer.write_all(&buf[..len]).await {
|
||||||
|
warn!("TUN write error for client {}: {}", client_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForwardingEngine::Socket(sender) => {
|
||||||
|
let _ = sender.try_send(buf[..len].to_vec());
|
||||||
|
}
|
||||||
|
ForwardingEngine::Testing => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Decrypt error from {}: {}", client_id, e);
|
warn!("Decrypt error from {}: {}", client_id, e);
|
||||||
@@ -965,6 +1228,37 @@ async fn handle_client_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Return packets from TUN device destined for this client
|
||||||
|
Some(packet) = tun_return_rx.recv() => {
|
||||||
|
let pkt_len = packet.len();
|
||||||
|
match noise_transport.write_message(&packet, &mut buf) {
|
||||||
|
Ok(len) => {
|
||||||
|
let frame = Frame {
|
||||||
|
packet_type: PacketType::IpPacket,
|
||||||
|
payload: buf[..len].to_vec(),
|
||||||
|
};
|
||||||
|
let mut frame_bytes = BytesMut::new();
|
||||||
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(
|
||||||
|
&mut FrameCodec, frame, &mut frame_bytes
|
||||||
|
)?;
|
||||||
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
let mut stats = state.stats.write().await;
|
||||||
|
stats.bytes_sent += pkt_len as u64;
|
||||||
|
stats.packets_sent += 1;
|
||||||
|
drop(stats);
|
||||||
|
let mut clients = state.clients.write().await;
|
||||||
|
if let Some(info) = clients.get_mut(&client_id) {
|
||||||
|
info.bytes_sent += pkt_len as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Noise encrypt error for return packet to {}: {}", client_id, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = tokio::time::sleep_until(last_activity + DEAD_PEER_TIMEOUT) => {
|
_ = tokio::time::sleep_until(last_activity + DEAD_PEER_TIMEOUT) => {
|
||||||
warn!("Client {} dead-peer timeout ({}s inactivity)", client_id, DEAD_PEER_TIMEOUT.as_secs());
|
warn!("Client {} dead-peer timeout ({}s inactivity)", client_id, DEAD_PEER_TIMEOUT.as_secs());
|
||||||
break;
|
break;
|
||||||
@@ -973,6 +1267,9 @@ async fn handle_client_connection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
|
if forwarding_active {
|
||||||
|
state.tun_routes.write().await.remove(&assigned_ip);
|
||||||
|
}
|
||||||
state.clients.write().await.remove(&client_id);
|
state.clients.write().await.remove(&client_id);
|
||||||
state.ip_pool.lock().await.release(&assigned_ip);
|
state.ip_pool.lock().await.release(&assigned_ip);
|
||||||
state.rate_limiters.lock().await.remove(&client_id);
|
state.rate_limiters.lock().await.remove(&client_id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Configuration for creating a TUN device.
|
/// Configuration for creating a TUN device.
|
||||||
@@ -80,6 +80,26 @@ pub fn check_tun_mtu(packet: &[u8], mtu_config: &crate::mtu::MtuConfig) -> TunMt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract destination IP from a raw IP packet header.
|
||||||
|
pub fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
|
||||||
|
if packet.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = packet[0] >> 4;
|
||||||
|
match version {
|
||||||
|
4 if packet.len() >= 20 => {
|
||||||
|
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
||||||
|
Some(IpAddr::V4(dst))
|
||||||
|
}
|
||||||
|
6 if packet.len() >= 40 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
octets.copy_from_slice(&packet[24..40]);
|
||||||
|
Some(IpAddr::V6(Ipv6Addr::from(octets)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a route.
|
/// Remove a route.
|
||||||
pub async fn remove_route(subnet: &str, device_name: &str) -> Result<()> {
|
pub async fn remove_route(subnet: &str, device_name: &str) -> Result<()> {
|
||||||
let output = tokio::process::Command::new("ip")
|
let output = tokio::process::Command::new("ip")
|
||||||
|
|||||||
640
rust/src/userspace_nat.rs
Normal file
640
rust/src/userspace_nat.rs
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use smoltcp::iface::{Config, Interface, SocketHandle, SocketSet};
|
||||||
|
use smoltcp::phy::{self, Device, DeviceCapabilities, Medium};
|
||||||
|
use smoltcp::socket::{tcp, udp};
|
||||||
|
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpStream, UdpSocket};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::server::ServerState;
|
||||||
|
use crate::tunnel;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Virtual IP device for smoltcp
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct VirtualIpDevice {
|
||||||
|
rx_queue: VecDeque<Vec<u8>>,
|
||||||
|
tx_queue: VecDeque<Vec<u8>>,
|
||||||
|
mtu: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualIpDevice {
|
||||||
|
pub fn new(mtu: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
rx_queue: VecDeque::new(),
|
||||||
|
tx_queue: VecDeque::new(),
|
||||||
|
mtu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inject_packet(&mut self, packet: Vec<u8>) {
|
||||||
|
self.rx_queue.push_back(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_tx(&mut self) -> impl Iterator<Item = Vec<u8>> + '_ {
|
||||||
|
self.tx_queue.drain(..)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VirtualRxToken {
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl phy::RxToken for VirtualRxToken {
|
||||||
|
fn consume<R, F>(self, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> R,
|
||||||
|
{
|
||||||
|
f(&self.buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VirtualTxToken<'a> {
|
||||||
|
queue: &'a mut VecDeque<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> phy::TxToken for VirtualTxToken<'a> {
|
||||||
|
fn consume<R, F>(self, len: usize, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut [u8]) -> R,
|
||||||
|
{
|
||||||
|
let mut buffer = vec![0u8; len];
|
||||||
|
let result = f(&mut buffer);
|
||||||
|
self.queue.push_back(buffer);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device for VirtualIpDevice {
|
||||||
|
type RxToken<'a> = VirtualRxToken;
|
||||||
|
type TxToken<'a> = VirtualTxToken<'a>;
|
||||||
|
|
||||||
|
fn receive(
|
||||||
|
&mut self,
|
||||||
|
_timestamp: smoltcp::time::Instant,
|
||||||
|
) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
||||||
|
self.rx_queue.pop_front().map(|buffer| {
|
||||||
|
let rx = VirtualRxToken { buffer };
|
||||||
|
let tx = VirtualTxToken {
|
||||||
|
queue: &mut self.tx_queue,
|
||||||
|
};
|
||||||
|
(rx, tx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transmit(&mut self, _timestamp: smoltcp::time::Instant) -> Option<Self::TxToken<'_>> {
|
||||||
|
Some(VirtualTxToken {
|
||||||
|
queue: &mut self.tx_queue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> DeviceCapabilities {
|
||||||
|
let mut caps = DeviceCapabilities::default();
|
||||||
|
caps.medium = Medium::Ip;
|
||||||
|
caps.max_transmission_unit = self.mtu;
|
||||||
|
caps.max_burst_size = Some(1);
|
||||||
|
caps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
|
struct SessionKey {
|
||||||
|
src_ip: Ipv4Addr,
|
||||||
|
src_port: u16,
|
||||||
|
dst_ip: Ipv4Addr,
|
||||||
|
dst_port: u16,
|
||||||
|
protocol: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TcpSession {
|
||||||
|
smoltcp_handle: SocketHandle,
|
||||||
|
bridge_data_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
client_ip: Ipv4Addr,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UdpSession {
|
||||||
|
smoltcp_handle: SocketHandle,
|
||||||
|
bridge_data_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
client_ip: Ipv4Addr,
|
||||||
|
last_activity: tokio::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BridgeMessage {
|
||||||
|
TcpData { key: SessionKey, data: Vec<u8> },
|
||||||
|
TcpClosed { key: SessionKey },
|
||||||
|
UdpData { key: SessionKey, data: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IP packet parsing helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn parse_ipv4_header(packet: &[u8]) -> Option<(u8, Ipv4Addr, Ipv4Addr, u8)> {
|
||||||
|
if packet.len() < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = packet[0] >> 4;
|
||||||
|
if version != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ihl = (packet[0] & 0x0F) as usize * 4;
|
||||||
|
let protocol = packet[9];
|
||||||
|
let src = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
|
||||||
|
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
||||||
|
Some((ihl as u8, src, dst, protocol))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tcp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16, u8)> {
|
||||||
|
if packet.len() < ihl + 14 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
|
||||||
|
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
|
||||||
|
let flags = packet[ihl + 13];
|
||||||
|
Some((src_port, dst_port, flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_udp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16)> {
|
||||||
|
if packet.len() < ihl + 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
|
||||||
|
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
|
||||||
|
Some((src_port, dst_port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NAT Engine
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct NatEngine {
|
||||||
|
device: VirtualIpDevice,
|
||||||
|
iface: Interface,
|
||||||
|
sockets: SocketSet<'static>,
|
||||||
|
tcp_sessions: HashMap<SessionKey, TcpSession>,
|
||||||
|
udp_sessions: HashMap<SessionKey, UdpSession>,
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
bridge_rx: mpsc::Receiver<BridgeMessage>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatEngine {
|
||||||
|
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>) -> Self {
|
||||||
|
let mut device = VirtualIpDevice::new(mtu);
|
||||||
|
let config = Config::new(HardwareAddress::Ip);
|
||||||
|
let now = smoltcp::time::Instant::from_millis(0);
|
||||||
|
let mut iface = Interface::new(config, &mut device, now);
|
||||||
|
|
||||||
|
// Accept packets to ANY destination IP (essential for NAT)
|
||||||
|
iface.set_any_ip(true);
|
||||||
|
|
||||||
|
// Assign the gateway IP as the interface address
|
||||||
|
iface.update_ip_addrs(|addrs| {
|
||||||
|
addrs
|
||||||
|
.push(IpCidr::new(IpAddress::Ipv4(gateway_ip.into()), 24))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a default route so smoltcp knows where to send packets
|
||||||
|
iface.routes_mut().add_default_ipv4_route(gateway_ip.into()).unwrap();
|
||||||
|
|
||||||
|
let sockets = SocketSet::new(Vec::with_capacity(256));
|
||||||
|
let (bridge_tx, bridge_rx) = mpsc::channel(4096);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
device,
|
||||||
|
iface,
|
||||||
|
sockets,
|
||||||
|
tcp_sessions: HashMap::new(),
|
||||||
|
udp_sessions: HashMap::new(),
|
||||||
|
state,
|
||||||
|
bridge_rx,
|
||||||
|
bridge_tx,
|
||||||
|
start_time: std::time::Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smoltcp_now(&self) -> smoltcp::time::Instant {
|
||||||
|
smoltcp::time::Instant::from_millis(self.start_time.elapsed().as_millis() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject a raw IP packet from a VPN client and handle new session creation.
|
||||||
|
fn inject_packet(&mut self, packet: Vec<u8>) {
|
||||||
|
let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ihl = ihl as usize;
|
||||||
|
|
||||||
|
match protocol {
|
||||||
|
6 => {
|
||||||
|
// TCP
|
||||||
|
let Some((src_port, dst_port, flags)) = parse_tcp_ports(&packet, ihl) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = SessionKey {
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
dst_ip,
|
||||||
|
dst_port,
|
||||||
|
protocol: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// SYN without ACK = new connection
|
||||||
|
let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0;
|
||||||
|
if is_syn && !self.tcp_sessions.contains_key(&key) {
|
||||||
|
self.create_tcp_session(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
17 => {
|
||||||
|
// UDP
|
||||||
|
let Some((src_port, dst_port)) = parse_udp_ports(&packet, ihl) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = SessionKey {
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
dst_ip,
|
||||||
|
dst_port,
|
||||||
|
protocol: 17,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.udp_sessions.contains_key(&key) {
|
||||||
|
self.create_udp_session(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_activity for existing sessions
|
||||||
|
if let Some(session) = self.udp_sessions.get_mut(&key) {
|
||||||
|
session.last_activity = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// ICMP and other protocols — not forwarded in socket mode
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.device.inject_packet(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tcp_session(&mut self, key: &SessionKey) {
|
||||||
|
// Create smoltcp TCP socket
|
||||||
|
let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||||
|
let tcp_tx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||||
|
let mut socket = tcp::Socket::new(tcp_rx_buf, tcp_tx_buf);
|
||||||
|
|
||||||
|
// Listen on the destination address so smoltcp accepts the SYN
|
||||||
|
let endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.dst_ip.into()),
|
||||||
|
key.dst_port,
|
||||||
|
);
|
||||||
|
if socket.listen(endpoint).is_err() {
|
||||||
|
warn!("NAT: failed to listen on {:?}", endpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = self.sockets.add(socket);
|
||||||
|
|
||||||
|
// Channel for sending data from NAT engine to bridge task
|
||||||
|
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
|
||||||
|
let session = TcpSession {
|
||||||
|
smoltcp_handle: handle,
|
||||||
|
bridge_data_tx: data_tx,
|
||||||
|
client_ip: key.src_ip,
|
||||||
|
};
|
||||||
|
self.tcp_sessions.insert(key.clone(), session);
|
||||||
|
|
||||||
|
// Spawn bridge task that connects to the real destination
|
||||||
|
let bridge_tx = self.bridge_tx.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tcp_bridge_task(key_clone, data_rx, bridge_tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"NAT: new TCP session {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_udp_session(&mut self, key: &SessionKey) {
|
||||||
|
// Create smoltcp UDP socket
|
||||||
|
let udp_rx_buf = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; 65535],
|
||||||
|
);
|
||||||
|
let udp_tx_buf = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; 65535],
|
||||||
|
);
|
||||||
|
let mut socket = udp::Socket::new(udp_rx_buf, udp_tx_buf);
|
||||||
|
|
||||||
|
let endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.dst_ip.into()),
|
||||||
|
key.dst_port,
|
||||||
|
);
|
||||||
|
if socket.bind(endpoint).is_err() {
|
||||||
|
warn!("NAT: failed to bind UDP on {:?}", endpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = self.sockets.add(socket);
|
||||||
|
|
||||||
|
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
|
||||||
|
let session = UdpSession {
|
||||||
|
smoltcp_handle: handle,
|
||||||
|
bridge_data_tx: data_tx,
|
||||||
|
client_ip: key.src_ip,
|
||||||
|
last_activity: tokio::time::Instant::now(),
|
||||||
|
};
|
||||||
|
self.udp_sessions.insert(key.clone(), session);
|
||||||
|
|
||||||
|
let bridge_tx = self.bridge_tx.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
udp_bridge_task(key_clone, data_rx, bridge_tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"NAT: new UDP session {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll smoltcp, bridge data between smoltcp sockets and bridge tasks,
|
||||||
|
/// and dispatch outgoing packets to VPN clients.
|
||||||
|
async fn process(&mut self) {
|
||||||
|
let now = self.smoltcp_now();
|
||||||
|
self.iface
|
||||||
|
.poll(now, &mut self.device, &mut self.sockets);
|
||||||
|
|
||||||
|
// Bridge: read data from smoltcp TCP sockets → send to bridge tasks
|
||||||
|
let mut closed_tcp: Vec<SessionKey> = Vec::new();
|
||||||
|
for (key, session) in &self.tcp_sessions {
|
||||||
|
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
if socket.can_recv() {
|
||||||
|
let _ = socket.recv(|data| {
|
||||||
|
let _ = session.bridge_data_tx.try_send(data.to_vec());
|
||||||
|
(data.len(), ())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Detect closed connections
|
||||||
|
if !socket.is_open() && !socket.is_listening() {
|
||||||
|
closed_tcp.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up closed TCP sessions
|
||||||
|
for key in closed_tcp {
|
||||||
|
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||||
|
self.sockets.remove(session.smoltcp_handle);
|
||||||
|
debug!("NAT: TCP session closed {}:{} -> {}:{}", key.src_ip, key.src_port, key.dst_ip, key.dst_port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge: read data from smoltcp UDP sockets → send to bridge tasks
|
||||||
|
for (_key, session) in &self.udp_sessions {
|
||||||
|
let socket = self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
|
||||||
|
while let Ok((data, _meta)) = socket.recv() {
|
||||||
|
let _ = session.bridge_data_tx.try_send(data.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch outgoing packets from smoltcp to VPN clients
|
||||||
|
let routes = self.state.tun_routes.read().await;
|
||||||
|
for packet in self.device.drain_tx() {
|
||||||
|
if let Some(std::net::IpAddr::V4(dst_ip)) = tunnel::extract_dst_ip(&packet) {
|
||||||
|
if let Some(sender) = routes.get(&dst_ip) {
|
||||||
|
let _ = sender.try_send(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_bridge_message(&mut self, msg: BridgeMessage) {
|
||||||
|
match msg {
|
||||||
|
BridgeMessage::TcpData { key, data } => {
|
||||||
|
if let Some(session) = self.tcp_sessions.get(&key) {
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
if socket.can_send() {
|
||||||
|
let _ = socket.send_slice(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessage::TcpClosed { key } => {
|
||||||
|
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
socket.close();
|
||||||
|
// Don't remove from SocketSet yet — let smoltcp send FIN
|
||||||
|
// It will be cleaned up in process() when is_open() returns false
|
||||||
|
self.tcp_sessions.insert(key, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessage::UdpData { key, data } => {
|
||||||
|
if let Some(session) = self.udp_sessions.get_mut(&key) {
|
||||||
|
session.last_activity = tokio::time::Instant::now();
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
|
||||||
|
let dst_endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.src_ip.into()),
|
||||||
|
key.src_port,
|
||||||
|
);
|
||||||
|
// Send response: from the "server" (dst) back to the "client" (src)
|
||||||
|
let _ = socket.send_slice(&data, dst_endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_idle_udp_sessions(&mut self) {
|
||||||
|
let timeout = Duration::from_secs(60);
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let expired: Vec<SessionKey> = self
|
||||||
|
.udp_sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| now.duration_since(s.last_activity) > timeout)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for key in expired {
|
||||||
|
if let Some(session) = self.udp_sessions.remove(&key) {
|
||||||
|
self.sockets.remove(session.smoltcp_handle);
|
||||||
|
debug!(
|
||||||
|
"NAT: UDP session timed out {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main async event loop for the NAT engine.
|
||||||
|
pub async fn run(
|
||||||
|
mut self,
|
||||||
|
mut packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
mut shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
info!("Userspace NAT engine started");
|
||||||
|
let mut timer = tokio::time::interval(Duration::from_millis(50));
|
||||||
|
let mut cleanup_timer = tokio::time::interval(Duration::from_secs(10));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(packet) = packet_rx.recv() => {
|
||||||
|
self.inject_packet(packet);
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
Some(msg) = self.bridge_rx.recv() => {
|
||||||
|
self.handle_bridge_message(msg);
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
_ = timer.tick() => {
|
||||||
|
// Periodic poll for smoltcp maintenance (TCP retransmit, etc.)
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
_ = cleanup_timer.tick() => {
|
||||||
|
self.cleanup_idle_udp_sessions();
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("Userspace NAT engine shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bridge tasks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn tcp_bridge_task(
|
||||||
|
key: SessionKey,
|
||||||
|
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
) {
|
||||||
|
let addr = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||||
|
|
||||||
|
// Connect to real destination with timeout
|
||||||
|
let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!("NAT TCP connect to {} failed: {}", addr, e);
|
||||||
|
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!("NAT TCP connect to {} timed out", addr);
|
||||||
|
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut reader, mut writer) = stream.into_split();
|
||||||
|
|
||||||
|
// Read from real socket → send to NAT engine
|
||||||
|
let bridge_tx2 = bridge_tx.clone();
|
||||||
|
let key2 = key.clone();
|
||||||
|
let read_task = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if bridge_tx2
|
||||||
|
.send(BridgeMessage::TcpData {
|
||||||
|
key: key2.clone(),
|
||||||
|
data: buf[..n].to_vec(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = bridge_tx2
|
||||||
|
.send(BridgeMessage::TcpClosed { key: key2 })
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Receive from NAT engine → write to real socket
|
||||||
|
while let Some(data) = data_rx.recv().await {
|
||||||
|
if writer.write_all(&data).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn udp_bridge_task(
|
||||||
|
key: SessionKey,
|
||||||
|
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
) {
|
||||||
|
let socket = match UdpSocket::bind("0.0.0.0:0").await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("NAT UDP bind failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dest = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||||
|
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
let socket2 = socket.clone();
|
||||||
|
let bridge_tx2 = bridge_tx.clone();
|
||||||
|
let key2 = key.clone();
|
||||||
|
|
||||||
|
// Read responses from real socket
|
||||||
|
let read_task = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
match socket2.recv_from(&mut buf).await {
|
||||||
|
Ok((n, _src)) => {
|
||||||
|
if bridge_tx2
|
||||||
|
.send(BridgeMessage::UdpData {
|
||||||
|
key: key2.clone(),
|
||||||
|
data: buf[..n].to_vec(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward data from NAT engine to real socket
|
||||||
|
while let Some(data) = data_rx.recv().await {
|
||||||
|
let _ = socket.send_to(&data, dest).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
read_task.abort();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -18,6 +18,7 @@ use tokio::sync::{mpsc, oneshot, RwLock};
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::network;
|
use crate::network;
|
||||||
|
use crate::tunnel::extract_dst_ip;
|
||||||
use crate::tunnel::{self, TunConfig};
|
use crate::tunnel::{self, TunConfig};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -228,26 +229,6 @@ impl AllowedIp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract destination IP from an IP packet header.
|
|
||||||
fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let version = packet[0] >> 4;
|
|
||||||
match version {
|
|
||||||
4 if packet.len() >= 20 => {
|
|
||||||
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
|
||||||
Some(IpAddr::V4(dst))
|
|
||||||
}
|
|
||||||
6 if packet.len() >= 40 => {
|
|
||||||
let mut octets = [0u8; 16];
|
|
||||||
octets.copy_from_slice(&packet[24..40]);
|
|
||||||
Some(IpAddr::V6(Ipv6Addr::from(octets)))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dynamic peer management commands
|
// Dynamic peer management commands
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1096,6 +1077,7 @@ fn chrono_now() -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::net::Ipv6Addr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_wg_keypair() {
|
fn test_generate_wg_keypair() {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.8.0',
|
version: '1.10.0',
|
||||||
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface IVpnClientConfig {
|
|||||||
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
|
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
|
||||||
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
|
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
|
||||||
serverCertHash?: string;
|
serverCertHash?: string;
|
||||||
|
/** Forwarding mode: 'tun' (TUN device, requires root) or 'testing' (no TUN).
|
||||||
|
* Default: 'testing'. */
|
||||||
|
forwardingMode?: 'tun' | 'testing';
|
||||||
/** WireGuard: client private key (base64, X25519) */
|
/** WireGuard: client private key (base64, X25519) */
|
||||||
wgPrivateKey?: string;
|
wgPrivateKey?: string;
|
||||||
/** WireGuard: client TUN address (e.g. 10.8.0.2) */
|
/** WireGuard: client TUN address (e.g. 10.8.0.2) */
|
||||||
@@ -86,6 +89,9 @@ export interface IVpnServerConfig {
|
|||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
/** Enable NAT/masquerade for client traffic */
|
/** Enable NAT/masquerade for client traffic */
|
||||||
enableNat?: boolean;
|
enableNat?: boolean;
|
||||||
|
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
|
||||||
|
* or 'testing' (monitoring only). Default: 'testing'. */
|
||||||
|
forwardingMode?: 'tun' | 'socket' | 'testing';
|
||||||
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
|
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
|
||||||
defaultRateLimitBytesPerSec?: number;
|
defaultRateLimitBytesPerSec?: number;
|
||||||
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
||||||
@@ -102,6 +108,13 @@ export interface IVpnServerConfig {
|
|||||||
wgPeers?: IWgPeerConfig[];
|
wgPeers?: IWgPeerConfig[];
|
||||||
/** Pre-registered clients for Noise IK authentication */
|
/** Pre-registered clients for Noise IK authentication */
|
||||||
clients?: IClientEntry[];
|
clients?: IClientEntry[];
|
||||||
|
/** Enable PROXY protocol v2 on incoming WebSocket connections.
|
||||||
|
* Required when behind a reverse proxy that sends PP v2 headers (HAProxy, SmartProxy).
|
||||||
|
* SECURITY: Must be false when accepting direct client connections. */
|
||||||
|
proxyProtocol?: boolean;
|
||||||
|
/** Server-level IP block list — applied at TCP accept, before Noise handshake.
|
||||||
|
* Supports exact IPs, CIDR, wildcards, ranges. */
|
||||||
|
connectionIpBlockList?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnServerOptions {
|
export interface IVpnServerOptions {
|
||||||
@@ -156,6 +169,8 @@ export interface IVpnClientInfo {
|
|||||||
authenticatedKey: string;
|
authenticatedKey: string;
|
||||||
/** Registered client ID from the client registry */
|
/** Registered client ID from the client registry */
|
||||||
registeredClientId: string;
|
registeredClientId: string;
|
||||||
|
/** Real client IP:port (from PROXY protocol or direct TCP connection) */
|
||||||
|
remoteAddr?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnServerStatistics extends IVpnStatistics {
|
export interface IVpnServerStatistics extends IVpnStatistics {
|
||||||
|
|||||||
Reference in New Issue
Block a user