Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c49fcaf1ce | |||
| fdeba5eeb5 | |||
| 17af7ab289 | |||
| b98006e792 | |||
| fbfbe0db51 | |||
| 67542f0be7 | |||
| 13d0183e9d | |||
| 99a8a29ff1 | |||
| fe9c693ac8 | |||
| 20ef92599b | |||
| c3f180e264 | |||
| 667e5ff3de | |||
| ef5856bd3a | |||
| 6e4cafe3c5 | |||
| 42949b1233 | |||
| 7ae7d389dd | |||
| 414edf7038 | |||
| a1b62f6b62 | |||
| cfa91fd419 | |||
| 8eb26e1920 | |||
| e513f8686b | |||
| e06667b298 | |||
| c3afb83470 | |||
| 2d7a507cf2 | |||
| a757a4bb73 | |||
| 5bf21ab4ac | |||
| af46dc9b39 | |||
| 79d9928485 | |||
| 70e838c8ff | |||
| dbcfdb1fb6 |
109
changelog.md
109
changelog.md
@@ -1,5 +1,114 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-31 - 1.18.0 - feat(server)
|
||||
add bridge forwarding mode and per-client destination policy overrides
|
||||
|
||||
- introduces Linux bridge-based forwarding so VPN clients can receive IPs from a LAN subnet via TAP/bridge integration
|
||||
- adds bridge server configuration options for LAN subnet, physical interface, and client IP allocation range
|
||||
- adds per-client destinationPolicy overrides in the client registry and applies them in the userspace NAT engine based on assigned tunnel IP
|
||||
- extends IP pool allocation to support constrained address ranges needed for bridge mode
|
||||
- updates TypeScript interfaces and documentation to cover bridge mode and per-client destination policy behavior
|
||||
|
||||
## 2026-03-31 - 1.17.1 - fix(readme)
|
||||
document per-transport metrics and handshake-driven WireGuard connection state
|
||||
|
||||
- Add README examples for getStatistics() per-transport active client and total connection counters
|
||||
- Clarify that WireGuard peers are marked connected only after a successful handshake and disconnect after idle timeout
|
||||
- Refresh API and project structure documentation to reflect newly documented stats fields and source files
|
||||
|
||||
## 2026-03-31 - 1.17.0 - feat(wireguard)
|
||||
track per-transport server statistics and make WireGuard clients active only after handshake
|
||||
|
||||
- add websocket, quic, and wireguard active-client and total-connection counters to server statistics
|
||||
- register WireGuard peers without marking them active until handshake/data is received, and remove them from active clients on expiration or idle timeout
|
||||
- sync WireGuard byte counters into aggregate server stats independently of active client presence and expose new statistics fields in TypeScript interfaces
|
||||
|
||||
## 2026-03-31 - 1.16.5 - fix(rust-userspace-nat)
|
||||
improve TCP session backpressure, buffering, and idle cleanup in userspace NAT
|
||||
|
||||
- apply proper bridge-channel backpressure by reserving channel capacity before consuming smoltcp TCP data
|
||||
- defer bridge sender initialization until the bridge task starts and track TCP session activity timestamps
|
||||
- cap per-session pending TCP send buffers at 512KB and abort stalled sessions when clients cannot keep up
|
||||
- add idle TCP session cleanup and switch NAT polling to a dynamic smoltcp-driven delay
|
||||
|
||||
## 2026-03-31 - 1.16.4 - fix(server)
|
||||
register preloaded WireGuard clients as peers on server startup
|
||||
|
||||
- Adds configured clients from the runtime registry to the WireGuard listener when the server starts.
|
||||
- Ensures clients loaded from config can complete WireGuard handshakes without requiring separate peer registration.
|
||||
- Logs a warning if automatic peer registration fails for an individual client.
|
||||
|
||||
## 2026-03-31 - 1.16.3 - fix(rust-nat)
|
||||
defer TCP bridge startup until handshake completion and buffer partial NAT socket writes
|
||||
|
||||
- Start TCP bridge tasks only after the smoltcp socket becomes active to prevent server data from arriving before the client handshake completes.
|
||||
- Buffer pending TCP payloads and flush partial writes so bridge-to-socket data is not silently lost under backpressure.
|
||||
- Keep closing TCP sessions alive until FIN processing completes and add logging for dropped packets when bridge or route channels are full.
|
||||
|
||||
## 2026-03-31 - 1.16.2 - fix(wireguard)
|
||||
sync runtime peer management with client registration and derive the correct server public key from the WireGuard private key
|
||||
|
||||
- Register, remove, and rotate WireGuard peers in the running listener when clients are added, deleted, or rekeyed.
|
||||
- Generate client WireGuard configs with the public key derived from the configured WireGuard private key instead of reusing the generic server public key.
|
||||
- Handle expired WireGuard sessions by re-initiating handshakes and mark client state as handshaking until the tunnel becomes active.
|
||||
- Improve allowed IP matching and peer VPN IP extraction for runtime packet routing.
|
||||
|
||||
## 2026-03-30 - 1.16.1 - fix(rust/server)
|
||||
add serde alias for clientAllowedIPs in server config
|
||||
|
||||
- Accepts the camelCase clientAllowedIPs field when deserializing server configuration.
|
||||
- Improves compatibility with existing or external configuration formats without changing runtime behavior.
|
||||
|
||||
## 2026-03-30 - 1.16.0 - feat(server)
|
||||
add configurable client endpoint and allowed IPs for generated VPN configs
|
||||
|
||||
- adds serverEndpoint to generated SmartVPN and WireGuard client configs so remote clients can use a public address instead of the listen address
|
||||
- adds clientAllowedIPs to generated WireGuard configs to support full-tunnel or split-tunnel routing
|
||||
- updates TypeScript interfaces to expose the new server configuration options
|
||||
|
||||
## 2026-03-30 - 1.15.0 - feat(vpnserver)
|
||||
add nftables-backed destination policy enforcement for TUN mode
|
||||
|
||||
- add @push.rocks/smartnftables dependency and export it through the plugin layer
|
||||
- apply destination policy rules via nftables when starting the server in TUN mode
|
||||
- add periodic nftables health checks and best-effort cleanup on server stop
|
||||
- update documentation for destination routing policy, socket transport mode, trusted client tags, events, and service generation
|
||||
|
||||
## 2026-03-30 - 1.14.0 - feat(nat)
|
||||
add destination routing policy support for socket-mode VPN traffic
|
||||
|
||||
- introduce configurable destinationPolicy settings in server and TypeScript interfaces
|
||||
- apply allow, block, and forceTarget routing decisions when creating TCP and UDP NAT sessions
|
||||
- export ACL IP matching helper for destination policy evaluation
|
||||
|
||||
## 2026-03-30 - 1.13.0 - feat(client-registry)
|
||||
separate trusted server-defined client tags from client-reported tags with legacy tag compatibility
|
||||
|
||||
- Adds distinct serverDefinedClientTags and clientDefinedClientTags fields to client registry and TypeScript interfaces.
|
||||
- Treats legacy tags values as serverDefinedClientTags during deserialization and server-side create/update flows for backward compatibility.
|
||||
- Clarifies that only server-defined tags are trusted for access control while client-defined tags are informational only.
|
||||
|
||||
## 2026-03-30 - 1.12.0 - feat(server)
|
||||
add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding
|
||||
|
||||
- introduce a socketForwardProxyProtocol server option in Rust and TypeScript interfaces
|
||||
- pass the new setting into the userspace NAT engine and TCP bridge tasks
|
||||
- prepend PROXY protocol v2 headers on outbound TCP connections when socket forwarding is enabled
|
||||
|
||||
## 2026-03-30 - 1.11.0 - feat(server)
|
||||
unify WireGuard into the shared server transport pipeline
|
||||
|
||||
- add integrated WireGuard server support to VpnServer with shared startup, shutdown, status, statistics, and peer management
|
||||
- introduce transportMode 'all' as the default and add server config support for wgPrivateKey, wgListenPort, and preconfigured peers
|
||||
- register WireGuard peers in the shared client registry and IP pool so they use the same forwarding engine, routing, and monitoring as WebSocket and QUIC clients
|
||||
- expose transportType in server client info and update TypeScript interfaces and documentation to reflect unified multi-transport forwarding
|
||||
|
||||
## 2026-03-30 - 1.10.2 - fix(client)
|
||||
wait for the connection task to shut down cleanly before disconnecting and increase test timeout
|
||||
|
||||
- store the spawned client connection task handle and await it during disconnect with a 5 second timeout so the disconnect frame can be sent before closing
|
||||
- increase the test script timeout from 60 seconds to 90 seconds to reduce flaky test runs
|
||||
|
||||
## 2026-03-29 - 1.10.1 - fix(test, docs, scripts)
|
||||
correct test command verbosity, shorten load test timings, and document forwarding modes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartvpn",
|
||||
"version": "1.10.1",
|
||||
"version": "1.18.0",
|
||||
"private": false,
|
||||
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
||||
"type": "module",
|
||||
@@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"build": "(tsbuild tsfolders) && (tsrust)",
|
||||
"test:before": "(tsrust)",
|
||||
"test": "tstest test/ --verbose --logfile --timeout 60",
|
||||
"test": "tstest test/ --verbose --logfile --timeout 90",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
@@ -29,6 +29,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@push.rocks/smartnftables": "1.1.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartrust": "^1.3.2"
|
||||
},
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@push.rocks/smartnftables':
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -1132,6 +1135,9 @@ packages:
|
||||
'@push.rocks/smartnetwork@4.5.2':
|
||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||
|
||||
@@ -5335,6 +5341,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
dependencies:
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
|
||||
287
readme.md
287
readme.md
@@ -6,10 +6,13 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust
|
||||
🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol)
|
||||
🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions
|
||||
🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum)
|
||||
📊 **Adaptive QoS**: per-client rate limiting, priority queues, connection quality tracking
|
||||
📊 **Per-transport metrics**: active clients and total connections broken down by websocket, QUIC, and WireGuard
|
||||
🔄 **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
|
||||
🌐 **Flexible forwarding**: TUN device (kernel), userspace NAT (no root), or testing mode
|
||||
🌐 **Unified forwarding pipeline**: all transports share the same engine — TUN (kernel), userspace NAT (no root), L2 bridge, or testing mode
|
||||
🏠 **Bridge mode**: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network
|
||||
🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration
|
||||
⚡ **Handshake-driven WireGuard state**: peers appear as "connected" only after a successful WireGuard handshake, and auto-disconnect on idle timeout
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -36,11 +39,38 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
|
||||
│ Config validation │ │ WS + QUIC + WireGuard │
|
||||
│ Hub: client management │ │ TUN device, IP pool, NAT │
|
||||
│ WireGuard .conf generation │ │ Rate limiting, ACLs, QoS │
|
||||
│ nftables destination policy │ │ Destination routing, nftables│
|
||||
└──────────────────────────────┘ └───────────────────────────────┘
|
||||
```
|
||||
|
||||
**Split-plane design** — TypeScript handles orchestration, config, and DX; Rust handles every hot-path byte with zero-copy async I/O (tokio, mimalloc).
|
||||
|
||||
### IPC Transport Modes
|
||||
|
||||
The bridge between TypeScript and Rust supports two transport modes:
|
||||
|
||||
| Mode | Use Case | How It Works |
|
||||
|------|----------|-------------|
|
||||
| **stdio** | Development, testing | Spawns the Rust daemon as a child process, communicates over stdin/stdout |
|
||||
| **socket** | Production | Connects to an already-running daemon via Unix domain socket, with optional auto-reconnect |
|
||||
|
||||
```typescript
|
||||
// Development: spawn the daemon
|
||||
const server = new VpnServer({ transport: { transport: 'stdio' } });
|
||||
|
||||
// Production: connect to running daemon
|
||||
const server = new VpnServer({
|
||||
transport: {
|
||||
transport: 'socket',
|
||||
socketPath: '/var/run/smartvpn.sock',
|
||||
autoReconnect: true,
|
||||
reconnectBaseDelayMs: 100,
|
||||
reconnectMaxDelayMs: 5000,
|
||||
maxReconnectAttempts: 10,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Quick Start 🚀
|
||||
|
||||
### 1. Start a VPN Server (Hub)
|
||||
@@ -54,8 +84,9 @@ await server.start({
|
||||
privateKey: '<server-noise-private-key-base64>',
|
||||
publicKey: '<server-noise-public-key-base64>',
|
||||
subnet: '10.8.0.0/24',
|
||||
transportMode: 'both', // WebSocket + QUIC simultaneously
|
||||
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing'
|
||||
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
|
||||
forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'testing'
|
||||
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
|
||||
enableNat: true,
|
||||
dns: ['1.1.1.1', '8.8.8.8'],
|
||||
});
|
||||
@@ -66,7 +97,7 @@ await server.start({
|
||||
```typescript
|
||||
const bundle = await server.createClient({
|
||||
clientId: 'alice-laptop',
|
||||
tags: ['engineering'],
|
||||
serverDefinedClientTags: ['engineering'], // trusted tags for access control
|
||||
security: {
|
||||
destinationAllowList: ['10.0.0.0/8'], // can only reach internal network
|
||||
destinationBlockList: ['10.0.0.99'], // except this host
|
||||
@@ -109,7 +140,31 @@ Every client authenticates with a **Noise IK handshake** (`Noise_IK_25519_ChaCha
|
||||
| **QUIC** | UDP (via quinn) | Low latency, datagram support for IP packets |
|
||||
| **WireGuard** | UDP (via boringtun) | Standard WG clients (iOS, Android, wg-quick) |
|
||||
|
||||
The server can run **all three simultaneously** with `transportMode: 'both'` (WS + QUIC) or `'wireguard'`. Clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS).
|
||||
The server runs **all three simultaneously** by default with `transportMode: 'all'`. All transports share the same unified forwarding pipeline (`ForwardingEngine`), IP pool, client registry, and stats — so WireGuard peers get the same userspace NAT, rate limiting, and monitoring as WS/QUIC clients. Clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS).
|
||||
|
||||
### 📊 Per-Transport Metrics
|
||||
|
||||
Server statistics include per-transport breakdowns so you can see exactly how many clients use each protocol:
|
||||
|
||||
```typescript
|
||||
const stats = await server.getStatistics();
|
||||
|
||||
// Aggregate
|
||||
console.log(stats.activeClients); // total connected clients
|
||||
console.log(stats.totalConnections); // total connections since start
|
||||
|
||||
// Per-transport active clients
|
||||
console.log(stats.activeClientsWebsocket); // currently connected via WS
|
||||
console.log(stats.activeClientsQuic); // currently connected via QUIC
|
||||
console.log(stats.activeClientsWireguard); // currently connected via WireGuard
|
||||
|
||||
// Per-transport total connections
|
||||
console.log(stats.totalConnectionsWebsocket);
|
||||
console.log(stats.totalConnectionsQuic);
|
||||
console.log(stats.totalConnectionsWireguard);
|
||||
```
|
||||
|
||||
**WireGuard connection state is handshake-driven** — registered WireGuard peers do NOT appear as "connected" until their first successful WireGuard handshake completes. They automatically disconnect after 180 seconds of inactivity or when boringtun reports `ConnectionExpired`. This matches how WebSocket/QUIC clients behave: they appear on connection and disappear on disconnect.
|
||||
|
||||
### 🛡️ ACL Engine (SmartProxy-Aligned)
|
||||
|
||||
@@ -154,14 +209,71 @@ await server.start({
|
||||
- `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
|
||||
|
||||
### 🎯 Destination Routing Policy
|
||||
|
||||
Control where decrypted VPN client traffic goes — force it to a specific target, block it, or allow it through. Evaluated per-packet before per-client ACLs.
|
||||
|
||||
```typescript
|
||||
await server.start({
|
||||
// ...
|
||||
forwardingMode: 'socket', // userspace NAT mode
|
||||
destinationPolicy: {
|
||||
default: 'forceTarget', // redirect all traffic to a target
|
||||
target: '127.0.0.1', // target IP for 'forceTarget' mode
|
||||
allowList: ['10.0.0.0/8'], // these destinations pass through directly
|
||||
blockList: ['10.0.0.99'], // always blocked (deny overrides allow)
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Policy modes:**
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `'forceTarget'` | Rewrites destination IP to `target` — funnels all traffic through a single endpoint |
|
||||
| `'block'` | Drops all traffic not explicitly in `allowList` |
|
||||
| `'allow'` | Passes all traffic through (default, backward compatible) |
|
||||
|
||||
In **TUN mode**, destination policies are enforced via **nftables** rules (using `@push.rocks/smartnftables`). A 60-second health check automatically re-applies rules if they're removed externally.
|
||||
|
||||
In **socket mode**, the policy is evaluated in the userspace NAT engine before per-client ACLs.
|
||||
|
||||
**Per-client override** — individual clients can have their own destination policy that overrides the server-level default:
|
||||
|
||||
```typescript
|
||||
await server.createClient({
|
||||
clientId: 'restricted-client',
|
||||
security: {
|
||||
destinationPolicy: {
|
||||
default: 'block', // block everything by default
|
||||
allowList: ['10.0.0.0/8'], // except internal network
|
||||
},
|
||||
// ... other security settings
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 🔗 Socket Forward Proxy Protocol
|
||||
|
||||
When using `forwardingMode: 'socket'` (userspace NAT), you can prepend **PROXY protocol v2 headers** on outbound TCP connections. This conveys the VPN client's tunnel IP as the source address to downstream services (e.g., SmartProxy):
|
||||
|
||||
```typescript
|
||||
await server.start({
|
||||
// ...
|
||||
forwardingMode: 'socket',
|
||||
socketForwardProxyProtocol: true, // downstream sees VPN client IP, not 127.0.0.1
|
||||
});
|
||||
```
|
||||
|
||||
### 📦 Packet Forwarding Modes
|
||||
|
||||
SmartVPN supports three forwarding modes, configurable per-server and per-client:
|
||||
SmartVPN supports four forwarding modes, configurable per-server and per-client:
|
||||
|
||||
| Mode | Flag | Description | Root Required |
|
||||
|------|------|-------------|---------------|
|
||||
| **TUN** | `'tun'` | Kernel TUN device — real packet forwarding with system routing | ✅ Yes |
|
||||
| **Userspace NAT** | `'socket'` | Userspace TCP/UDP proxy via `connect(2)` — no TUN, no root needed | ❌ No |
|
||||
| **Bridge** | `'bridge'` | L2 bridge — VPN clients get IPs from a physical LAN subnet | ✅ Yes |
|
||||
| **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No |
|
||||
|
||||
```typescript
|
||||
@@ -172,6 +284,16 @@ await server.start({
|
||||
enableNat: true,
|
||||
});
|
||||
|
||||
// Server with bridge mode — VPN clients appear on the LAN
|
||||
await server.start({
|
||||
// ...
|
||||
forwardingMode: 'bridge',
|
||||
bridgeLanSubnet: '192.168.1.0/24', // LAN subnet to bridge into
|
||||
bridgePhysicalInterface: 'eth0', // auto-detected if omitted
|
||||
bridgeIpRangeStart: 200, // clients get .200–.250 (defaults)
|
||||
bridgeIpRangeEnd: 250,
|
||||
});
|
||||
|
||||
// Client with TUN device
|
||||
const { assignedIp } = await client.connect({
|
||||
// ...
|
||||
@@ -179,15 +301,42 @@ const { assignedIp } = await client.connect({
|
||||
});
|
||||
```
|
||||
|
||||
The userspace NAT mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring `CAP_NET_ADMIN` or root privileges.
|
||||
The **userspace NAT** mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring `CAP_NET_ADMIN` or root privileges.
|
||||
|
||||
The **bridge** mode assigns VPN clients IPs from a real LAN subnet instead of a virtual VPN subnet. Clients appear as if they're directly on the physical network — perfect for remote access to home labs, office networks, or IoT devices.
|
||||
|
||||
### 📊 Telemetry & QoS
|
||||
|
||||
- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`)
|
||||
- **Adaptive keepalives**: Interval adjusts based on link health (60s → 30s → 10s)
|
||||
- **Per-client rate limiting**: Token bucket with configurable bytes/sec and burst
|
||||
- **Dead-peer detection**: 180s inactivity timeout
|
||||
- **Dead-peer detection**: 180s inactivity timeout (all transports)
|
||||
- **MTU management**: Automatic overhead calculation (IP+TCP+WS+Noise = 79 bytes)
|
||||
- **Per-transport stats**: Active client and total connection counts broken down by websocket, QUIC, and WireGuard
|
||||
|
||||
### 🏷️ Client Tags (Trusted vs Informational)
|
||||
|
||||
SmartVPN separates server-managed tags from client-reported tags:
|
||||
|
||||
| Field | Set By | Trust Level | Use For |
|
||||
|-------|--------|-------------|---------|
|
||||
| `serverDefinedClientTags` | Server admin (via `createClient` / `updateClient`) | ✅ Trusted | Access control, routing, billing |
|
||||
| `clientDefinedClientTags` | Client (reported after connection) | ⚠️ Informational | Diagnostics, client self-identification |
|
||||
| `tags` | *(deprecated)* | — | Legacy alias for `serverDefinedClientTags` |
|
||||
|
||||
```typescript
|
||||
// Server-side: trusted tags
|
||||
await server.createClient({
|
||||
clientId: 'alice-laptop',
|
||||
serverDefinedClientTags: ['engineering', 'office-berlin'],
|
||||
});
|
||||
|
||||
// Client-side: informational tags (reported to server)
|
||||
await client.connect({
|
||||
// ...
|
||||
clientDefinedClientTags: ['macOS', 'v2.1.0'],
|
||||
});
|
||||
```
|
||||
|
||||
### 🔄 Hub Client Management
|
||||
|
||||
@@ -204,7 +353,7 @@ const all = await server.listRegisteredClients();
|
||||
// Update (ACLs, tags, description, rate limits...)
|
||||
await server.updateClient('bob-phone', {
|
||||
security: { destinationAllowList: ['0.0.0.0/0'] },
|
||||
tags: ['mobile', 'field-ops'],
|
||||
serverDefinedClientTags: ['mobile', 'field-ops'],
|
||||
});
|
||||
|
||||
// Enable / Disable
|
||||
@@ -242,46 +391,101 @@ const conf = WgConfigGenerator.generateClientConfig({
|
||||
// → standard WireGuard .conf compatible with wg-quick, iOS, Android
|
||||
```
|
||||
|
||||
Server configs too:
|
||||
|
||||
```typescript
|
||||
const serverConf = WgConfigGenerator.generateServerConfig({
|
||||
privateKey: '<server-wg-private-key>',
|
||||
address: '10.8.0.1/24',
|
||||
listenPort: 51820,
|
||||
enableNat: true,
|
||||
natInterface: 'eth0',
|
||||
peers: [
|
||||
{ publicKey: '<client-wg-public-key>', allowedIps: ['10.8.0.2/32'] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 🖥️ System Service Installation
|
||||
|
||||
Generate systemd (Linux) or launchd (macOS) service units:
|
||||
|
||||
```typescript
|
||||
import { VpnInstaller } from '@push.rocks/smartvpn';
|
||||
|
||||
const unit = VpnInstaller.generateServiceUnit({
|
||||
binaryPath: '/usr/local/bin/smartvpn_daemon',
|
||||
socketPath: '/var/run/smartvpn.sock',
|
||||
mode: 'server',
|
||||
configPath: '/etc/smartvpn/server.json',
|
||||
});
|
||||
// unit.platform → 'linux' | 'macos'
|
||||
// unit.content → systemd unit file or launchd plist
|
||||
// unit.platform → 'linux' | 'macos'
|
||||
// unit.content → systemd unit file or launchd plist
|
||||
// unit.installPath → /etc/systemd/system/smartvpn-server.service
|
||||
```
|
||||
|
||||
You can also call `generateSystemdUnit()` or `generateLaunchdPlist()` directly for platform-specific options like custom descriptions.
|
||||
|
||||
### 📢 Events
|
||||
|
||||
Both `VpnServer` and `VpnClient` extend `EventEmitter` and emit typed events:
|
||||
|
||||
```typescript
|
||||
server.on('client-connected', (info: IVpnClientInfo) => {
|
||||
console.log(`${info.registeredClientId} connected from ${info.remoteAddr} via ${info.transportType}`);
|
||||
});
|
||||
|
||||
server.on('client-disconnected', ({ clientId, reason }) => {
|
||||
console.log(`${clientId} disconnected: ${reason}`);
|
||||
});
|
||||
|
||||
client.on('status', (status: IVpnStatus) => {
|
||||
console.log(`State: ${status.state}, IP: ${status.assignedIp}`);
|
||||
});
|
||||
|
||||
// Both server and client emit:
|
||||
server.on('exit', ({ code, signal }) => { /* daemon process exited */ });
|
||||
server.on('reconnected', () => { /* socket transport reconnected */ });
|
||||
```
|
||||
|
||||
| Event | Emitted By | Payload |
|
||||
|-------|-----------|---------|
|
||||
| `status` | Both | `IVpnStatus` — connection state changes |
|
||||
| `error` | Both | `{ message, code? }` |
|
||||
| `client-connected` | Server | `IVpnClientInfo` — full client info including transport type |
|
||||
| `client-disconnected` | Server | `{ clientId, reason? }` |
|
||||
| `exit` | Both | `{ code, signal }` — daemon process exited |
|
||||
| `reconnected` | Both | `void` — socket transport reconnected |
|
||||
|
||||
## API Reference 📖
|
||||
|
||||
### Classes
|
||||
|
||||
| Class | Description |
|
||||
|-------|-------------|
|
||||
| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD. |
|
||||
| `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, telemetry. |
|
||||
| `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). |
|
||||
| `VpnConfig` | Static config validation and file I/O. |
|
||||
| `VpnInstaller` | Generates systemd/launchd service files. |
|
||||
| `WgConfigGenerator` | Generates standard WireGuard `.conf` files. |
|
||||
| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD, telemetry, rate limits, WireGuard peer management. |
|
||||
| `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, status, telemetry. |
|
||||
| `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). Handles spawn, connect, reconnect, and typed command dispatch. |
|
||||
| `VpnConfig` | Static config validation and JSON file I/O. Validates keys, addresses, CIDRs, MTU, etc. |
|
||||
| `VpnInstaller` | Generates systemd/launchd service files for daemon deployment. |
|
||||
| `WgConfigGenerator` | Generates standard WireGuard `.conf` files (client and server). |
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
| Interface | Purpose |
|
||||
|-----------|---------|
|
||||
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode, clients, proxy protocol) |
|
||||
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options) |
|
||||
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) |
|
||||
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) |
|
||||
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge, clients, proxy protocol, destination policy) |
|
||||
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options, client-defined tags) |
|
||||
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry) |
|
||||
| `IClientSecurity` | Per-client ACLs, rate limits, and destination policy override (SmartProxy-aligned naming) |
|
||||
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
|
||||
| `IClientConfigBundle` | Full config bundle returned by `createClient()` |
|
||||
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr) |
|
||||
| `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets |
|
||||
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr, transport type) |
|
||||
| `IVpnServerStatistics` | Server stats with per-transport breakdowns (activeClientsWebsocket/Quic/Wireguard, totalConnections*) |
|
||||
| `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health |
|
||||
| `IVpnMtuInfo` | TUN MTU, effective MTU, overhead bytes, oversized packet stats |
|
||||
| `IVpnKeypair` | Base64-encoded public/private key pair |
|
||||
| `IDestinationPolicy` | Destination routing policy (forceTarget / block / allow with allow/block lists) |
|
||||
| `IVpnEventMap` | Typed event map for server and client EventEmitter |
|
||||
|
||||
### Server IPC Commands
|
||||
|
||||
@@ -295,7 +499,7 @@ const unit = VpnInstaller.generateServiceUnit({
|
||||
| `exportClientConfig` | Re-export as SmartVPN config or WireGuard `.conf` |
|
||||
| `listClients` / `disconnectClient` | Manage live connections |
|
||||
| `setClientRateLimit` / `removeClientRateLimit` | Runtime rate limit adjustments |
|
||||
| `getStatus` / `getStatistics` / `getClientTelemetry` | Monitoring |
|
||||
| `getStatus` / `getStatistics` / `getClientTelemetry` | Monitoring (stats include per-transport breakdowns) |
|
||||
| `generateKeypair` / `generateWgKeypair` / `generateClientKeypair` | Key generation |
|
||||
| `addWgPeer` / `removeWgPeer` / `listWgPeers` | WireGuard peer management |
|
||||
|
||||
@@ -313,19 +517,24 @@ const unit = VpnInstaller.generateServiceUnit({
|
||||
### Server Configuration
|
||||
|
||||
```typescript
|
||||
// All transports simultaneously (default) — WS + QUIC + WireGuard
|
||||
{ transportMode: 'all', listenAddr: '0.0.0.0:443', wgPrivateKey: '...', wgListenPort: 51820 }
|
||||
|
||||
// WS + QUIC only
|
||||
{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' }
|
||||
|
||||
// WebSocket only
|
||||
{ transportMode: 'websocket', listenAddr: '0.0.0.0:443' }
|
||||
|
||||
// QUIC only
|
||||
{ transportMode: 'quic', listenAddr: '0.0.0.0:443' }
|
||||
|
||||
// Both (WS + QUIC on same or different ports)
|
||||
{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' }
|
||||
|
||||
// WireGuard
|
||||
{ transportMode: 'wireguard', wgListenPort: 51820, wgPeers: [...] }
|
||||
// WireGuard only
|
||||
{ transportMode: 'wireguard', wgPrivateKey: '...', wgListenPort: 51820, wgPeers: [...] }
|
||||
```
|
||||
|
||||
All transport modes share the same `forwardingMode` — WireGuard peers can use `'socket'` (userspace NAT) just like WS/QUIC clients.
|
||||
|
||||
### Client Configuration
|
||||
|
||||
```typescript
|
||||
@@ -370,7 +579,7 @@ pnpm install
|
||||
# Build (TypeScript + Rust cross-compile)
|
||||
pnpm build
|
||||
|
||||
# Run all tests (79 TS + 132 Rust = 211 tests)
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run Rust tests directly
|
||||
@@ -387,6 +596,8 @@ smartvpn/
|
||||
├── ts/ # TypeScript control plane
|
||||
│ ├── index.ts # All exports
|
||||
│ ├── smartvpn.interfaces.ts # Interfaces, types, IPC command maps
|
||||
│ ├── smartvpn.plugins.ts # Dependency imports
|
||||
│ ├── smartvpn.paths.ts # Binary path resolution
|
||||
│ ├── smartvpn.classes.vpnserver.ts
|
||||
│ ├── smartvpn.classes.vpnclient.ts
|
||||
│ ├── smartvpn.classes.vpnbridge.ts
|
||||
@@ -404,14 +615,20 @@ smartvpn/
|
||||
│ ├── proxy_protocol.rs # PROXY protocol v2 parser
|
||||
│ ├── management.rs # JSON-lines IPC
|
||||
│ ├── transport.rs # WebSocket transport
|
||||
│ ├── transport_trait.rs # Transport abstraction (Sink/Stream)
|
||||
│ ├── quic_transport.rs # QUIC transport
|
||||
│ ├── wireguard.rs # WireGuard (boringtun)
|
||||
│ ├── codec.rs # Binary frame protocol
|
||||
│ ├── keepalive.rs # Adaptive keepalives
|
||||
│ ├── ratelimit.rs # Token bucket
|
||||
│ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy
|
||||
│ └── ... # tunnel, network, telemetry, qos, mtu, reconnect
|
||||
├── test/ # 9 test files (79 tests)
|
||||
│ ├── tunnel.rs # TUN device management
|
||||
│ ├── network.rs # IP pool + networking
|
||||
│ ├── telemetry.rs # RTT/jitter/loss tracking
|
||||
│ ├── qos.rs # Priority queues + smart dropping
|
||||
│ ├── mtu.rs # MTU + ICMP too-big
|
||||
│ └── reconnect.rs # Exponential backoff + session tokens
|
||||
├── test/ # Test files
|
||||
├── dist_ts/ # Compiled TypeScript
|
||||
└── dist_rust/ # Cross-compiled binaries (linux amd64 + arm64)
|
||||
```
|
||||
@@ -430,7 +647,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr)
|
||||
/// Check if `ip` matches any pattern in the list.
|
||||
/// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*),
|
||||
/// and IP ranges (192.168.1.1-192.168.1.100).
|
||||
fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool {
|
||||
pub fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool {
|
||||
for pattern in patterns {
|
||||
if ip_matches(ip, pattern) {
|
||||
return true;
|
||||
@@ -164,6 +164,7 @@ mod tests {
|
||||
destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()),
|
||||
max_connections: None,
|
||||
rate_limit: None,
|
||||
destination_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
352
rust/src/bridge.rs
Normal file
352
rust/src/bridge.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
//! L2 Bridge forwarding engine.
|
||||
//!
|
||||
//! Provides server-side bridging: receives L3 IP packets from VPN clients,
|
||||
//! wraps them in Ethernet frames, and injects them into a Linux bridge
|
||||
//! connected to the host's physical network interface.
|
||||
//!
|
||||
//! Return traffic from the bridge is stripped of its Ethernet header and
|
||||
//! routed back to VPN clients via `tun_routes`.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::server::ServerState;
|
||||
|
||||
/// Configuration for the bridge forwarding engine.
|
||||
pub struct BridgeConfig {
|
||||
/// TAP device name (e.g., "svpn_tap0")
|
||||
pub tap_name: String,
|
||||
/// Linux bridge name (e.g., "svpn_br0")
|
||||
pub bridge_name: String,
|
||||
/// Physical interface to bridge (e.g., "eth0")
|
||||
pub physical_interface: String,
|
||||
/// Gateway IP on the bridge (host's LAN IP)
|
||||
pub gateway_ip: Ipv4Addr,
|
||||
/// Subnet prefix length (e.g., 24)
|
||||
pub prefix_len: u8,
|
||||
/// MTU for the TAP device
|
||||
pub mtu: u16,
|
||||
}
|
||||
|
||||
/// Ethernet frame constants
|
||||
const ETH_HEADER_LEN: usize = 14;
|
||||
const ETH_TYPE_IPV4: [u8; 2] = [0x08, 0x00];
|
||||
const ETH_TYPE_ARP: [u8; 2] = [0x08, 0x06];
|
||||
const BROADCAST_MAC: [u8; 6] = [0xff; 6];
|
||||
|
||||
/// Generate a deterministic locally-administered MAC from an IPv4 address.
|
||||
/// Uses prefix 02:53:56 (locally administered, "SVP" in hex-ish).
|
||||
fn mac_from_ip(ip: Ipv4Addr) -> [u8; 6] {
|
||||
let octets = ip.octets();
|
||||
[0x02, 0x53, 0x56, octets[1], octets[2], octets[3]]
|
||||
}
|
||||
|
||||
/// Wrap an IP packet in an Ethernet frame.
|
||||
fn wrap_in_ethernet(ip_packet: &[u8], src_mac: [u8; 6], dst_mac: [u8; 6]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(ETH_HEADER_LEN + ip_packet.len());
|
||||
frame.extend_from_slice(&dst_mac);
|
||||
frame.extend_from_slice(&src_mac);
|
||||
frame.extend_from_slice(Ð_TYPE_IPV4);
|
||||
frame.extend_from_slice(ip_packet);
|
||||
frame
|
||||
}
|
||||
|
||||
/// Extract the EtherType and payload from an Ethernet frame.
|
||||
fn unwrap_ethernet(frame: &[u8]) -> Option<([u8; 2], &[u8])> {
|
||||
if frame.len() < ETH_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let ether_type = [frame[12], frame[13]];
|
||||
Some((ether_type, &frame[ETH_HEADER_LEN..]))
|
||||
}
|
||||
|
||||
/// Extract destination IPv4 from a raw IP packet header.
|
||||
fn dst_ip_from_packet(packet: &[u8]) -> Option<Ipv4Addr> {
|
||||
if packet.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
// Version must be 4
|
||||
if (packet[0] >> 4) != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]))
|
||||
}
|
||||
|
||||
/// Extract source IPv4 from a raw IP packet header.
|
||||
fn src_ip_from_packet(packet: &[u8]) -> Option<Ipv4Addr> {
|
||||
if packet.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
if (packet[0] >> 4) != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]))
|
||||
}
|
||||
|
||||
/// Build a gratuitous ARP announcement frame.
|
||||
fn build_garp(ip: Ipv4Addr, mac: [u8; 6]) -> Vec<u8> {
|
||||
let ip_bytes = ip.octets();
|
||||
let mut frame = Vec::with_capacity(42); // 14 eth + 28 ARP
|
||||
// Ethernet header
|
||||
frame.extend_from_slice(&BROADCAST_MAC); // dst: broadcast
|
||||
frame.extend_from_slice(&mac); // src: our MAC
|
||||
frame.extend_from_slice(Ð_TYPE_ARP); // EtherType: ARP
|
||||
// ARP payload
|
||||
frame.extend_from_slice(&[0x00, 0x01]); // Hardware type: Ethernet
|
||||
frame.extend_from_slice(&[0x08, 0x00]); // Protocol type: IPv4
|
||||
frame.push(6); // Hardware addr len
|
||||
frame.push(4); // Protocol addr len
|
||||
frame.extend_from_slice(&[0x00, 0x01]); // Operation: ARP Request (GARP uses request)
|
||||
frame.extend_from_slice(&mac); // Sender hardware addr
|
||||
frame.extend_from_slice(&ip_bytes); // Sender protocol addr
|
||||
frame.extend_from_slice(&[0x00; 6]); // Target hardware addr (ignored in GARP)
|
||||
frame.extend_from_slice(&ip_bytes); // Target protocol addr (same as sender for GARP)
|
||||
frame
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux bridge management (ip commands)
|
||||
// ============================================================================
|
||||
|
||||
async fn run_ip_cmd(args: &[&str]) -> Result<String> {
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(args)
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("ip {} failed: {}", args.join(" "), stderr.trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Create a Linux bridge interface.
|
||||
pub async fn create_bridge(name: &str) -> Result<()> {
|
||||
run_ip_cmd(&["link", "add", name, "type", "bridge"]).await?;
|
||||
info!("Created bridge {}", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an interface to a bridge.
|
||||
pub async fn bridge_add_interface(bridge: &str, iface: &str) -> Result<()> {
|
||||
run_ip_cmd(&["link", "set", iface, "master", bridge]).await?;
|
||||
info!("Added {} to bridge {}", iface, bridge);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bring an interface up.
|
||||
pub async fn set_interface_up(iface: &str) -> Result<()> {
|
||||
run_ip_cmd(&["link", "set", iface, "up"]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a bridge interface.
|
||||
pub async fn remove_bridge(name: &str) -> Result<()> {
|
||||
// First bring it down, ignore errors
|
||||
let _ = run_ip_cmd(&["link", "set", name, "down"]).await;
|
||||
run_ip_cmd(&["link", "del", name]).await?;
|
||||
info!("Removed bridge {}", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect the default network interface from the routing table.
|
||||
pub async fn detect_default_interface() -> Result<String> {
|
||||
let output = run_ip_cmd(&["route", "show", "default"]).await?;
|
||||
// Format: "default via X.X.X.X dev IFACE ..."
|
||||
let parts: Vec<&str> = output.split_whitespace().collect();
|
||||
if let Some(idx) = parts.iter().position(|&s| s == "dev") {
|
||||
if let Some(iface) = parts.get(idx + 1) {
|
||||
return Ok(iface.to_string());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Could not detect default network interface from route table");
|
||||
}
|
||||
|
||||
/// Get the IP address and prefix length of a network interface.
|
||||
pub async fn get_interface_ip(iface: &str) -> Result<(Ipv4Addr, u8)> {
|
||||
let output = run_ip_cmd(&["-4", "addr", "show", "dev", iface]).await?;
|
||||
// Parse "inet X.X.X.X/NN" from output
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("inet ") {
|
||||
let addr_cidr = rest.split_whitespace().next().unwrap_or("");
|
||||
let parts: Vec<&str> = addr_cidr.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let ip: Ipv4Addr = parts[0].parse()?;
|
||||
let prefix: u8 = parts[1].parse()?;
|
||||
return Ok((ip, prefix));
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Could not find IPv4 address on interface {}", iface);
|
||||
}
|
||||
|
||||
/// Migrate the host's IP from a physical interface to a bridge.
|
||||
/// This is the most delicate operation — briefly interrupts connectivity.
|
||||
pub async fn migrate_host_ip_to_bridge(
|
||||
physical_iface: &str,
|
||||
bridge: &str,
|
||||
ip: Ipv4Addr,
|
||||
prefix: u8,
|
||||
) -> Result<()> {
|
||||
let cidr = format!("{}/{}", ip, prefix);
|
||||
// Remove IP from physical interface
|
||||
let _ = run_ip_cmd(&["addr", "del", &cidr, "dev", physical_iface]).await;
|
||||
// Add IP to bridge
|
||||
run_ip_cmd(&["addr", "add", &cidr, "dev", bridge]).await?;
|
||||
info!("Migrated IP {} from {} to {}", cidr, physical_iface, bridge);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore the host's IP from bridge back to the physical interface.
|
||||
pub async fn restore_host_ip(
|
||||
physical_iface: &str,
|
||||
bridge: &str,
|
||||
ip: Ipv4Addr,
|
||||
prefix: u8,
|
||||
) -> Result<()> {
|
||||
let cidr = format!("{}/{}", ip, prefix);
|
||||
let _ = run_ip_cmd(&["addr", "del", &cidr, "dev", bridge]).await;
|
||||
run_ip_cmd(&["addr", "add", &cidr, "dev", physical_iface]).await?;
|
||||
info!("Restored IP {} to {}", cidr, physical_iface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable proxy ARP on an interface via sysctl.
|
||||
pub async fn enable_proxy_arp(iface: &str) -> Result<()> {
|
||||
let path = format!("/proc/sys/net/ipv4/conf/{}/proxy_arp", iface);
|
||||
tokio::fs::write(&path, "1").await?;
|
||||
info!("Enabled proxy_arp on {}", iface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a TAP device (L2) using the tun crate.
|
||||
pub fn create_tap(name: &str, mtu: u16) -> Result<tun::AsyncDevice> {
|
||||
let mut config = tun::Configuration::default();
|
||||
config
|
||||
.tun_name(name)
|
||||
.layer(tun::Layer::L2)
|
||||
.mtu(mtu)
|
||||
.up();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
config.platform_config(|p| {
|
||||
p.ensure_root_privileges(true);
|
||||
});
|
||||
|
||||
let device = tun::create_as_async(&config)?;
|
||||
info!("TAP device {} created (L2, mtu={})", name, mtu);
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BridgeEngine — main event loop
|
||||
// ============================================================================
|
||||
|
||||
/// The BridgeEngine wraps/unwraps Ethernet frames and bridges VPN traffic
|
||||
/// to the host's physical LAN via a Linux bridge + TAP device.
|
||||
pub struct BridgeEngine {
|
||||
state: Arc<ServerState>,
|
||||
/// Learned MAC addresses for LAN peers (dst IP → MAC).
|
||||
/// Populated from ARP replies and Ethernet frame src MACs.
|
||||
arp_cache: HashMap<Ipv4Addr, [u8; 6]>,
|
||||
}
|
||||
|
||||
impl BridgeEngine {
|
||||
pub fn new(state: Arc<ServerState>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
arp_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the bridge engine event loop.
|
||||
/// Receives L3 IP packets from VPN clients, wraps in Ethernet, writes to TAP.
|
||||
/// Reads Ethernet frames from TAP, strips header, routes back to VPN clients.
|
||||
pub async fn run(
|
||||
mut self,
|
||||
mut tap_device: tun::AsyncDevice,
|
||||
mut packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||
mut shutdown_rx: mpsc::Receiver<()>,
|
||||
) -> Result<()> {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
|
||||
info!("BridgeEngine started");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Packet from VPN client → wrap in Ethernet → write to TAP
|
||||
Some(ip_packet) = packet_rx.recv() => {
|
||||
if let Some(dst_ip) = dst_ip_from_packet(&ip_packet) {
|
||||
let src_ip = src_ip_from_packet(&ip_packet).unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
let src_mac = mac_from_ip(src_ip);
|
||||
let dst_mac = self.arp_cache.get(&dst_ip)
|
||||
.copied()
|
||||
.unwrap_or(BROADCAST_MAC);
|
||||
let frame = wrap_in_ethernet(&ip_packet, src_mac, dst_mac);
|
||||
if let Err(e) = tap_device.write_all(&frame).await {
|
||||
warn!("TAP write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frame from TAP (LAN) → strip Ethernet → route to VPN client
|
||||
result = tap_device.read(&mut buf) => {
|
||||
match result {
|
||||
Ok(len) if len >= ETH_HEADER_LEN => {
|
||||
let frame = &buf[..len];
|
||||
|
||||
// Learn src MAC from incoming frames
|
||||
if let Some((ether_type, payload)) = unwrap_ethernet(frame) {
|
||||
// Learn ARP cache from src MAC + src IP
|
||||
let src_mac: [u8; 6] = frame[6..12].try_into().unwrap_or([0; 6]);
|
||||
if ether_type == ETH_TYPE_IPV4 {
|
||||
if let Some(src_ip) = src_ip_from_packet(payload) {
|
||||
self.arp_cache.insert(src_ip, src_mac);
|
||||
}
|
||||
}
|
||||
|
||||
// Only forward IPv4 packets to VPN clients
|
||||
if ether_type == ETH_TYPE_IPV4 {
|
||||
if let Some(dst_ip) = dst_ip_from_packet(payload) {
|
||||
// Look up VPN client by dst IP in tun_routes
|
||||
let routes = self.state.tun_routes.read().await;
|
||||
if let Some(sender) = routes.get(&dst_ip) {
|
||||
let _ = sender.try_send(payload.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Frame too short, ignore
|
||||
Err(e) => {
|
||||
warn!("TAP read error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("BridgeEngine shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a gratuitous ARP for a VPN client IP.
|
||||
pub async fn announce_client(tap: &mut tun::AsyncDevice, ip: Ipv4Addr) -> Result<()> {
|
||||
let mac = mac_from_ip(ip);
|
||||
let garp = build_garp(ip, mac);
|
||||
tap.write_all(&garp).await?;
|
||||
debug!("Sent GARP for {} (MAC {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x})",
|
||||
ip, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ pub struct VpnClient {
|
||||
connected_since: Arc<RwLock<Option<std::time::Instant>>>,
|
||||
quality_rx: Option<watch::Receiver<ConnectionQuality>>,
|
||||
link_health: Arc<RwLock<LinkHealth>>,
|
||||
connection_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl VpnClient {
|
||||
@@ -93,6 +94,7 @@ impl VpnClient {
|
||||
connected_since: Arc::new(RwLock::new(None)),
|
||||
quality_rx: None,
|
||||
link_health: Arc::new(RwLock::new(LinkHealth::Degraded)),
|
||||
connection_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +282,7 @@ impl VpnClient {
|
||||
|
||||
// Spawn packet forwarding loop
|
||||
let assigned_ip_clone = assigned_ip.clone();
|
||||
tokio::spawn(client_loop(
|
||||
let join_handle = tokio::spawn(client_loop(
|
||||
sink,
|
||||
stream,
|
||||
noise_transport,
|
||||
@@ -294,6 +296,7 @@ impl VpnClient {
|
||||
tun_writer,
|
||||
tun_subnet,
|
||||
));
|
||||
self.connection_handle = Some(join_handle);
|
||||
|
||||
Ok(assigned_ip_clone)
|
||||
}
|
||||
@@ -303,6 +306,13 @@ impl VpnClient {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
// Wait for the connection task to send the Disconnect frame and close
|
||||
if let Some(handle) = self.connection_handle.take() {
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
handle,
|
||||
).await;
|
||||
}
|
||||
*self.assigned_ip.write().await = None;
|
||||
*self.connected_since.write().await = None;
|
||||
*self.state.write().await = ClientState::Disconnected;
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct ClientSecurity {
|
||||
pub max_connections: Option<u32>,
|
||||
/// Per-client rate limiting.
|
||||
pub rate_limit: Option<ClientRateLimit>,
|
||||
/// Per-client destination routing policy override.
|
||||
/// When set, overrides the server-level DestinationPolicy for this client's traffic.
|
||||
pub destination_policy: Option<crate::server::DestinationPolicyConfig>,
|
||||
}
|
||||
|
||||
/// A registered client entry — the server-side source of truth.
|
||||
@@ -44,7 +47,12 @@ pub struct ClientEntry {
|
||||
pub priority: Option<u32>,
|
||||
/// Whether this client is enabled (default: true).
|
||||
pub enabled: Option<bool>,
|
||||
/// Tags for grouping.
|
||||
/// Tags assigned by the server admin — trusted, used for access control.
|
||||
pub server_defined_client_tags: Option<Vec<String>>,
|
||||
/// Tags reported by the connecting client — informational only.
|
||||
pub client_defined_client_tags: Option<Vec<String>>,
|
||||
/// Legacy tags field — treated as serverDefinedClientTags during deserialization.
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// Optional description.
|
||||
pub description: Option<String>,
|
||||
@@ -71,12 +79,14 @@ impl ClientEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory client registry with dual-key indexing.
|
||||
/// In-memory client registry with triple-key indexing.
|
||||
pub struct ClientRegistry {
|
||||
/// Primary index: clientId → ClientEntry
|
||||
entries: HashMap<String, ClientEntry>,
|
||||
/// Secondary index: publicKey (base64) → clientId (fast lookup during handshake)
|
||||
key_index: HashMap<String, String>,
|
||||
/// Tertiary index: assignedIp → clientId (fast lookup during NAT destination policy)
|
||||
ip_index: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ClientRegistry {
|
||||
@@ -84,13 +94,18 @@ impl ClientRegistry {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
key_index: HashMap::new(),
|
||||
ip_index: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a registry from a list of client entries.
|
||||
pub fn from_entries(entries: Vec<ClientEntry>) -> Result<Self> {
|
||||
let mut registry = Self::new();
|
||||
for entry in entries {
|
||||
for mut entry in entries {
|
||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||
if entry.server_defined_client_tags.is_none() && entry.tags.is_some() {
|
||||
entry.server_defined_client_tags = entry.tags.take();
|
||||
}
|
||||
registry.add(entry)?;
|
||||
}
|
||||
Ok(registry)
|
||||
@@ -105,6 +120,9 @@ impl ClientRegistry {
|
||||
anyhow::bail!("Public key already registered to another client");
|
||||
}
|
||||
self.key_index.insert(entry.public_key.clone(), entry.client_id.clone());
|
||||
if let Some(ref ip) = entry.assigned_ip {
|
||||
self.ip_index.insert(ip.clone(), entry.client_id.clone());
|
||||
}
|
||||
self.entries.insert(entry.client_id.clone(), entry);
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,6 +132,9 @@ impl ClientRegistry {
|
||||
let entry = self.entries.remove(client_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||
self.key_index.remove(&entry.public_key);
|
||||
if let Some(ref ip) = entry.assigned_ip {
|
||||
self.ip_index.remove(ip);
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
@@ -128,6 +149,12 @@ impl ClientRegistry {
|
||||
self.entries.get(client_id)
|
||||
}
|
||||
|
||||
/// Get a client by assigned IP (used for per-client destination policy in NAT engine).
|
||||
pub fn get_by_assigned_ip(&self, ip: &str) -> Option<&ClientEntry> {
|
||||
let client_id = self.ip_index.get(ip)?;
|
||||
self.entries.get(client_id)
|
||||
}
|
||||
|
||||
/// Check if a public key is authorized (exists, enabled, not expired).
|
||||
pub fn is_authorized(&self, public_key: &str) -> bool {
|
||||
match self.get_by_key(public_key) {
|
||||
@@ -144,12 +171,22 @@ impl ClientRegistry {
|
||||
let entry = self.entries.get_mut(client_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||
let old_key = entry.public_key.clone();
|
||||
let old_ip = entry.assigned_ip.clone();
|
||||
updater(entry);
|
||||
// If public key changed, update the index
|
||||
// If public key changed, update the key index
|
||||
if entry.public_key != old_key {
|
||||
self.key_index.remove(&old_key);
|
||||
self.key_index.insert(entry.public_key.clone(), client_id.to_string());
|
||||
}
|
||||
// If assigned IP changed, update the IP index
|
||||
if entry.assigned_ip != old_ip {
|
||||
if let Some(ref old) = old_ip {
|
||||
self.ip_index.remove(old);
|
||||
}
|
||||
if let Some(ref new_ip) = entry.assigned_ip {
|
||||
self.ip_index.insert(new_ip.clone(), client_id.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -193,6 +230,8 @@ mod tests {
|
||||
security: None,
|
||||
priority: None,
|
||||
enabled: None,
|
||||
server_defined_client_tags: None,
|
||||
client_defined_client_tags: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
expires_at: None,
|
||||
@@ -351,6 +390,7 @@ mod tests {
|
||||
bytes_per_sec: 1_000_000,
|
||||
burst_bytes: 2_000_000,
|
||||
}),
|
||||
destination_policy: None,
|
||||
});
|
||||
let mut reg = ClientRegistry::new();
|
||||
reg.add(entry).unwrap();
|
||||
|
||||
@@ -22,3 +22,4 @@ pub mod client_registry;
|
||||
pub mod acl;
|
||||
pub mod proxy_protocol;
|
||||
pub mod userspace_nat;
|
||||
pub mod bridge;
|
||||
|
||||
@@ -7,7 +7,7 @@ use tracing::{info, error, warn};
|
||||
use crate::client::{ClientConfig, VpnClient};
|
||||
use crate::crypto;
|
||||
use crate::server::{ServerConfig, VpnServer};
|
||||
use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig, WgServer, WgServerConfig};
|
||||
use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig};
|
||||
|
||||
// ============================================================================
|
||||
// IPC protocol types
|
||||
@@ -95,7 +95,6 @@ pub async fn management_loop_stdio(mode: &str) -> Result<()> {
|
||||
let mut vpn_client = VpnClient::new();
|
||||
let mut vpn_server = VpnServer::new();
|
||||
let mut wg_client = WgClient::new();
|
||||
let mut wg_server = WgServer::new();
|
||||
|
||||
send_event_stdout("ready", serde_json::json!({ "mode": mode }));
|
||||
|
||||
@@ -131,7 +130,7 @@ pub async fn management_loop_stdio(mode: &str) -> Result<()> {
|
||||
|
||||
let response = match mode {
|
||||
"client" => handle_client_request(&request, &mut vpn_client, &mut wg_client).await,
|
||||
"server" => handle_server_request(&request, &mut vpn_server, &mut wg_server).await,
|
||||
"server" => handle_server_request(&request, &mut vpn_server).await,
|
||||
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
||||
};
|
||||
send_response_stdout(&response);
|
||||
@@ -154,7 +153,6 @@ pub async fn management_loop_socket(socket_path: &str, mode: &str) -> Result<()>
|
||||
let vpn_client = std::sync::Arc::new(Mutex::new(VpnClient::new()));
|
||||
let vpn_server = std::sync::Arc::new(Mutex::new(VpnServer::new()));
|
||||
let wg_client = std::sync::Arc::new(Mutex::new(WgClient::new()));
|
||||
let wg_server = std::sync::Arc::new(Mutex::new(WgServer::new()));
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
@@ -163,10 +161,9 @@ pub async fn management_loop_socket(socket_path: &str, mode: &str) -> Result<()>
|
||||
let client = vpn_client.clone();
|
||||
let server = vpn_server.clone();
|
||||
let wg_c = wg_client.clone();
|
||||
let wg_s = wg_server.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
handle_socket_connection(stream, &mode, client, server, wg_c, wg_s).await
|
||||
handle_socket_connection(stream, &mode, client, server, wg_c).await
|
||||
{
|
||||
warn!("Socket connection error: {}", e);
|
||||
}
|
||||
@@ -185,7 +182,6 @@ async fn handle_socket_connection(
|
||||
vpn_client: std::sync::Arc<Mutex<VpnClient>>,
|
||||
vpn_server: std::sync::Arc<Mutex<VpnServer>>,
|
||||
wg_client: std::sync::Arc<Mutex<WgClient>>,
|
||||
wg_server: std::sync::Arc<Mutex<WgServer>>,
|
||||
) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let buf_reader = BufReader::new(reader);
|
||||
@@ -241,8 +237,7 @@ async fn handle_socket_connection(
|
||||
}
|
||||
"server" => {
|
||||
let mut server = vpn_server.lock().await;
|
||||
let mut wg_s = wg_server.lock().await;
|
||||
handle_server_request(&request, &mut server, &mut wg_s).await
|
||||
handle_server_request(&request, &mut server).await
|
||||
}
|
||||
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
||||
};
|
||||
@@ -381,92 +376,46 @@ async fn handle_client_request(
|
||||
async fn handle_server_request(
|
||||
request: &ManagementRequest,
|
||||
vpn_server: &mut VpnServer,
|
||||
wg_server: &mut WgServer,
|
||||
) -> ManagementResponse {
|
||||
let id = request.id.clone();
|
||||
|
||||
match request.method.as_str() {
|
||||
"start" => {
|
||||
// Check if transportMode is "wireguard"
|
||||
let transport_mode = request.params
|
||||
.get("config")
|
||||
.and_then(|c| c.get("transportMode"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if transport_mode == "wireguard" {
|
||||
let config: WgServerConfig = match serde_json::from_value(
|
||||
request.params.get("config").cloned().unwrap_or_default(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ManagementResponse::err(id, format!("Invalid WG config: {}", e));
|
||||
}
|
||||
};
|
||||
match wg_server.start(config).await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("WG start failed: {}", e)),
|
||||
}
|
||||
} else {
|
||||
let config: ServerConfig = match serde_json::from_value(
|
||||
request.params.get("config").cloned().unwrap_or_default(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ManagementResponse::err(id, format!("Invalid config: {}", e));
|
||||
}
|
||||
};
|
||||
match vpn_server.start(config).await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Start failed: {}", e)),
|
||||
let config: ServerConfig = match serde_json::from_value(
|
||||
request.params.get("config").cloned().unwrap_or_default(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ManagementResponse::err(id, format!("Invalid config: {}", e));
|
||||
}
|
||||
};
|
||||
match vpn_server.start(config).await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Start failed: {}", e)),
|
||||
}
|
||||
}
|
||||
"stop" => {
|
||||
if wg_server.is_running() {
|
||||
match wg_server.stop().await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("WG stop failed: {}", e)),
|
||||
}
|
||||
} else {
|
||||
match vpn_server.stop().await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Stop failed: {}", e)),
|
||||
}
|
||||
match vpn_server.stop().await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Stop failed: {}", e)),
|
||||
}
|
||||
}
|
||||
"getStatus" => {
|
||||
if wg_server.is_running() {
|
||||
ManagementResponse::ok(id, wg_server.get_status())
|
||||
} else {
|
||||
let status = vpn_server.get_status();
|
||||
ManagementResponse::ok(id, status)
|
||||
}
|
||||
let status = vpn_server.get_status();
|
||||
ManagementResponse::ok(id, status)
|
||||
}
|
||||
"getStatistics" => {
|
||||
if wg_server.is_running() {
|
||||
ManagementResponse::ok(id, wg_server.get_statistics().await)
|
||||
} else {
|
||||
let stats = vpn_server.get_statistics().await;
|
||||
match serde_json::to_value(&stats) {
|
||||
Ok(v) => ManagementResponse::ok(id, v),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
}
|
||||
let stats = vpn_server.get_statistics().await;
|
||||
match serde_json::to_value(&stats) {
|
||||
Ok(v) => ManagementResponse::ok(id, v),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
}
|
||||
}
|
||||
"listClients" => {
|
||||
if wg_server.is_running() {
|
||||
let peers = wg_server.list_peers().await;
|
||||
match serde_json::to_value(&peers) {
|
||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
}
|
||||
} else {
|
||||
let clients = vpn_server.list_clients().await;
|
||||
match serde_json::to_value(&clients) {
|
||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
}
|
||||
let clients = vpn_server.list_clients().await;
|
||||
match serde_json::to_value(&clients) {
|
||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
}
|
||||
}
|
||||
"disconnectClient" => {
|
||||
@@ -546,9 +495,6 @@ async fn handle_server_request(
|
||||
)
|
||||
}
|
||||
"addWgPeer" => {
|
||||
if !wg_server.is_running() {
|
||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
||||
}
|
||||
let config: WgPeerConfig = match serde_json::from_value(
|
||||
request.params.get("peer").cloned().unwrap_or_default(),
|
||||
) {
|
||||
@@ -557,29 +503,23 @@ async fn handle_server_request(
|
||||
return ManagementResponse::err(id, format!("Invalid peer config: {}", e));
|
||||
}
|
||||
};
|
||||
match wg_server.add_peer(config).await {
|
||||
match vpn_server.add_wg_peer(config).await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Add peer failed: {}", e)),
|
||||
}
|
||||
}
|
||||
"removeWgPeer" => {
|
||||
if !wg_server.is_running() {
|
||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
||||
}
|
||||
let public_key = match request.params.get("publicKey").and_then(|v| v.as_str()) {
|
||||
Some(k) => k.to_string(),
|
||||
None => return ManagementResponse::err(id, "Missing publicKey".to_string()),
|
||||
};
|
||||
match wg_server.remove_peer(&public_key).await {
|
||||
match vpn_server.remove_wg_peer(&public_key).await {
|
||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||
Err(e) => ManagementResponse::err(id, format!("Remove peer failed: {}", e)),
|
||||
}
|
||||
}
|
||||
"listWgPeers" => {
|
||||
if !wg_server.is_running() {
|
||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
||||
}
|
||||
let peers = wg_server.list_peers().await;
|
||||
let peers = vpn_server.list_wg_peers().await;
|
||||
match serde_json::to_value(&peers) {
|
||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "peers": v })),
|
||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||
|
||||
@@ -13,6 +13,10 @@ pub struct IpPool {
|
||||
allocated: HashMap<Ipv4Addr, String>,
|
||||
/// Next candidate offset (skipping .0 network and .1 gateway)
|
||||
next_offset: u32,
|
||||
/// Minimum allocation offset (inclusive). Default: 2 (skip .0 network and .1 gateway).
|
||||
min_offset: u32,
|
||||
/// Maximum allocation offset (exclusive). Default: broadcast offset.
|
||||
max_offset: u32,
|
||||
}
|
||||
|
||||
impl IpPool {
|
||||
@@ -28,11 +32,47 @@ impl IpPool {
|
||||
anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len);
|
||||
}
|
||||
|
||||
let host_bits = 32 - prefix_len as u32;
|
||||
let max_offset = (1u32 << host_bits) - 1; // broadcast offset
|
||||
|
||||
Ok(Self {
|
||||
network,
|
||||
prefix_len,
|
||||
allocated: HashMap::new(),
|
||||
next_offset: 2, // Skip .0 (network) and .1 (server/gateway)
|
||||
min_offset: 2,
|
||||
max_offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new IP pool with a restricted allocation range within the subnet.
|
||||
/// `range_start` and `range_end` are host offsets (e.g., 200 and 250 for .200-.250).
|
||||
pub fn new_with_range(subnet: &str, range_start: u32, range_end: u32) -> Result<Self> {
|
||||
let parts: Vec<&str> = subnet.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!("Invalid subnet format: {}", subnet);
|
||||
}
|
||||
let network: Ipv4Addr = parts[0].parse()?;
|
||||
let prefix_len: u8 = parts[1].parse()?;
|
||||
if prefix_len > 30 {
|
||||
anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len);
|
||||
}
|
||||
if range_start >= range_end {
|
||||
anyhow::bail!("Invalid IP range: start ({}) must be less than end ({})", range_start, range_end);
|
||||
}
|
||||
let host_bits = 32 - prefix_len as u32;
|
||||
let broadcast_offset = (1u32 << host_bits) - 1;
|
||||
if range_end > broadcast_offset {
|
||||
anyhow::bail!("IP range end ({}) exceeds subnet broadcast ({})", range_end, broadcast_offset);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
network,
|
||||
prefix_len,
|
||||
allocated: HashMap::new(),
|
||||
next_offset: range_start,
|
||||
min_offset: range_start,
|
||||
max_offset: range_end + 1, // exclusive
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,22 +84,17 @@ impl IpPool {
|
||||
|
||||
/// Total number of usable client addresses in the pool.
|
||||
pub fn capacity(&self) -> u32 {
|
||||
let host_bits = 32 - self.prefix_len as u32;
|
||||
let total = 1u32 << host_bits;
|
||||
total.saturating_sub(3) // minus network, gateway, broadcast
|
||||
self.max_offset.saturating_sub(self.min_offset)
|
||||
}
|
||||
|
||||
/// Allocate an IP for a client. Returns the assigned IP.
|
||||
pub fn allocate(&mut self, client_id: &str) -> Result<Ipv4Addr> {
|
||||
let host_bits = 32 - self.prefix_len as u32;
|
||||
let max_offset = (1u32 << host_bits) - 1; // broadcast offset
|
||||
|
||||
// Try to find a free IP starting from next_offset
|
||||
let start = self.next_offset;
|
||||
let mut offset = start;
|
||||
loop {
|
||||
if offset >= max_offset {
|
||||
offset = 2; // wrap around
|
||||
if offset >= self.max_offset {
|
||||
offset = self.min_offset; // wrap around
|
||||
}
|
||||
|
||||
let ip = Ipv4Addr::from(u32::from(self.network) + offset);
|
||||
@@ -86,6 +121,16 @@ impl IpPool {
|
||||
client_id
|
||||
}
|
||||
|
||||
/// Reserve a specific IP for a client (e.g., WireGuard static IP from allowed_ips).
|
||||
pub fn reserve(&mut self, ip: Ipv4Addr, client_id: &str) -> Result<()> {
|
||||
if self.allocated.contains_key(&ip) {
|
||||
anyhow::bail!("IP {} is already allocated", ip);
|
||||
}
|
||||
self.allocated.insert(ip, client_id.to_string());
|
||||
info!("Reserved IP {} for client {}", ip, client_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of currently allocated IPs.
|
||||
pub fn allocated_count(&self) -> usize {
|
||||
self.allocated.len()
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tracing::{info, error, warn};
|
||||
use tracing::{debug, info, error, warn};
|
||||
|
||||
use crate::acl;
|
||||
use crate::client_registry::{ClientEntry, ClientRegistry};
|
||||
@@ -24,6 +24,20 @@ use crate::tunnel::{self, TunConfig};
|
||||
/// Dead-peer timeout: 3x max keepalive interval (Healthy=60s).
|
||||
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Destination routing policy for VPN client traffic.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DestinationPolicyConfig {
|
||||
/// Default action: "forceTarget", "block", or "allow".
|
||||
pub default: String,
|
||||
/// Target IP for "forceTarget" mode (e.g. "127.0.0.1").
|
||||
pub target: Option<String>,
|
||||
/// Destinations that pass through directly (not rewritten, not blocked).
|
||||
pub allow_list: Option<Vec<String>>,
|
||||
/// Destinations always blocked (overrides allowList, deny wins).
|
||||
pub block_list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Server configuration (matches TS IVpnServerConfig).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -58,6 +72,37 @@ pub struct ServerConfig {
|
||||
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>>,
|
||||
/// When true and forwarding_mode is "socket", the userspace NAT engine prepends
|
||||
/// PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's
|
||||
/// tunnel IP as the source address.
|
||||
pub socket_forward_proxy_protocol: Option<bool>,
|
||||
/// Destination routing policy for VPN client traffic (socket mode).
|
||||
pub destination_policy: Option<DestinationPolicyConfig>,
|
||||
/// WireGuard: server X25519 private key (base64). Required when transport includes WG.
|
||||
pub wg_private_key: Option<String>,
|
||||
/// WireGuard: UDP listen port (default: 51820).
|
||||
pub wg_listen_port: Option<u16>,
|
||||
/// WireGuard: pre-configured peers.
|
||||
pub wg_peers: Option<Vec<crate::wireguard::WgPeerConfig>>,
|
||||
/// Public endpoint address for generated client configs (e.g. "vpn.example.com:51820").
|
||||
/// Used as WireGuard `Endpoint` and SmartVPN `serverUrl` host.
|
||||
/// Defaults to listen_addr.
|
||||
pub server_endpoint: Option<String>,
|
||||
/// AllowedIPs for generated WireGuard client configs.
|
||||
/// Defaults to ["0.0.0.0/0"] (full tunnel).
|
||||
#[serde(alias = "clientAllowedIPs")]
|
||||
pub client_allowed_ips: Option<Vec<String>>,
|
||||
|
||||
// Bridge mode configuration (forwarding_mode: "bridge")
|
||||
|
||||
/// LAN subnet CIDR for bridge mode (e.g. "192.168.1.0/24").
|
||||
pub bridge_lan_subnet: Option<String>,
|
||||
/// Physical network interface to bridge (e.g. "eth0"). Auto-detected if omitted.
|
||||
pub bridge_physical_interface: Option<String>,
|
||||
/// Start of VPN client IP range within the LAN subnet (host offset, e.g. 200).
|
||||
pub bridge_ip_range_start: Option<u32>,
|
||||
/// End of VPN client IP range within the LAN subnet (host offset, e.g. 250).
|
||||
pub bridge_ip_range_end: Option<u32>,
|
||||
}
|
||||
|
||||
/// Information about a connected client.
|
||||
@@ -81,6 +126,8 @@ pub struct ClientInfo {
|
||||
pub registered_client_id: String,
|
||||
/// Real client IP:port (from PROXY protocol header or direct TCP connection).
|
||||
pub remote_addr: Option<String>,
|
||||
/// Transport used for this connection: "websocket", "quic", or "wireguard".
|
||||
pub transport_type: String,
|
||||
}
|
||||
|
||||
/// Server statistics.
|
||||
@@ -96,6 +143,14 @@ pub struct ServerStatistics {
|
||||
pub uptime_seconds: u64,
|
||||
pub active_clients: u64,
|
||||
pub total_connections: u64,
|
||||
/// Per-transport active client counts.
|
||||
pub active_clients_websocket: u64,
|
||||
pub active_clients_quic: u64,
|
||||
pub active_clients_wireguard: u64,
|
||||
/// Per-transport total connection counts.
|
||||
pub total_connections_websocket: u64,
|
||||
pub total_connections_quic: u64,
|
||||
pub total_connections_wireguard: u64,
|
||||
}
|
||||
|
||||
/// The forwarding engine determines how decrypted IP packets are routed.
|
||||
@@ -104,6 +159,8 @@ pub enum ForwardingEngine {
|
||||
Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
|
||||
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
|
||||
Socket(mpsc::Sender<Vec<u8>>),
|
||||
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
|
||||
Bridge(mpsc::Sender<Vec<u8>>),
|
||||
/// Testing/monitoring — packets are counted but not forwarded.
|
||||
Testing,
|
||||
}
|
||||
@@ -130,6 +187,7 @@ pub struct ServerState {
|
||||
pub struct VpnServer {
|
||||
state: Option<Arc<ServerState>>,
|
||||
shutdown_tx: Option<mpsc::Sender<()>>,
|
||||
wg_command_tx: Option<mpsc::Sender<crate::wireguard::WgCommand>>,
|
||||
}
|
||||
|
||||
impl VpnServer {
|
||||
@@ -137,6 +195,7 @@ impl VpnServer {
|
||||
Self {
|
||||
state: None,
|
||||
shutdown_tx: None,
|
||||
wg_command_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +204,15 @@ impl VpnServer {
|
||||
anyhow::bail!("Server is already running");
|
||||
}
|
||||
|
||||
let ip_pool = IpPool::new(&config.subnet)?;
|
||||
let mode = config.forwarding_mode.as_deref().unwrap_or("testing");
|
||||
let ip_pool = if mode == "bridge" {
|
||||
let lan_subnet = config.bridge_lan_subnet.as_deref().unwrap_or(&config.subnet);
|
||||
let range_start = config.bridge_ip_range_start.unwrap_or(200);
|
||||
let range_end = config.bridge_ip_range_end.unwrap_or(250);
|
||||
IpPool::new_with_range(lan_subnet, range_start, range_end)?
|
||||
} else {
|
||||
IpPool::new(&config.subnet)?
|
||||
};
|
||||
|
||||
if config.enable_nat.unwrap_or(false) {
|
||||
if let Err(e) = crate::network::enable_ip_forwarding() {
|
||||
@@ -159,7 +226,6 @@ impl VpnServer {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -174,6 +240,12 @@ impl VpnServer {
|
||||
packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||
shutdown_rx: mpsc::Receiver<()>,
|
||||
},
|
||||
Bridge {
|
||||
packet_tx: mpsc::Sender<Vec<u8>>,
|
||||
packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||
tap_device: tun::AsyncDevice,
|
||||
shutdown_rx: mpsc::Receiver<()>,
|
||||
},
|
||||
Testing,
|
||||
}
|
||||
|
||||
@@ -197,6 +269,33 @@ impl VpnServer {
|
||||
let (tx, rx) = mpsc::channel::<()>(1);
|
||||
(ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx)
|
||||
}
|
||||
"bridge" => {
|
||||
info!("Starting L2 bridge forwarding (requires CAP_NET_ADMIN)");
|
||||
let phys_iface = match &config.bridge_physical_interface {
|
||||
Some(i) => i.clone(),
|
||||
None => crate::bridge::detect_default_interface().await?,
|
||||
};
|
||||
let (host_ip, host_prefix) = crate::bridge::get_interface_ip(&phys_iface).await?;
|
||||
|
||||
let bridge_name = "svpn_br0";
|
||||
let tap_name = "svpn_tap0";
|
||||
|
||||
// Create TAP + bridge infrastructure
|
||||
let tap_device = crate::bridge::create_tap(tap_name, link_mtu)?;
|
||||
crate::bridge::create_bridge(bridge_name).await?;
|
||||
crate::bridge::set_interface_up(bridge_name).await?;
|
||||
crate::bridge::bridge_add_interface(bridge_name, tap_name).await?;
|
||||
crate::bridge::set_interface_up(tap_name).await?;
|
||||
crate::bridge::bridge_add_interface(bridge_name, &phys_iface).await?;
|
||||
crate::bridge::migrate_host_ip_to_bridge(&phys_iface, bridge_name, host_ip, host_prefix).await?;
|
||||
crate::bridge::enable_proxy_arp(bridge_name).await?;
|
||||
|
||||
info!("Bridge {} created: TAP={}, physical={}, IP={}/{}", bridge_name, tap_name, phys_iface, host_ip, host_prefix);
|
||||
|
||||
let (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||
let (tx, rx) = mpsc::channel::<()>(1);
|
||||
(ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx)
|
||||
}
|
||||
_ => {
|
||||
info!("Forwarding disabled (testing/monitoring mode)");
|
||||
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||
@@ -241,10 +340,13 @@ impl VpnServer {
|
||||
}
|
||||
ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
|
||||
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx);
|
||||
let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false);
|
||||
let nat_engine = crate::userspace_nat::NatEngine::new(
|
||||
gateway_ip,
|
||||
link_mtu as usize,
|
||||
state.clone(),
|
||||
proxy_protocol,
|
||||
config.destination_policy.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await {
|
||||
@@ -252,65 +354,116 @@ impl VpnServer {
|
||||
}
|
||||
});
|
||||
}
|
||||
ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx } => {
|
||||
*state.forwarding_engine.lock().await = ForwardingEngine::Bridge(packet_tx);
|
||||
let bridge_engine = crate::bridge::BridgeEngine::new(state.clone());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bridge_engine.run(tap_device, packet_rx, shutdown_rx).await {
|
||||
error!("Bridge engine error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
ForwardingSetup::Testing => {}
|
||||
}
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
self.state = Some(state.clone());
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let transport_mode = config.transport_mode.as_deref().unwrap_or("both");
|
||||
let transport_mode = config.transport_mode.as_deref().unwrap_or("all");
|
||||
let listen_addr = config.listen_addr.clone();
|
||||
|
||||
match transport_mode {
|
||||
"quic" => {
|
||||
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone());
|
||||
let idle_timeout = config.quic_idle_timeout_secs.unwrap_or(30);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_quic_listener(state, quic_addr, idle_timeout, &mut shutdown_rx).await {
|
||||
error!("QUIC listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"both" => {
|
||||
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone());
|
||||
let idle_timeout = config.quic_idle_timeout_secs.unwrap_or(30);
|
||||
let state2 = state.clone();
|
||||
let (shutdown_tx2, mut shutdown_rx2) = mpsc::channel::<()>(1);
|
||||
// Store second shutdown sender so both listeners stop
|
||||
let shutdown_tx_orig = self.shutdown_tx.take().unwrap();
|
||||
let (combined_tx, mut combined_rx) = mpsc::channel::<()>(1);
|
||||
self.shutdown_tx = Some(combined_tx);
|
||||
// Determine if WG should be included
|
||||
let include_wg = config.wg_private_key.is_some()
|
||||
&& matches!(transport_mode, "all" | "wireguard");
|
||||
|
||||
// Forward combined shutdown to both listeners
|
||||
tokio::spawn(async move {
|
||||
combined_rx.recv().await;
|
||||
let _ = shutdown_tx_orig.send(()).await;
|
||||
let _ = shutdown_tx2.send(()).await;
|
||||
});
|
||||
// Collect shutdown senders for all listeners
|
||||
let mut listener_shutdown_txs: Vec<mpsc::Sender<()>> = Vec::new();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_ws_listener(state, listen_addr, &mut shutdown_rx).await {
|
||||
error!("WebSocket listener error: {}", e);
|
||||
}
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_quic_listener(state2, quic_addr, idle_timeout, &mut shutdown_rx2).await {
|
||||
error!("QUIC listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// "websocket" (default)
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_ws_listener(state, listen_addr, &mut shutdown_rx).await {
|
||||
error!("Server listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Spawn transport listeners based on mode
|
||||
let spawn_ws = matches!(transport_mode, "all" | "both" | "websocket");
|
||||
let spawn_quic = matches!(transport_mode, "all" | "both" | "quic");
|
||||
|
||||
if spawn_ws {
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
listener_shutdown_txs.push(tx);
|
||||
let ws_state = state.clone();
|
||||
let ws_addr = listen_addr.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_ws_listener(ws_state, ws_addr, &mut rx).await {
|
||||
error!("WebSocket listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if spawn_quic {
|
||||
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone());
|
||||
let idle_timeout = config.quic_idle_timeout_secs.unwrap_or(30);
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
listener_shutdown_txs.push(tx);
|
||||
let quic_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_quic_listener(quic_state, quic_addr, idle_timeout, &mut rx).await {
|
||||
error!("QUIC listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if include_wg {
|
||||
let wg_config = crate::wireguard::WgListenerConfig {
|
||||
private_key: config.wg_private_key.clone().unwrap(),
|
||||
listen_port: config.wg_listen_port.unwrap_or(51820),
|
||||
peers: config.wg_peers.clone().unwrap_or_default(),
|
||||
};
|
||||
let (tx, rx) = mpsc::channel::<()>(1);
|
||||
listener_shutdown_txs.push(tx);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<crate::wireguard::WgCommand>(32);
|
||||
self.wg_command_tx = Some(cmd_tx);
|
||||
let wg_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::wireguard::run_wg_listener(wg_state, wg_config, rx, cmd_rx).await {
|
||||
error!("WireGuard listener error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Replace self.shutdown_tx with a combined sender that fans out to all listeners
|
||||
if listener_shutdown_txs.len() > 1 {
|
||||
let (combined_tx, mut combined_rx) = mpsc::channel::<()>(1);
|
||||
// Take the original shutdown_tx (from line above)
|
||||
let _ = self.shutdown_tx.take();
|
||||
self.shutdown_tx = Some(combined_tx);
|
||||
tokio::spawn(async move {
|
||||
combined_rx.recv().await;
|
||||
for tx in listener_shutdown_txs {
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
});
|
||||
} else if let Some(single_tx) = listener_shutdown_txs.into_iter().next() {
|
||||
self.shutdown_tx = Some(single_tx);
|
||||
}
|
||||
|
||||
info!("VPN server started (transport: {})", transport_mode);
|
||||
|
||||
// Register pre-loaded clients (from config.clients) as WG peers.
|
||||
// The WG listener only starts with config.wg_peers; clients loaded into the
|
||||
// registry need to be dynamically added so WG handshakes work.
|
||||
if self.wg_command_tx.is_some() {
|
||||
let registry = state.client_registry.read().await;
|
||||
for entry in registry.list() {
|
||||
if let (Some(ref wg_key), Some(ref ip_str)) = (&entry.wg_public_key, &entry.assigned_ip) {
|
||||
let peer_config = crate::wireguard::WgPeerConfig {
|
||||
public_key: wg_key.clone(),
|
||||
preshared_key: None,
|
||||
allowed_ips: vec![format!("{}/32", ip_str)],
|
||||
endpoint: None,
|
||||
persistent_keepalive: Some(25),
|
||||
};
|
||||
if let Err(e) = self.add_wg_peer(peer_config).await {
|
||||
warn!("Failed to register pre-loaded WG peer for {}: {}", entry.client_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -346,6 +499,7 @@ impl VpnServer {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
self.wg_command_tx = None;
|
||||
self.state = None;
|
||||
info!("VPN server stopped");
|
||||
Ok(())
|
||||
@@ -366,7 +520,21 @@ impl VpnServer {
|
||||
if let Some(ref state) = self.state {
|
||||
let mut stats = state.stats.read().await.clone();
|
||||
stats.uptime_seconds = state.started_at.elapsed().as_secs();
|
||||
stats.active_clients = state.clients.read().await.len() as u64;
|
||||
let clients = state.clients.read().await;
|
||||
stats.active_clients = clients.len() as u64;
|
||||
// Compute per-transport active counts
|
||||
stats.active_clients_websocket = 0;
|
||||
stats.active_clients_quic = 0;
|
||||
stats.active_clients_wireguard = 0;
|
||||
for info in clients.values() {
|
||||
match info.transport_type.as_str() {
|
||||
"websocket" => stats.active_clients_websocket += 1,
|
||||
"quic" => stats.active_clients_quic += 1,
|
||||
"wireguard" => stats.active_clients_wireguard += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
drop(clients);
|
||||
stats
|
||||
} else {
|
||||
ServerStatistics::default()
|
||||
@@ -434,6 +602,54 @@ impl VpnServer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── WireGuard Peer Management ────────────────────────────────────────
|
||||
|
||||
/// Add a WireGuard peer dynamically (delegates to the WG event loop).
|
||||
pub async fn add_wg_peer(&self, config: crate::wireguard::WgPeerConfig) -> Result<()> {
|
||||
let tx = self.wg_command_tx.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("WireGuard listener not running"))?;
|
||||
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(crate::wireguard::WgCommand::AddPeer(config, resp_tx))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("WG event loop closed"))?;
|
||||
resp_rx.await.map_err(|_| anyhow::anyhow!("No response from WG loop"))?
|
||||
}
|
||||
|
||||
/// Remove a WireGuard peer dynamically (delegates to the WG event loop).
|
||||
pub async fn remove_wg_peer(&self, public_key: &str) -> Result<()> {
|
||||
let tx = self.wg_command_tx.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("WireGuard listener not running"))?;
|
||||
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(crate::wireguard::WgCommand::RemovePeer(public_key.to_string(), resp_tx))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("WG event loop closed"))?;
|
||||
resp_rx.await.map_err(|_| anyhow::anyhow!("No response from WG loop"))?
|
||||
}
|
||||
|
||||
/// List WireGuard peers from the unified client list.
|
||||
pub async fn list_wg_peers(&self) -> Vec<crate::wireguard::WgPeerInfo> {
|
||||
if let Some(ref state) = self.state {
|
||||
state.clients.read().await.values()
|
||||
.filter(|c| c.transport_type == "wireguard")
|
||||
.map(|c| crate::wireguard::WgPeerInfo {
|
||||
public_key: c.authenticated_key.clone(),
|
||||
allowed_ips: vec![format!("{}/32", c.assigned_ip)],
|
||||
endpoint: c.remote_addr.clone(),
|
||||
persistent_keepalive: None,
|
||||
stats: crate::wireguard::WgPeerStats {
|
||||
bytes_sent: c.bytes_sent,
|
||||
bytes_received: c.bytes_received,
|
||||
packets_sent: 0,
|
||||
packets_received: 0,
|
||||
last_handshake_time: None,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client Registry (Hub) Methods ───────────────────────────────────
|
||||
|
||||
/// Create a new client entry. Generates keypairs and assigns an IP.
|
||||
@@ -466,9 +682,16 @@ impl VpnServer {
|
||||
).ok(),
|
||||
priority: partial.get("priority").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
enabled: partial.get("enabled").and_then(|v| v.as_bool()).or(Some(true)),
|
||||
tags: partial.get("tags").and_then(|v| {
|
||||
server_defined_client_tags: partial.get("serverDefinedClientTags").and_then(|v| {
|
||||
v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
}).or_else(|| {
|
||||
// Legacy: accept "tags" as serverDefinedClientTags
|
||||
partial.get("tags").and_then(|v| {
|
||||
v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
})
|
||||
}),
|
||||
client_defined_client_tags: None, // Only set by connecting client
|
||||
tags: None, // Legacy field — not used for new entries
|
||||
description: partial.get("description").and_then(|v| v.as_str()).map(String::from),
|
||||
expires_at: partial.get("expiresAt").and_then(|v| v.as_str()).map(String::from),
|
||||
assigned_ip: Some(assigned_ip.to_string()),
|
||||
@@ -477,10 +700,27 @@ impl VpnServer {
|
||||
// Add to registry
|
||||
state.client_registry.write().await.add(entry.clone())?;
|
||||
|
||||
// Register WG peer with the running WG listener (if active)
|
||||
if self.wg_command_tx.is_some() {
|
||||
let wg_peer_config = crate::wireguard::WgPeerConfig {
|
||||
public_key: wg_pub.clone(),
|
||||
preshared_key: None,
|
||||
allowed_ips: vec![format!("{}/32", assigned_ip)],
|
||||
endpoint: None,
|
||||
persistent_keepalive: Some(25),
|
||||
};
|
||||
if let Err(e) = self.add_wg_peer(wg_peer_config).await {
|
||||
warn!("Failed to register WG peer for client {}: {}", client_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Build SmartVPN client config
|
||||
let smartvpn_server_url = format!("wss://{}",
|
||||
state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr)
|
||||
.replace("0.0.0.0", "localhost"));
|
||||
let smartvpn_config = serde_json::json!({
|
||||
"serverUrl": format!("wss://{}",
|
||||
state.config.listen_addr.replace("0.0.0.0", "localhost")),
|
||||
"serverUrl": smartvpn_server_url,
|
||||
"serverPublicKey": state.config.public_key,
|
||||
"clientPrivateKey": noise_priv,
|
||||
"clientPublicKey": noise_pub,
|
||||
@@ -490,15 +730,25 @@ impl VpnServer {
|
||||
});
|
||||
|
||||
// Build WireGuard config string
|
||||
let wg_server_pubkey = match &state.config.wg_private_key {
|
||||
Some(wg_priv_key) => crate::wireguard::wg_public_key_from_private(wg_priv_key)?,
|
||||
None => state.config.public_key.clone(),
|
||||
};
|
||||
let wg_endpoint = state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr);
|
||||
let wg_allowed_ips = state.config.client_allowed_ips.as_ref()
|
||||
.map(|ips| ips.join(", "))
|
||||
.unwrap_or_else(|| "0.0.0.0/0".to_string());
|
||||
let wg_config = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = {}\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
wg_priv,
|
||||
assigned_ip,
|
||||
state.config.dns.as_ref()
|
||||
.map(|d| format!("DNS = {}", d.join(", ")))
|
||||
.unwrap_or_default(),
|
||||
state.config.public_key,
|
||||
state.config.listen_addr,
|
||||
wg_server_pubkey,
|
||||
wg_allowed_ips,
|
||||
wg_endpoint,
|
||||
);
|
||||
|
||||
let entry_json = serde_json::to_value(&entry)?;
|
||||
@@ -519,6 +769,14 @@ impl VpnServer {
|
||||
let state = self.state.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Server not running"))?;
|
||||
let entry = state.client_registry.write().await.remove(client_id)?;
|
||||
// Remove WG peer from running listener
|
||||
if self.wg_command_tx.is_some() {
|
||||
if let Some(ref wg_key) = entry.wg_public_key {
|
||||
if let Err(e) = self.remove_wg_peer(wg_key).await {
|
||||
debug!("Failed to remove WG peer for client {}: {}", client_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Release the IP if assigned
|
||||
if let Some(ref ip_str) = entry.assigned_ip {
|
||||
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
|
||||
@@ -563,8 +821,11 @@ impl VpnServer {
|
||||
if let Some(enabled) = update.get("enabled").and_then(|v| v.as_bool()) {
|
||||
entry.enabled = Some(enabled);
|
||||
}
|
||||
if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) {
|
||||
entry.tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
if let Some(tags) = update.get("serverDefinedClientTags").and_then(|v| v.as_array()) {
|
||||
entry.server_defined_client_tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
} else if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) {
|
||||
// Legacy: accept "tags" as serverDefinedClientTags
|
||||
entry.server_defined_client_tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
}
|
||||
if let Some(desc) = update.get("description").and_then(|v| v.as_str()) {
|
||||
entry.description = Some(desc.to_string());
|
||||
@@ -602,6 +863,14 @@ impl VpnServer {
|
||||
let state = self.state.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Server not running"))?;
|
||||
|
||||
// Capture old WG key before rotation (needed to remove from WG listener)
|
||||
let old_wg_pub = {
|
||||
let registry = state.client_registry.read().await;
|
||||
let entry = registry.get_by_id(client_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||
entry.wg_public_key.clone()
|
||||
};
|
||||
|
||||
let (noise_pub, noise_priv) = crypto::generate_keypair()?;
|
||||
let (wg_pub, wg_priv) = crate::wireguard::generate_wg_keypair();
|
||||
|
||||
@@ -620,9 +889,31 @@ impl VpnServer {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0.0");
|
||||
|
||||
// Update WG listener: remove old peer, add new peer
|
||||
if self.wg_command_tx.is_some() {
|
||||
if let Some(ref old_key) = old_wg_pub {
|
||||
if let Err(e) = self.remove_wg_peer(old_key).await {
|
||||
debug!("Failed to remove old WG peer during rotation: {}", e);
|
||||
}
|
||||
}
|
||||
let wg_peer_config = crate::wireguard::WgPeerConfig {
|
||||
public_key: wg_pub.clone(),
|
||||
preshared_key: None,
|
||||
allowed_ips: vec![format!("{}/32", assigned_ip)],
|
||||
endpoint: None,
|
||||
persistent_keepalive: Some(25),
|
||||
};
|
||||
if let Err(e) = self.add_wg_peer(wg_peer_config).await {
|
||||
warn!("Failed to register new WG peer during rotation: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let smartvpn_server_url = format!("wss://{}",
|
||||
state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr)
|
||||
.replace("0.0.0.0", "localhost"));
|
||||
let smartvpn_config = serde_json::json!({
|
||||
"serverUrl": format!("wss://{}",
|
||||
state.config.listen_addr.replace("0.0.0.0", "localhost")),
|
||||
"serverUrl": smartvpn_server_url,
|
||||
"serverPublicKey": state.config.public_key,
|
||||
"clientPrivateKey": noise_priv,
|
||||
"clientPublicKey": noise_pub,
|
||||
@@ -631,14 +922,24 @@ impl VpnServer {
|
||||
"keepaliveIntervalSecs": state.config.keepalive_interval_secs,
|
||||
});
|
||||
|
||||
let wg_server_pubkey = match &state.config.wg_private_key {
|
||||
Some(wg_priv_key) => crate::wireguard::wg_public_key_from_private(wg_priv_key)?,
|
||||
None => state.config.public_key.clone(),
|
||||
};
|
||||
let wg_endpoint = state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr);
|
||||
let wg_allowed_ips = state.config.client_allowed_ips.as_ref()
|
||||
.map(|ips| ips.join(", "))
|
||||
.unwrap_or_else(|| "0.0.0.0/0".to_string());
|
||||
let wg_config = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = {}\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
wg_priv, assigned_ip,
|
||||
state.config.dns.as_ref()
|
||||
.map(|d| format!("DNS = {}", d.join(", ")))
|
||||
.unwrap_or_default(),
|
||||
state.config.public_key,
|
||||
state.config.listen_addr,
|
||||
wg_server_pubkey,
|
||||
wg_allowed_ips,
|
||||
wg_endpoint,
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -662,10 +963,13 @@ impl VpnServer {
|
||||
|
||||
match format {
|
||||
"smartvpn" => {
|
||||
let smartvpn_server_url = format!("wss://{}",
|
||||
state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr)
|
||||
.replace("0.0.0.0", "localhost"));
|
||||
Ok(serde_json::json!({
|
||||
"config": {
|
||||
"serverUrl": format!("wss://{}",
|
||||
state.config.listen_addr.replace("0.0.0.0", "localhost")),
|
||||
"serverUrl": smartvpn_server_url,
|
||||
"serverPublicKey": state.config.public_key,
|
||||
"clientPublicKey": entry.public_key,
|
||||
"dns": state.config.dns,
|
||||
@@ -675,15 +979,25 @@ impl VpnServer {
|
||||
}))
|
||||
}
|
||||
"wireguard" => {
|
||||
let wg_server_pubkey = match &state.config.wg_private_key {
|
||||
Some(wg_priv_key) => crate::wireguard::wg_public_key_from_private(wg_priv_key)?,
|
||||
None => state.config.public_key.clone(),
|
||||
};
|
||||
let assigned_ip = entry.assigned_ip.as_deref().unwrap_or("0.0.0.0");
|
||||
let wg_endpoint = state.config.server_endpoint.as_deref()
|
||||
.unwrap_or(&state.config.listen_addr);
|
||||
let wg_allowed_ips = state.config.client_allowed_ips.as_ref()
|
||||
.map(|ips| ips.join(", "))
|
||||
.unwrap_or_else(|| "0.0.0.0/0".to_string());
|
||||
let config = format!(
|
||||
"[Interface]\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
"[Interface]\nAddress = {}/24\n{}\n[Peer]\nPublicKey = {}\nAllowedIPs = {}\nEndpoint = {}\nPersistentKeepalive = 25\n",
|
||||
assigned_ip,
|
||||
state.config.dns.as_ref()
|
||||
.map(|d| format!("DNS = {}", d.join(", ")))
|
||||
.unwrap_or_default(),
|
||||
state.config.public_key,
|
||||
state.config.listen_addr,
|
||||
wg_server_pubkey,
|
||||
wg_allowed_ips,
|
||||
wg_endpoint,
|
||||
);
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
@@ -751,6 +1065,7 @@ async fn run_ws_listener(
|
||||
Box::new(sink),
|
||||
Box::new(stream),
|
||||
remote_addr,
|
||||
"websocket",
|
||||
).await {
|
||||
warn!("Client connection error: {}", e);
|
||||
}
|
||||
@@ -827,6 +1142,7 @@ async fn run_quic_listener(
|
||||
Box::new(sink),
|
||||
Box::new(stream),
|
||||
Some(remote),
|
||||
"quic",
|
||||
).await {
|
||||
warn!("QUIC client error: {}", e);
|
||||
}
|
||||
@@ -916,6 +1232,7 @@ async fn handle_client_connection(
|
||||
mut sink: Box<dyn TransportSink>,
|
||||
mut stream: Box<dyn TransportStream>,
|
||||
remote_addr: Option<std::net::SocketAddr>,
|
||||
transport_type: &str,
|
||||
) -> Result<()> {
|
||||
let server_private_key = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
@@ -1054,6 +1371,7 @@ async fn handle_client_connection(
|
||||
authenticated_key: client_pub_key_b64.clone(),
|
||||
registered_client_id: registered_client_id.clone(),
|
||||
remote_addr: remote_addr.map(|a| a.to_string()),
|
||||
transport_type: transport_type.to_string(),
|
||||
};
|
||||
state.clients.write().await.insert(client_id.clone(), client_info);
|
||||
|
||||
@@ -1069,6 +1387,11 @@ async fn handle_client_connection(
|
||||
{
|
||||
let mut stats = state.stats.write().await;
|
||||
stats.total_connections += 1;
|
||||
match transport_type {
|
||||
"websocket" => stats.total_connections_websocket += 1,
|
||||
"quic" => stats.total_connections_quic += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Send assigned IP info (encrypted), include effective MTU
|
||||
@@ -1169,6 +1492,9 @@ async fn handle_client_connection(
|
||||
ForwardingEngine::Socket(sender) => {
|
||||
let _ = sender.try_send(buf[..len].to_vec());
|
||||
}
|
||||
ForwardingEngine::Bridge(sender) => {
|
||||
let _ = sender.try_send(buf[..len].to_vec());
|
||||
}
|
||||
ForwardingEngine::Testing => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,14 @@ use tokio::net::{TcpStream, UdpSocket};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::server::ServerState;
|
||||
use crate::acl;
|
||||
use crate::server::{DestinationPolicyConfig, ServerState};
|
||||
use crate::tunnel;
|
||||
|
||||
/// Maximum size of per-session pending send buffer (512KB = 8x socket buffer).
|
||||
/// Sessions exceeding this are aborted — the client cannot keep up.
|
||||
const TCP_PENDING_SEND_MAX: usize = 512 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Virtual IP device for smoltcp
|
||||
// ============================================================================
|
||||
@@ -100,7 +105,7 @@ impl Device for VirtualIpDevice {
|
||||
let mut caps = DeviceCapabilities::default();
|
||||
caps.medium = Medium::Ip;
|
||||
caps.max_transmission_unit = self.mtu;
|
||||
caps.max_burst_size = Some(1);
|
||||
caps.max_burst_size = None;
|
||||
caps
|
||||
}
|
||||
}
|
||||
@@ -120,9 +125,20 @@ struct SessionKey {
|
||||
|
||||
struct TcpSession {
|
||||
smoltcp_handle: SocketHandle,
|
||||
bridge_data_tx: mpsc::Sender<Vec<u8>>,
|
||||
/// Channel to send data to the bridge task. None until bridge starts.
|
||||
bridge_data_tx: Option<mpsc::Sender<Vec<u8>>>,
|
||||
#[allow(dead_code)]
|
||||
client_ip: Ipv4Addr,
|
||||
/// Bridge task has been spawned (deferred until handshake completes)
|
||||
bridge_started: bool,
|
||||
/// Address to connect the bridge task to (may differ from dst if policy rewrote it)
|
||||
connect_addr: SocketAddr,
|
||||
/// Buffered data from bridge waiting to be written to smoltcp socket
|
||||
pending_send: Vec<u8>,
|
||||
/// Session is closing (FIN in progress), don't accept new SYNs
|
||||
closing: bool,
|
||||
/// Last time data flowed through this session (for idle timeout)
|
||||
last_activity: tokio::time::Instant,
|
||||
}
|
||||
|
||||
struct UdpSession {
|
||||
@@ -191,10 +207,25 @@ pub struct NatEngine {
|
||||
bridge_rx: mpsc::Receiver<BridgeMessage>,
|
||||
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||
start_time: std::time::Instant,
|
||||
/// When true, outbound TCP connections prepend PROXY protocol v2 headers
|
||||
/// with the VPN client's tunnel IP as source address.
|
||||
proxy_protocol: bool,
|
||||
/// Destination routing policy: forceTarget, block, or allow.
|
||||
destination_policy: Option<DestinationPolicyConfig>,
|
||||
}
|
||||
|
||||
/// Result of destination policy evaluation.
|
||||
enum DestinationAction {
|
||||
/// Connect to the original destination.
|
||||
PassThrough(SocketAddr),
|
||||
/// Redirect to a target IP, preserving original port.
|
||||
ForceTarget(SocketAddr),
|
||||
/// Drop the packet silently.
|
||||
Drop,
|
||||
}
|
||||
|
||||
impl NatEngine {
|
||||
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>) -> Self {
|
||||
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>, proxy_protocol: bool, destination_policy: Option<DestinationPolicyConfig>) -> Self {
|
||||
let mut device = VirtualIpDevice::new(mtu);
|
||||
let config = Config::new(HardwareAddress::Ip);
|
||||
let now = smoltcp::time::Instant::from_millis(0);
|
||||
@@ -226,6 +257,8 @@ impl NatEngine {
|
||||
bridge_rx,
|
||||
bridge_tx,
|
||||
start_time: std::time::Instant::now(),
|
||||
proxy_protocol,
|
||||
destination_policy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +266,51 @@ impl NatEngine {
|
||||
smoltcp::time::Instant::from_millis(self.start_time.elapsed().as_millis() as i64)
|
||||
}
|
||||
|
||||
/// Evaluate destination policy for a packet's destination IP.
|
||||
/// Checks per-client policy first (via src_ip → client registry lookup),
|
||||
/// falls back to server-wide policy.
|
||||
fn evaluate_destination(&self, src_ip: Ipv4Addr, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction {
|
||||
// Try per-client destination policy (lookup by tunnel IP)
|
||||
let client_policy = if let Ok(registry) = self.state.client_registry.try_read() {
|
||||
registry.get_by_assigned_ip(&src_ip.to_string())
|
||||
.and_then(|e| e.security.as_ref())
|
||||
.and_then(|s| s.destination_policy.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let policy = match client_policy.as_ref().or(self.destination_policy.as_ref()) {
|
||||
Some(p) => p,
|
||||
None => return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)),
|
||||
};
|
||||
|
||||
// 1. Block list wins (deny overrides allow)
|
||||
if let Some(ref block_list) = policy.block_list {
|
||||
if !block_list.is_empty() && acl::ip_matches_any(dst_ip, block_list) {
|
||||
return DestinationAction::Drop;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Allow list — pass through directly
|
||||
if let Some(ref allow_list) = policy.allow_list {
|
||||
if !allow_list.is_empty() && acl::ip_matches_any(dst_ip, allow_list) {
|
||||
return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default action
|
||||
match policy.default.as_str() {
|
||||
"forceTarget" => {
|
||||
let target_ip = policy.target.as_deref()
|
||||
.and_then(|t| t.parse::<Ipv4Addr>().ok())
|
||||
.unwrap_or(Ipv4Addr::LOCALHOST);
|
||||
DestinationAction::ForceTarget(SocketAddr::new(target_ip.into(), dst_port))
|
||||
}
|
||||
"block" => DestinationAction::Drop,
|
||||
_ => DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -256,8 +334,17 @@ impl NatEngine {
|
||||
|
||||
// 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);
|
||||
// Skip if session exists (including closing sessions — let FIN complete)
|
||||
let session_exists = self.tcp_sessions.contains_key(&key);
|
||||
if is_syn && !session_exists {
|
||||
match self.evaluate_destination(src_ip, dst_ip, dst_port) {
|
||||
DestinationAction::Drop => {
|
||||
debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||
return;
|
||||
}
|
||||
DestinationAction::PassThrough(addr) => self.create_tcp_session(&key, addr),
|
||||
DestinationAction::ForceTarget(addr) => self.create_tcp_session(&key, addr),
|
||||
}
|
||||
}
|
||||
}
|
||||
17 => {
|
||||
@@ -274,7 +361,14 @@ impl NatEngine {
|
||||
};
|
||||
|
||||
if !self.udp_sessions.contains_key(&key) {
|
||||
self.create_udp_session(&key);
|
||||
match self.evaluate_destination(src_ip, dst_ip, dst_port) {
|
||||
DestinationAction::Drop => {
|
||||
debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||
return;
|
||||
}
|
||||
DestinationAction::PassThrough(addr) => self.create_udp_session(&key, addr),
|
||||
DestinationAction::ForceTarget(addr) => self.create_udp_session(&key, addr),
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_activity for existing sessions
|
||||
@@ -291,7 +385,7 @@ impl NatEngine {
|
||||
self.device.inject_packet(packet);
|
||||
}
|
||||
|
||||
fn create_tcp_session(&mut self, key: &SessionKey) {
|
||||
fn create_tcp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) {
|
||||
// Create smoltcp TCP socket
|
||||
let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||
let tcp_tx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||
@@ -309,22 +403,22 @@ impl NatEngine {
|
||||
|
||||
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,
|
||||
bridge_data_tx: None,
|
||||
client_ip: key.src_ip,
|
||||
bridge_started: false,
|
||||
connect_addr,
|
||||
pending_send: Vec::new(),
|
||||
closing: false,
|
||||
last_activity: tokio::time::Instant::now(),
|
||||
};
|
||||
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;
|
||||
});
|
||||
// NOTE: Bridge task is NOT spawned here — it will be spawned in process()
|
||||
// once the smoltcp handshake completes (socket.is_active() == true).
|
||||
// This prevents data from the real server arriving before the VPN client
|
||||
// handshake is done, which would cause silent data loss.
|
||||
|
||||
debug!(
|
||||
"NAT: new TCP session {}:{} -> {}:{}",
|
||||
@@ -332,7 +426,7 @@ impl NatEngine {
|
||||
);
|
||||
}
|
||||
|
||||
fn create_udp_session(&mut self, key: &SessionKey) {
|
||||
fn create_udp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) {
|
||||
// Create smoltcp UDP socket
|
||||
let udp_rx_buf = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
@@ -368,7 +462,7 @@ impl NatEngine {
|
||||
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;
|
||||
udp_bridge_task(key_clone, data_rx, bridge_tx, connect_addr).await;
|
||||
});
|
||||
|
||||
debug!(
|
||||
@@ -384,15 +478,69 @@ impl NatEngine {
|
||||
self.iface
|
||||
.poll(now, &mut self.device, &mut self.sockets);
|
||||
|
||||
// Start bridge tasks for sessions whose handshake just completed
|
||||
let bridge_tx_clone = self.bridge_tx.clone();
|
||||
let proxy_protocol = self.proxy_protocol;
|
||||
for (key, session) in self.tcp_sessions.iter_mut() {
|
||||
if !session.bridge_started && !session.closing {
|
||||
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
if socket.is_active() {
|
||||
session.bridge_started = true;
|
||||
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||
session.bridge_data_tx = Some(data_tx);
|
||||
let btx = bridge_tx_clone.clone();
|
||||
let k = key.clone();
|
||||
let addr = session.connect_addr;
|
||||
let pp = proxy_protocol;
|
||||
tokio::spawn(async move {
|
||||
tcp_bridge_task(k, data_rx, btx, pp, addr).await;
|
||||
});
|
||||
debug!("NAT: TCP handshake complete, starting bridge for {}:{} -> {}:{}",
|
||||
key.src_ip, key.src_port, key.dst_ip, key.dst_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending send buffers to smoltcp sockets
|
||||
for (_key, session) in self.tcp_sessions.iter_mut() {
|
||||
if !session.pending_send.is_empty() {
|
||||
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
if socket.can_send() {
|
||||
match socket.send_slice(&session.pending_send) {
|
||||
Ok(written) if written > 0 => {
|
||||
session.pending_send.drain(..written);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge: read data from smoltcp TCP sockets → send to bridge tasks
|
||||
let mut closed_tcp: Vec<SessionKey> = Vec::new();
|
||||
let mut active_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(), ())
|
||||
});
|
||||
if session.bridge_started && socket.can_recv() {
|
||||
if let Some(ref sender) = session.bridge_data_tx {
|
||||
// Reserve channel slot BEFORE consuming from smoltcp.
|
||||
// If the channel is full, we don't consume — smoltcp's RX buffer
|
||||
// fills up, it stops advertising TCP window space, and the VPN
|
||||
// client's TCP stack backs off. Proper end-to-end backpressure.
|
||||
match sender.try_reserve() {
|
||||
Ok(permit) => {
|
||||
let _ = socket.recv(|data| {
|
||||
permit.send(data.to_vec());
|
||||
(data.len(), ())
|
||||
});
|
||||
active_tcp.push(key.clone());
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("NAT: bridge channel full for {}:{} -> {}:{}, applying backpressure",
|
||||
key.src_ip, key.src_port, key.dst_ip, key.dst_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Detect closed connections
|
||||
if !socket.is_open() && !socket.is_listening() {
|
||||
@@ -400,6 +548,14 @@ impl NatEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_activity for sessions that had data flow
|
||||
let now = tokio::time::Instant::now();
|
||||
for key in active_tcp {
|
||||
if let Some(session) = self.tcp_sessions.get_mut(&key) {
|
||||
session.last_activity = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up closed TCP sessions
|
||||
for key in closed_tcp {
|
||||
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||
@@ -412,7 +568,9 @@ impl NatEngine {
|
||||
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());
|
||||
if session.bridge_data_tx.try_send(data.to_vec()).is_err() {
|
||||
debug!("NAT: bridge channel full, UDP data dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +579,9 @@ impl NatEngine {
|
||||
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);
|
||||
if sender.try_send(packet).is_err() {
|
||||
debug!("NAT: tun_routes channel full for {}, packet dropped", dst_ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,22 +590,43 @@ impl NatEngine {
|
||||
fn handle_bridge_message(&mut self, msg: BridgeMessage) {
|
||||
match msg {
|
||||
BridgeMessage::TcpData { key, data } => {
|
||||
if let Some(session) = self.tcp_sessions.get(&key) {
|
||||
if let Some(session) = self.tcp_sessions.get_mut(&key) {
|
||||
session.last_activity = tokio::time::Instant::now();
|
||||
// Append to pending buffer, then flush as much as possible
|
||||
session.pending_send.extend_from_slice(&data);
|
||||
let socket =
|
||||
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
if socket.can_send() {
|
||||
let _ = socket.send_slice(&data);
|
||||
if socket.can_send() && !session.pending_send.is_empty() {
|
||||
match socket.send_slice(&session.pending_send) {
|
||||
Ok(written) if written > 0 => {
|
||||
session.pending_send.drain(..written);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Cap check — abort session if client can't keep up
|
||||
if session.pending_send.len() > TCP_PENDING_SEND_MAX {
|
||||
warn!(
|
||||
"NAT: TCP session {}:{} -> {}:{} pending buffer exceeded {}KB, aborting",
|
||||
key.src_ip, key.src_port, key.dst_ip, key.dst_port,
|
||||
TCP_PENDING_SEND_MAX / 1024
|
||||
);
|
||||
let socket =
|
||||
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
socket.abort();
|
||||
session.pending_send.clear();
|
||||
session.closing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
BridgeMessage::TcpClosed { key } => {
|
||||
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||
if let Some(session) = self.tcp_sessions.get_mut(&key) {
|
||||
let socket =
|
||||
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
socket.close();
|
||||
session.closing = true;
|
||||
// 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 } => {
|
||||
@@ -485,6 +666,29 @@ impl NatEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_idle_tcp_sessions(&mut self) {
|
||||
let timeout = Duration::from_secs(300); // 5 minutes
|
||||
let now = tokio::time::Instant::now();
|
||||
let expired: Vec<SessionKey> = self
|
||||
.tcp_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.tcp_sessions.remove(&key) {
|
||||
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
socket.abort();
|
||||
self.sockets.remove(session.smoltcp_handle);
|
||||
warn!(
|
||||
"NAT: TCP 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,
|
||||
@@ -492,9 +696,13 @@ impl NatEngine {
|
||||
mut shutdown_rx: mpsc::Receiver<()>,
|
||||
) -> Result<()> {
|
||||
info!("Userspace NAT engine started");
|
||||
let mut timer = tokio::time::interval(Duration::from_millis(50));
|
||||
let default_poll_delay = Duration::from_millis(50);
|
||||
let mut cleanup_timer = tokio::time::interval(Duration::from_secs(10));
|
||||
|
||||
// Dynamic poll timer — reset after each event using smoltcp's poll_delay()
|
||||
let poll_sleep = tokio::time::sleep(default_poll_delay);
|
||||
tokio::pin!(poll_sleep);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(packet) = packet_rx.recv() => {
|
||||
@@ -505,18 +713,26 @@ impl NatEngine {
|
||||
self.handle_bridge_message(msg);
|
||||
self.process().await;
|
||||
}
|
||||
_ = timer.tick() => {
|
||||
() = &mut poll_sleep => {
|
||||
// Periodic poll for smoltcp maintenance (TCP retransmit, etc.)
|
||||
self.process().await;
|
||||
}
|
||||
_ = cleanup_timer.tick() => {
|
||||
self.cleanup_idle_udp_sessions();
|
||||
self.cleanup_idle_tcp_sessions();
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("Userspace NAT engine shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset poll delay based on smoltcp's actual timer needs
|
||||
let now = self.smoltcp_now();
|
||||
let delay = self.iface.poll_delay(now, &self.sockets)
|
||||
.map(|d| Duration::from_millis(d.total_millis()))
|
||||
.unwrap_or(default_poll_delay);
|
||||
poll_sleep.as_mut().reset(tokio::time::Instant::now() + delay);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -531,20 +747,20 @@ async fn tcp_bridge_task(
|
||||
key: SessionKey,
|
||||
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||
proxy_protocol: bool,
|
||||
connect_addr: SocketAddr,
|
||||
) {
|
||||
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
|
||||
// Connect to resolved destination (may differ from key.dst_ip if policy rewrote it)
|
||||
let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(connect_addr)).await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("NAT TCP connect to {} failed: {}", addr, e);
|
||||
debug!("NAT TCP connect to {} failed: {}", connect_addr, e);
|
||||
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("NAT TCP connect to {} timed out", addr);
|
||||
debug!("NAT TCP connect to {} timed out", connect_addr);
|
||||
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||
return;
|
||||
}
|
||||
@@ -552,6 +768,18 @@ async fn tcp_bridge_task(
|
||||
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// Send PROXY protocol v2 header with VPN client's tunnel IP as source
|
||||
if proxy_protocol {
|
||||
let src = SocketAddr::new(key.src_ip.into(), key.src_port);
|
||||
let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||
let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst);
|
||||
if let Err(e) = writer.write_all(&pp_header).await {
|
||||
debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e);
|
||||
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Read from real socket → send to NAT engine
|
||||
let bridge_tx2 = bridge_tx.clone();
|
||||
let key2 = key.clone();
|
||||
@@ -594,6 +822,7 @@ async fn udp_bridge_task(
|
||||
key: SessionKey,
|
||||
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||
connect_addr: SocketAddr,
|
||||
) {
|
||||
let socket = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
@@ -602,7 +831,7 @@ async fn udp_bridge_task(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let dest = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||
let dest = connect_addr;
|
||||
|
||||
let socket = Arc::new(socket);
|
||||
let socket2 = socket.clone();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartvpn',
|
||||
version: '1.10.1',
|
||||
version: '1.18.0',
|
||||
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
IWgPeerInfo,
|
||||
IClientEntry,
|
||||
IClientConfigBundle,
|
||||
IDestinationPolicy,
|
||||
TVpnServerCommands,
|
||||
} from './smartvpn.interfaces.js';
|
||||
|
||||
@@ -21,6 +22,10 @@ import type {
|
||||
export class VpnServer extends plugins.events.EventEmitter {
|
||||
private bridge: VpnBridge<TVpnServerCommands>;
|
||||
private options: IVpnServerOptions;
|
||||
private nft?: plugins.smartnftables.SmartNftables;
|
||||
private nftHealthInterval?: ReturnType<typeof setInterval>;
|
||||
private nftSubnet?: string;
|
||||
private nftPolicy?: IDestinationPolicy;
|
||||
|
||||
constructor(options: IVpnServerOptions) {
|
||||
super();
|
||||
@@ -50,6 +55,11 @@ export class VpnServer extends plugins.events.EventEmitter {
|
||||
const cfg = config || this.options.config;
|
||||
if (cfg) {
|
||||
await this.bridge.sendCommand('start', { config: cfg });
|
||||
|
||||
// For TUN mode with a destination policy, set up nftables rules
|
||||
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
|
||||
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,10 +239,110 @@ export class VpnServer extends plugins.events.EventEmitter {
|
||||
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>);
|
||||
}
|
||||
|
||||
// ── TUN Destination Policy via nftables ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Set up nftables rules for TUN mode destination policy.
|
||||
* Also starts a 60-second health check interval to re-apply if rules are removed externally.
|
||||
*/
|
||||
private async setupTunDestinationPolicy(subnet: string, policy: IDestinationPolicy): Promise<void> {
|
||||
this.nftSubnet = subnet;
|
||||
this.nftPolicy = policy;
|
||||
this.nft = new plugins.smartnftables.SmartNftables({
|
||||
tableName: 'smartvpn_tun',
|
||||
dryRun: process.getuid?.() !== 0,
|
||||
});
|
||||
|
||||
await this.nft.initialize();
|
||||
await this.applyDestinationPolicyRules();
|
||||
|
||||
// Health check: re-apply rules if they disappear
|
||||
this.nftHealthInterval = setInterval(async () => {
|
||||
if (!this.nft) return;
|
||||
try {
|
||||
const exists = await this.nft.tableExists();
|
||||
if (!exists) {
|
||||
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
|
||||
this.nft = new plugins.smartnftables.SmartNftables({
|
||||
tableName: 'smartvpn_tun',
|
||||
});
|
||||
await this.nft.initialize();
|
||||
await this.applyDestinationPolicyRules();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[smartvpn] nftables health check failed: ${err}`);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply destination policy as nftables rules.
|
||||
* Order: blockList (drop) → allowList (accept) → default action.
|
||||
*/
|
||||
private async applyDestinationPolicyRules(): Promise<void> {
|
||||
if (!this.nft || !this.nftSubnet || !this.nftPolicy) return;
|
||||
|
||||
const subnet = this.nftSubnet;
|
||||
const policy = this.nftPolicy;
|
||||
const family = 'ip';
|
||||
const table = 'smartvpn_tun';
|
||||
const commands: string[] = [];
|
||||
|
||||
// 1. Block list (deny wins — evaluated first)
|
||||
if (policy.blockList) {
|
||||
for (const dest of policy.blockList) {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} drop`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Allow list (pass through directly — skip DNAT)
|
||||
if (policy.allowList) {
|
||||
for (const dest of policy.allowList) {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} accept`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default action
|
||||
switch (policy.default) {
|
||||
case 'forceTarget': {
|
||||
const target = policy.target || '127.0.0.1';
|
||||
commands.push(
|
||||
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} dnat to ${target}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'block':
|
||||
commands.push(
|
||||
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} drop`
|
||||
);
|
||||
break;
|
||||
case 'allow':
|
||||
// No rule needed — kernel default allows
|
||||
break;
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
await this.nft.applyRuleGroup('vpn-destination-policy', commands);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the daemon bridge.
|
||||
*/
|
||||
public stop(): void {
|
||||
// Clean up nftables rules
|
||||
if (this.nftHealthInterval) {
|
||||
clearInterval(this.nftHealthInterval);
|
||||
this.nftHealthInterval = undefined;
|
||||
}
|
||||
if (this.nft) {
|
||||
this.nft.cleanup().catch(() => {}); // best-effort cleanup
|
||||
this.nft = undefined;
|
||||
}
|
||||
this.bridge.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface IVpnClientConfig {
|
||||
wgEndpoint?: string;
|
||||
/** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */
|
||||
wgAllowedIps?: string[];
|
||||
/** Client-defined tags reported to the server after connection (informational, not for access control) */
|
||||
clientDefinedClientTags?: string[];
|
||||
}
|
||||
|
||||
export interface IVpnClientOptions {
|
||||
@@ -91,17 +93,20 @@ export interface IVpnServerConfig {
|
||||
enableNat?: boolean;
|
||||
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
|
||||
* or 'testing' (monitoring only). Default: 'testing'. */
|
||||
forwardingMode?: 'tun' | 'socket' | 'testing';
|
||||
forwardingMode?: 'tun' | 'socket' | 'bridge' | 'testing';
|
||||
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
|
||||
defaultRateLimitBytesPerSec?: number;
|
||||
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
||||
defaultBurstBytes?: number;
|
||||
/** Transport mode: 'both' (default, WS+QUIC), 'websocket', 'quic', or 'wireguard' */
|
||||
transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard';
|
||||
/** Transport mode: 'all' (default, WS+QUIC+WG if configured), 'both' (WS+QUIC),
|
||||
* 'websocket', 'quic', or 'wireguard' */
|
||||
transportMode?: 'websocket' | 'quic' | 'both' | 'all' | 'wireguard';
|
||||
/** QUIC listen address (host:port). Defaults to listenAddr. */
|
||||
quicListenAddr?: string;
|
||||
/** QUIC idle timeout in seconds (default: 30) */
|
||||
quicIdleTimeoutSecs?: number;
|
||||
/** WireGuard: server X25519 private key (base64). Required when transport includes WG. */
|
||||
wgPrivateKey?: string;
|
||||
/** WireGuard: UDP listen port (default: 51820) */
|
||||
wgListenPort?: number;
|
||||
/** WireGuard: configured peers */
|
||||
@@ -115,6 +120,56 @@ export interface IVpnServerConfig {
|
||||
/** Server-level IP block list — applied at TCP accept, before Noise handshake.
|
||||
* Supports exact IPs, CIDR, wildcards, ranges. */
|
||||
connectionIpBlockList?: string[];
|
||||
/** When true and forwardingMode is 'socket', the userspace NAT engine prepends
|
||||
* PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's
|
||||
* tunnel IP as the source address. This allows downstream services (e.g. SmartProxy)
|
||||
* to see the real VPN client identity instead of 127.0.0.1. */
|
||||
socketForwardProxyProtocol?: boolean;
|
||||
/** Destination routing policy for VPN client traffic (socket mode).
|
||||
* Controls where decrypted traffic goes: allow through, block, or redirect to a target.
|
||||
* Default: all traffic passes through (backward compatible). */
|
||||
destinationPolicy?: IDestinationPolicy;
|
||||
/** Public endpoint address for generated client configs (e.g. 'vpn.example.com:51820').
|
||||
* Used as the WireGuard `Endpoint =` and SmartVPN `serverUrl` host.
|
||||
* Defaults to listenAddr (which is typically wrong for remote clients). */
|
||||
serverEndpoint?: string;
|
||||
/** AllowedIPs for generated WireGuard client configs.
|
||||
* Controls what traffic the client routes through the VPN tunnel.
|
||||
* Defaults to ['0.0.0.0/0'] (full tunnel). Set to e.g. ['10.8.0.0/24'] for split tunnel. */
|
||||
clientAllowedIPs?: string[];
|
||||
|
||||
// Bridge mode configuration (forwardingMode: 'bridge')
|
||||
|
||||
/** LAN subnet CIDR for bridge mode (e.g. '192.168.1.0/24').
|
||||
* VPN clients get IPs from this subnet instead of the VPN subnet.
|
||||
* Required when forwardingMode is 'bridge'. */
|
||||
bridgeLanSubnet?: string;
|
||||
/** Physical network interface to bridge (e.g. 'eth0').
|
||||
* Auto-detected from the default route if omitted. */
|
||||
bridgePhysicalInterface?: string;
|
||||
/** Start of VPN client IP range within the LAN subnet (host offset, e.g. 200 for .200).
|
||||
* Default: 200. */
|
||||
bridgeIpRangeStart?: number;
|
||||
/** End of VPN client IP range within the LAN subnet (host offset, e.g. 250 for .250).
|
||||
* Default: 250. */
|
||||
bridgeIpRangeEnd?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination routing policy for VPN client traffic.
|
||||
* Evaluated per-packet in the NAT engine before per-client ACLs.
|
||||
*/
|
||||
export interface IDestinationPolicy {
|
||||
/** Default action for traffic not matching allow/block lists */
|
||||
default: 'forceTarget' | 'block' | 'allow';
|
||||
/** Target IP address for 'forceTarget' mode (e.g. '127.0.0.1'). Required when default is 'forceTarget'. */
|
||||
target?: string;
|
||||
/** Destinations that pass through directly — not rewritten, not blocked.
|
||||
* Supports: exact IP, CIDR, wildcards (192.168.190.*), ranges. */
|
||||
allowList?: string[];
|
||||
/** Destinations that are always blocked. Overrides allowList (deny wins).
|
||||
* Supports: exact IP, CIDR, wildcards, ranges. */
|
||||
blockList?: string[];
|
||||
}
|
||||
|
||||
export interface IVpnServerOptions {
|
||||
@@ -171,11 +226,21 @@ export interface IVpnClientInfo {
|
||||
registeredClientId: string;
|
||||
/** Real client IP:port (from PROXY protocol or direct TCP connection) */
|
||||
remoteAddr?: string;
|
||||
/** Transport used: "websocket", "quic", or "wireguard" */
|
||||
transportType: string;
|
||||
}
|
||||
|
||||
export interface IVpnServerStatistics extends IVpnStatistics {
|
||||
activeClients: number;
|
||||
totalConnections: number;
|
||||
/** Per-transport active client counts. */
|
||||
activeClientsWebsocket: number;
|
||||
activeClientsQuic: number;
|
||||
activeClientsWireguard: number;
|
||||
/** Per-transport total connection counts. */
|
||||
totalConnectionsWebsocket: number;
|
||||
totalConnectionsQuic: number;
|
||||
totalConnectionsWireguard: number;
|
||||
}
|
||||
|
||||
export interface IVpnKeypair {
|
||||
@@ -261,6 +326,10 @@ export interface IClientSecurity {
|
||||
maxConnections?: number;
|
||||
/** Per-client rate limiting. */
|
||||
rateLimit?: IClientRateLimit;
|
||||
/** Per-client destination routing policy override.
|
||||
* When set, overrides the server-level destinationPolicy for this client's traffic.
|
||||
* Supports the same options: forceTarget, block, allow with allow/block lists. */
|
||||
destinationPolicy?: IDestinationPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +349,11 @@ export interface IClientEntry {
|
||||
priority?: number;
|
||||
/** Whether this client is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Tags for grouping (e.g. ["engineering", "office"]) */
|
||||
/** Tags assigned by the server admin — trusted, used for access control (e.g. ["engineering", "office"]) */
|
||||
serverDefinedClientTags?: string[];
|
||||
/** Tags reported by the connecting client — informational only, never used for access control */
|
||||
clientDefinedClientTags?: string[];
|
||||
/** @deprecated Use serverDefinedClientTags instead. Legacy field kept for backward compatibility. */
|
||||
tags?: string[];
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
|
||||
@@ -8,7 +8,8 @@ import * as events from 'events';
|
||||
export { path, fs, os, url, events };
|
||||
|
||||
// @push.rocks
|
||||
import * as smartnftables from '@push.rocks/smartnftables';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
|
||||
export { smartpath, smartrust };
|
||||
export { smartnftables, smartpath, smartrust };
|
||||
|
||||
Reference in New Issue
Block a user