38 Commits

Author SHA1 Message Date
f8bdb991c8 v1.19.2 2026-04-06 10:15:37 +00:00
d4bad38908 fix(server): clean up bridge and hybrid shutdown handling 2026-04-06 10:15:37 +00:00
a293986d6d v1.19.1 2026-04-01 03:58:10 +00:00
96a3159c5d fix(rust): clean up unused Rust warnings in bridge, network, and server modules 2026-04-01 03:58:10 +00:00
3f40506246 v1.19.0 2026-04-01 03:47:26 +00:00
180282ba86 feat(forwarding): add hybrid forwarding mode with per-client bridge and VLAN settings 2026-04-01 03:47:26 +00:00
c49fcaf1ce v1.18.0 2026-03-31 21:34:49 +00:00
fdeba5eeb5 feat(server): add bridge forwarding mode and per-client destination policy overrides 2026-03-31 21:34:49 +00:00
17af7ab289 v1.17.1 2026-03-31 10:57:02 +00:00
b98006e792 fix(readme): document per-transport metrics and handshake-driven WireGuard connection state 2026-03-31 10:57:02 +00:00
fbfbe0db51 v1.17.0 2026-03-31 10:55:15 +00:00
67542f0be7 feat(wireguard): track per-transport server statistics and make WireGuard clients active only after handshake 2026-03-31 10:55:15 +00:00
13d0183e9d v1.16.5 2026-03-31 08:58:27 +00:00
99a8a29ff1 fix(rust-userspace-nat): improve TCP session backpressure, buffering, and idle cleanup in userspace NAT 2026-03-31 08:58:27 +00:00
fe9c693ac8 v1.16.4 2026-03-31 03:35:54 +00:00
20ef92599b fix(server): register preloaded WireGuard clients as peers on server startup 2026-03-31 03:35:54 +00:00
c3f180e264 v1.16.3 2026-03-31 03:21:04 +00:00
667e5ff3de fix(rust-nat): defer TCP bridge startup until handshake completion and buffer partial NAT socket writes 2026-03-31 03:21:04 +00:00
ef5856bd3a v1.16.2 2026-03-31 02:11:29 +00:00
6e4cafe3c5 fix(wireguard): sync runtime peer management with client registration and derive the correct server public key from the WireGuard private key 2026-03-31 02:11:29 +00:00
42949b1233 v1.16.1 2026-03-30 18:06:16 +00:00
7ae7d389dd fix(rust/server): add serde alias for clientAllowedIPs in server config 2026-03-30 18:06:16 +00:00
414edf7038 v1.16.0 2026-03-30 17:55:27 +00:00
a1b62f6b62 feat(server): add configurable client endpoint and allowed IPs for generated VPN configs 2026-03-30 17:55:27 +00:00
cfa91fd419 v1.15.0 2026-03-30 14:32:02 +00:00
8eb26e1920 feat(vpnserver): add nftables-backed destination policy enforcement for TUN mode 2026-03-30 14:32:02 +00:00
e513f8686b v1.14.0 2026-03-30 12:52:17 +00:00
e06667b298 feat(nat): add destination routing policy support for socket-mode VPN traffic 2026-03-30 12:52:17 +00:00
c3afb83470 v1.13.0 2026-03-30 09:42:04 +00:00
2d7a507cf2 feat(client-registry): separate trusted server-defined client tags from client-reported tags with legacy tag compatibility 2026-03-30 09:42:04 +00:00
a757a4bb73 v1.12.0 2026-03-30 07:13:49 +00:00
5bf21ab4ac feat(server): add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding 2026-03-30 07:13:49 +00:00
af46dc9b39 v1.11.0 2026-03-30 06:52:20 +00:00
79d9928485 feat(server): unify WireGuard into the shared server transport pipeline 2026-03-30 06:52:20 +00:00
70e838c8ff v1.10.2 2026-03-30 00:03:46 +00:00
dbcfdb1fb6 fix(client): wait for the connection task to shut down cleanly before disconnecting and increase test timeout 2026-03-30 00:03:46 +00:00
c97beed6e0 v1.10.1 2026-03-29 23:41:31 +00:00
c3cc237db5 fix(test, docs, scripts): correct test command verbosity, shorten load test timings, and document forwarding modes 2026-03-29 23:41:31 +00:00
19 changed files with 2750 additions and 766 deletions

View File

@@ -1,5 +1,143 @@
# Changelog # Changelog
## 2026-04-06 - 1.19.2 - fix(server)
clean up bridge and hybrid shutdown handling
- persist bridge teardown metadata so stop() can restore host IP configuration and remove the bridge in bridge and hybrid modes
- use separate shutdown channels for hybrid socket and bridge engines to stop both forwarding paths correctly
- avoid IP pool leaks when client registration fails and ignore unspecified IPv4 addresses when selecting WireGuard peer addresses
- make daemon bridge stop await nftables cleanup and process exit, and cap effective tunnel MTU to the link MTU
## 2026-04-01 - 1.19.1 - fix(rust)
clean up unused Rust warnings in bridge, network, and server modules
- remove the unused error import from the bridge module
- mark IpPool.prefix_len as intentionally unused to suppress dead code warnings
- rename the unused socket shutdown sender binding in the server to an underscore-prefixed variable
## 2026-04-01 - 1.19.0 - feat(forwarding)
add hybrid forwarding mode with per-client bridge and VLAN settings
- introduces a new hybrid forwarding mode that routes each client through either userspace NAT or bridge mode based on per-client configuration
- adds per-client bridge options including useHostIp, DHCP, static LAN IP, and VLAN assignment fields to the server and TypeScript interfaces
- adds Linux bridge VLAN helper functions and updates documentation to cover hybrid mode and VLAN-capable bridge clients
## 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
- Fixes the test script by removing the duplicated verbose flag in package.json.
- Reduces load test delays and burst sizes to keep keepalive and connection tests faster and more stable.
- Updates the README to describe forwardingMode options, userspace NAT support, and related configuration examples.
## 2026-03-29 - 1.10.0 - feat(rust-server, rust-client, ts-interfaces) ## 2026-03-29 - 1.10.0 - feat(rust-server, rust-client, ts-interfaces)
add configurable packet forwarding with TUN and userspace NAT modes add configurable packet forwarding with TUN and userspace NAT modes

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvpn", "name": "@push.rocks/smartvpn",
"version": "1.10.0", "version": "1.19.2",
"private": false, "private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon", "description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module", "type": "module",
@@ -12,7 +12,7 @@
"scripts": { "scripts": {
"build": "(tsbuild tsfolders) && (tsrust)", "build": "(tsbuild tsfolders) && (tsrust)",
"test:before": "(tsrust)", "test:before": "(tsrust)",
"test": "tstest test/ --verbose--verbose --logfile --timeout 60", "test": "tstest test/ --verbose --logfile --timeout 90",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"repository": { "repository": {
@@ -29,6 +29,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@push.rocks/smartnftables": "1.1.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartrust": "^1.3.2" "@push.rocks/smartrust": "^1.3.2"
}, },

11
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@push.rocks/smartnftables':
specifier: 1.1.0
version: 1.1.0
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -1132,6 +1135,9 @@ packages:
'@push.rocks/smartnetwork@4.5.2': '@push.rocks/smartnetwork@4.5.2':
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==} 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': '@push.rocks/smartnpm@2.0.6':
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==} resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
@@ -5335,6 +5341,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': '@push.rocks/smartnpm@2.0.6':
dependencies: dependencies:
'@push.rocks/consolecolor': 2.0.3 '@push.rocks/consolecolor': 2.0.3

379
readme.md
View File

@@ -2,13 +2,19 @@
A high-performance VPN solution with a **TypeScript control plane** and a **Rust data plane daemon**. Enterprise-ready client authentication, triple transport support (WebSocket + QUIC + WireGuard), and a typed hub API for managing clients from code. A high-performance VPN solution with a **TypeScript control plane** and a **Rust data plane daemon**. Enterprise-ready client authentication, triple transport support (WebSocket + QUIC + WireGuard), and a typed hub API for managing clients from code.
🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry - 🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry
🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol) - 🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol)
🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions - 🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions
🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum) - 🔀 **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 - 🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs
📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs - 📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs
- 🌐 **Unified forwarding pipeline**: all transports share the same engine — TUN (kernel), userspace NAT (no root), L2 bridge, hybrid, or testing mode
- 🏠 **Bridge mode**: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network
- 🔀 **Hybrid mode**: per-client routing — some clients bridge to the LAN, others use userspace NAT, all on the same server
- 🏷️ **VLAN support**: assign individual clients to 802.1Q VLANs on the bridge
- 🎯 **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 ## Issue Reporting and Security
@@ -35,11 +41,38 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
│ Config validation │ │ WS + QUIC + WireGuard │ │ Config validation │ │ WS + QUIC + WireGuard │
│ Hub: client management │ │ TUN device, IP pool, NAT │ │ Hub: client management │ │ TUN device, IP pool, NAT │
│ WireGuard .conf generation │ │ Rate limiting, ACLs, QoS │ │ 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). **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 🚀 ## Quick Start 🚀
### 1. Start a VPN Server (Hub) ### 1. Start a VPN Server (Hub)
@@ -53,7 +86,9 @@ await server.start({
privateKey: '<server-noise-private-key-base64>', privateKey: '<server-noise-private-key-base64>',
publicKey: '<server-noise-public-key-base64>', publicKey: '<server-noise-public-key-base64>',
subnet: '10.8.0.0/24', subnet: '10.8.0.0/24',
transportMode: 'both', // WebSocket + QUIC simultaneously transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'hybrid' | 'testing'
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
enableNat: true, enableNat: true,
dns: ['1.1.1.1', '8.8.8.8'], dns: ['1.1.1.1', '8.8.8.8'],
}); });
@@ -64,7 +99,7 @@ await server.start({
```typescript ```typescript
const bundle = await server.createClient({ const bundle = await server.createClient({
clientId: 'alice-laptop', clientId: 'alice-laptop',
tags: ['engineering'], serverDefinedClientTags: ['engineering'], // trusted tags for access control
security: { security: {
destinationAllowList: ['10.0.0.0/8'], // can only reach internal network destinationAllowList: ['10.0.0.0/8'], // can only reach internal network
destinationBlockList: ['10.0.0.99'], // except this host destinationBlockList: ['10.0.0.99'], // except this host
@@ -107,7 +142,31 @@ Every client authenticates with a **Noise IK handshake** (`Noise_IK_25519_ChaCha
| **QUIC** | UDP (via quinn) | Low latency, datagram support for IP packets | | **QUIC** | UDP (via quinn) | Low latency, datagram support for IP packets |
| **WireGuard** | UDP (via boringtun) | Standard WG clients (iOS, Android, wg-quick) | | **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) ### 🛡️ ACL Engine (SmartProxy-Aligned)
@@ -152,13 +211,188 @@ await server.start({
- `remoteAddr` field on `IVpnClientInfo` exposes the real client IP for monitoring - `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 - **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 five forwarding modes, configurable per-server:
| 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 |
| **Hybrid** | `'hybrid'` | Per-client routing: some clients use socket NAT, others use bridge — both engines run simultaneously | ✅ Yes |
| **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No |
```typescript
// Server with userspace NAT (no root required)
await server.start({
// ...
forwardingMode: 'socket',
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,
});
// Server with hybrid mode — per-client routing
await server.start({
// ...
forwardingMode: 'hybrid',
bridgePhysicalInterface: 'eth0', // for bridge clients
});
// Client with TUN device
const { assignedIp } = await client.connect({
// ...
forwardingMode: 'tun',
});
```
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.
The **hybrid** mode runs both engines simultaneously with a **per-client routing table**. Each client's `useHostIp` flag determines whether its packets go through the bridge (L2, LAN IP) or socket NAT (userspace, VPN IP). This is ideal when most clients need internet NAT but some need direct LAN access.
### 🏠 Per-Client Bridge & VLAN Settings
When using `bridge` or `hybrid` mode, each client can be individually configured for LAN bridging, static IPs, DHCP, and 802.1Q VLAN assignment:
```typescript
// Client that bridges to the LAN with a static IP
await server.createClient({
clientId: 'office-printer',
useHostIp: true, // bridge to LAN instead of VPN subnet
staticIp: '192.168.1.210', // fixed LAN IP
});
// Client that gets a LAN IP via DHCP
await server.createClient({
clientId: 'roaming-laptop',
useHostIp: true,
useDhcp: true, // obtain IP from LAN DHCP server
});
// Client on a specific VLAN
await server.createClient({
clientId: 'iot-sensor',
useHostIp: true,
forceVlan: true,
vlanId: 100, // 802.1Q VLAN ID (1-4094)
});
// Regular NAT client (default, no bridge)
await server.createClient({
clientId: 'remote-worker',
// useHostIp defaults to false → uses socket NAT
});
```
| Field | Type | Description |
|-------|------|-------------|
| `useHostIp` | `boolean` | `true` = bridge to LAN (host IP), `false` = VPN subnet via NAT (default) |
| `useDhcp` | `boolean` | When `useHostIp` is true, obtain IP via DHCP relay instead of static/auto-assign |
| `staticIp` | `string` | Fixed LAN IP when `useHostIp` is true and `useDhcp` is false |
| `forceVlan` | `boolean` | Assign this client to a specific 802.1Q VLAN on the bridge |
| `vlanId` | `number` | VLAN ID (1-4094), required when `forceVlan` is true |
VLAN support uses Linux bridge VLAN filtering — each client's TAP port gets tagged with the specified VLAN ID, isolating traffic at Layer 2.
### 📊 Telemetry & QoS ### 📊 Telemetry & QoS
- **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`) - **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`)
- **Adaptive keepalives**: Interval adjusts based on link health (60s → 30s → 10s) - **Adaptive keepalives**: Interval adjusts based on link health (60s → 30s → 10s)
- **Per-client rate limiting**: Token bucket with configurable bytes/sec and burst - **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) - **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 ### 🔄 Hub Client Management
@@ -175,7 +409,7 @@ const all = await server.listRegisteredClients();
// Update (ACLs, tags, description, rate limits...) // Update (ACLs, tags, description, rate limits...)
await server.updateClient('bob-phone', { await server.updateClient('bob-phone', {
security: { destinationAllowList: ['0.0.0.0/0'] }, security: { destinationAllowList: ['0.0.0.0/0'] },
tags: ['mobile', 'field-ops'], serverDefinedClientTags: ['mobile', 'field-ops'],
}); });
// Enable / Disable // Enable / Disable
@@ -213,46 +447,101 @@ const conf = WgConfigGenerator.generateClientConfig({
// → standard WireGuard .conf compatible with wg-quick, iOS, Android // → 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 ### 🖥️ System Service Installation
Generate systemd (Linux) or launchd (macOS) service units:
```typescript ```typescript
import { VpnInstaller } from '@push.rocks/smartvpn'; import { VpnInstaller } from '@push.rocks/smartvpn';
const unit = VpnInstaller.generateServiceUnit({ const unit = VpnInstaller.generateServiceUnit({
binaryPath: '/usr/local/bin/smartvpn_daemon',
socketPath: '/var/run/smartvpn.sock',
mode: 'server', mode: 'server',
configPath: '/etc/smartvpn/server.json',
}); });
// unit.platform → 'linux' | 'macos' // unit.platform → 'linux' | 'macos'
// unit.content → systemd unit file or launchd plist // unit.content → systemd unit file or launchd plist
// unit.installPath → /etc/systemd/system/smartvpn-server.service // 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 📖 ## API Reference 📖
### Classes ### Classes
| Class | Description | | Class | Description |
|-------|-------------| |-------|-------------|
| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD. | | `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, telemetry. | | `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, status, telemetry. |
| `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). | | `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). Handles spawn, connect, reconnect, and typed command dispatch. |
| `VpnConfig` | Static config validation and file I/O. | | `VpnConfig` | Static config validation and JSON file I/O. Validates keys, addresses, CIDRs, MTU, etc. |
| `VpnInstaller` | Generates systemd/launchd service files. | | `VpnInstaller` | Generates systemd/launchd service files for daemon deployment. |
| `WgConfigGenerator` | Generates standard WireGuard `.conf` files. | | `WgConfigGenerator` | Generates standard WireGuard `.conf` files (client and server). |
### Key Interfaces ### Key Interfaces
| Interface | Purpose | | Interface | Purpose |
|-----------|---------| |-----------|---------|
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, clients, proxy protocol) | | `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge/hybrid, clients, proxy protocol, destination policy) |
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, WG options) | | `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options, client-defined tags) |
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) | | `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry, bridge/VLAN settings) |
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) | | `IClientSecurity` | Per-client ACLs, rate limits, and destination policy override (SmartProxy-aligned naming) |
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) | | `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
| `IClientConfigBundle` | Full config bundle returned by `createClient()` | | `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets |
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr) | | `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 | | `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health |
| `IVpnMtuInfo` | TUN MTU, effective MTU, overhead bytes, oversized packet stats |
| `IVpnKeypair` | Base64-encoded public/private key pair | | `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 ### Server IPC Commands
@@ -266,7 +555,7 @@ const unit = VpnInstaller.generateServiceUnit({
| `exportClientConfig` | Re-export as SmartVPN config or WireGuard `.conf` | | `exportClientConfig` | Re-export as SmartVPN config or WireGuard `.conf` |
| `listClients` / `disconnectClient` | Manage live connections | | `listClients` / `disconnectClient` | Manage live connections |
| `setClientRateLimit` / `removeClientRateLimit` | Runtime rate limit adjustments | | `setClientRateLimit` / `removeClientRateLimit` | Runtime rate limit adjustments |
| `getStatus` / `getStatistics` / `getClientTelemetry` | Monitoring | | `getStatus` / `getStatistics` / `getClientTelemetry` | Monitoring (stats include per-transport breakdowns) |
| `generateKeypair` / `generateWgKeypair` / `generateClientKeypair` | Key generation | | `generateKeypair` / `generateWgKeypair` / `generateClientKeypair` | Key generation |
| `addWgPeer` / `removeWgPeer` / `listWgPeers` | WireGuard peer management | | `addWgPeer` / `removeWgPeer` / `listWgPeers` | WireGuard peer management |
@@ -284,19 +573,24 @@ const unit = VpnInstaller.generateServiceUnit({
### Server Configuration ### Server Configuration
```typescript ```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 // WebSocket only
{ transportMode: 'websocket', listenAddr: '0.0.0.0:443' } { transportMode: 'websocket', listenAddr: '0.0.0.0:443' }
// QUIC only // QUIC only
{ transportMode: 'quic', listenAddr: '0.0.0.0:443' } { transportMode: 'quic', listenAddr: '0.0.0.0:443' }
// Both (WS + QUIC on same or different ports) // WireGuard only
{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' } { transportMode: 'wireguard', wgPrivateKey: '...', wgListenPort: 51820, wgPeers: [...] }
// WireGuard
{ transportMode: 'wireguard', wgListenPort: 51820, wgPeers: [...] }
``` ```
All transport modes share the same `forwardingMode` — WireGuard peers can use `'socket'` (userspace NAT) just like WS/QUIC clients.
### Client Configuration ### Client Configuration
```typescript ```typescript
@@ -341,7 +635,7 @@ pnpm install
# Build (TypeScript + Rust cross-compile) # Build (TypeScript + Rust cross-compile)
pnpm build pnpm build
# Run all tests (79 TS + 129 Rust = 208 tests) # Run all tests
pnpm test pnpm test
# Run Rust tests directly # Run Rust tests directly
@@ -358,6 +652,8 @@ smartvpn/
├── ts/ # TypeScript control plane ├── ts/ # TypeScript control plane
│ ├── index.ts # All exports │ ├── index.ts # All exports
│ ├── smartvpn.interfaces.ts # Interfaces, types, IPC command maps │ ├── 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.vpnserver.ts
│ ├── smartvpn.classes.vpnclient.ts │ ├── smartvpn.classes.vpnclient.ts
│ ├── smartvpn.classes.vpnbridge.ts │ ├── smartvpn.classes.vpnbridge.ts
@@ -375,20 +671,27 @@ smartvpn/
│ ├── proxy_protocol.rs # PROXY protocol v2 parser │ ├── proxy_protocol.rs # PROXY protocol v2 parser
│ ├── management.rs # JSON-lines IPC │ ├── management.rs # JSON-lines IPC
│ ├── transport.rs # WebSocket transport │ ├── transport.rs # WebSocket transport
│ ├── transport_trait.rs # Transport abstraction (Sink/Stream)
│ ├── quic_transport.rs # QUIC transport │ ├── quic_transport.rs # QUIC transport
│ ├── wireguard.rs # WireGuard (boringtun) │ ├── wireguard.rs # WireGuard (boringtun)
│ ├── codec.rs # Binary frame protocol │ ├── codec.rs # Binary frame protocol
│ ├── keepalive.rs # Adaptive keepalives │ ├── keepalive.rs # Adaptive keepalives
│ ├── ratelimit.rs # Token bucket │ ├── ratelimit.rs # Token bucket
── ... # tunnel, network, telemetry, qos, mtu, reconnect ── userspace_nat.rs # Userspace TCP/UDP NAT proxy
├── 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_ts/ # Compiled TypeScript
└── dist_rust/ # Cross-compiled binaries (linux amd64 + arm64) └── dist_rust/ # Cross-compiled binaries (linux amd64 + arm64)
``` ```
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license.md) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
@@ -400,7 +703,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany 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. For any legal inquiries or further information, please contact us via email at hello@task.vc.

View File

@@ -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. /// Check if `ip` matches any pattern in the list.
/// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*), /// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*),
/// and IP ranges (192.168.1.1-192.168.1.100). /// 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 { for pattern in patterns {
if ip_matches(ip, pattern) { if ip_matches(ip, pattern) {
return true; return true;
@@ -164,6 +164,7 @@ mod tests {
destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()), destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()),
max_connections: None, max_connections: None,
rate_limit: None, rate_limit: None,
destination_policy: None,
} }
} }

396
rust/src/bridge.rs Normal file
View File

@@ -0,0 +1,396 @@
//! 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, 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(&ETH_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(&ETH_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(())
}
// ============================================================================
// VLAN support (802.1Q via Linux bridge VLAN filtering)
// ============================================================================
async fn run_bridge_cmd(args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("bridge")
.args(args)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("bridge {} failed: {}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Enable VLAN filtering on a bridge.
pub async fn enable_vlan_filtering(bridge: &str) -> Result<()> {
run_ip_cmd(&["link", "set", bridge, "type", "bridge", "vlan_filtering", "1"]).await?;
info!("Enabled VLAN filtering on bridge {}", bridge);
Ok(())
}
/// Add a VLAN ID to a bridge port (TAP or physical interface).
/// `pvid` = set as port VLAN ID (untagged ingress), `untagged` = strip tag on egress.
pub async fn add_vlan_to_port(port: &str, vlan_id: u16, pvid: bool, untagged: bool) -> Result<()> {
let mut args = vec!["vlan", "add", "dev", port, "vid"];
let vid_str = vlan_id.to_string();
args.push(&vid_str);
if pvid { args.push("pvid"); }
if untagged { args.push("untagged"); }
run_bridge_cmd(&args).await?;
info!("Added VLAN {} to port {} (pvid={}, untagged={})", vlan_id, port, pvid, untagged);
Ok(())
}
/// Remove a VLAN ID from a bridge port.
pub async fn remove_vlan_from_port(port: &str, vlan_id: u16) -> Result<()> {
let vid_str = vlan_id.to_string();
run_bridge_cmd(&["vlan", "del", "dev", port, "vid", &vid_str]).await?;
info!("Removed VLAN {} from port {}", vlan_id, port);
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(())
}
}

View File

@@ -81,6 +81,7 @@ pub struct VpnClient {
connected_since: Arc<RwLock<Option<std::time::Instant>>>, connected_since: Arc<RwLock<Option<std::time::Instant>>>,
quality_rx: Option<watch::Receiver<ConnectionQuality>>, quality_rx: Option<watch::Receiver<ConnectionQuality>>,
link_health: Arc<RwLock<LinkHealth>>, link_health: Arc<RwLock<LinkHealth>>,
connection_handle: Option<tokio::task::JoinHandle<()>>,
} }
impl VpnClient { impl VpnClient {
@@ -93,6 +94,7 @@ impl VpnClient {
connected_since: Arc::new(RwLock::new(None)), connected_since: Arc::new(RwLock::new(None)),
quality_rx: None, quality_rx: None,
link_health: Arc::new(RwLock::new(LinkHealth::Degraded)), link_health: Arc::new(RwLock::new(LinkHealth::Degraded)),
connection_handle: None,
} }
} }
@@ -280,7 +282,7 @@ impl VpnClient {
// Spawn packet forwarding loop // Spawn packet forwarding loop
let assigned_ip_clone = assigned_ip.clone(); let assigned_ip_clone = assigned_ip.clone();
tokio::spawn(client_loop( let join_handle = tokio::spawn(client_loop(
sink, sink,
stream, stream,
noise_transport, noise_transport,
@@ -294,6 +296,7 @@ impl VpnClient {
tun_writer, tun_writer,
tun_subnet, tun_subnet,
)); ));
self.connection_handle = Some(join_handle);
Ok(assigned_ip_clone) Ok(assigned_ip_clone)
} }
@@ -303,6 +306,13 @@ impl VpnClient {
if let Some(tx) = self.shutdown_tx.take() { if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(()).await; 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.assigned_ip.write().await = None;
*self.connected_since.write().await = None; *self.connected_since.write().await = None;
*self.state.write().await = ClientState::Disconnected; *self.state.write().await = ClientState::Disconnected;

View File

@@ -26,6 +26,9 @@ pub struct ClientSecurity {
pub max_connections: Option<u32>, pub max_connections: Option<u32>,
/// Per-client rate limiting. /// Per-client rate limiting.
pub rate_limit: Option<ClientRateLimit>, 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. /// A registered client entry — the server-side source of truth.
@@ -44,7 +47,12 @@ pub struct ClientEntry {
pub priority: Option<u32>, pub priority: Option<u32>,
/// Whether this client is enabled (default: true). /// Whether this client is enabled (default: true).
pub enabled: Option<bool>, 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>>, pub tags: Option<Vec<String>>,
/// Optional description. /// Optional description.
pub description: Option<String>, pub description: Option<String>,
@@ -52,6 +60,19 @@ pub struct ClientEntry {
pub expires_at: Option<String>, pub expires_at: Option<String>,
/// Assigned VPN IP address. /// Assigned VPN IP address.
pub assigned_ip: Option<String>, pub assigned_ip: Option<String>,
// Per-client bridge/host-IP settings
/// If true, client gets a host network IP via bridge mode.
pub use_host_ip: Option<bool>,
/// If true and use_host_ip is true, obtain IP via DHCP relay.
pub use_dhcp: Option<bool>,
/// Static LAN IP when use_host_ip is true and use_dhcp is false.
pub static_ip: Option<String>,
/// If true, assign this client to a specific 802.1Q VLAN.
pub force_vlan: Option<bool>,
/// 802.1Q VLAN ID (1-4094).
pub vlan_id: Option<u16>,
} }
impl ClientEntry { impl ClientEntry {
@@ -71,12 +92,14 @@ impl ClientEntry {
} }
} }
/// In-memory client registry with dual-key indexing. /// In-memory client registry with triple-key indexing.
pub struct ClientRegistry { pub struct ClientRegistry {
/// Primary index: clientId → ClientEntry /// Primary index: clientId → ClientEntry
entries: HashMap<String, ClientEntry>, entries: HashMap<String, ClientEntry>,
/// Secondary index: publicKey (base64) → clientId (fast lookup during handshake) /// Secondary index: publicKey (base64) → clientId (fast lookup during handshake)
key_index: HashMap<String, String>, key_index: HashMap<String, String>,
/// Tertiary index: assignedIp → clientId (fast lookup during NAT destination policy)
ip_index: HashMap<String, String>,
} }
impl ClientRegistry { impl ClientRegistry {
@@ -84,13 +107,18 @@ impl ClientRegistry {
Self { Self {
entries: HashMap::new(), entries: HashMap::new(),
key_index: HashMap::new(), key_index: HashMap::new(),
ip_index: HashMap::new(),
} }
} }
/// Build a registry from a list of client entries. /// Build a registry from a list of client entries.
pub fn from_entries(entries: Vec<ClientEntry>) -> Result<Self> { pub fn from_entries(entries: Vec<ClientEntry>) -> Result<Self> {
let mut registry = Self::new(); 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)?; registry.add(entry)?;
} }
Ok(registry) Ok(registry)
@@ -105,6 +133,9 @@ impl ClientRegistry {
anyhow::bail!("Public key already registered to another client"); anyhow::bail!("Public key already registered to another client");
} }
self.key_index.insert(entry.public_key.clone(), entry.client_id.clone()); 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); self.entries.insert(entry.client_id.clone(), entry);
Ok(()) Ok(())
} }
@@ -114,6 +145,9 @@ impl ClientRegistry {
let entry = self.entries.remove(client_id) let entry = self.entries.remove(client_id)
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
self.key_index.remove(&entry.public_key); self.key_index.remove(&entry.public_key);
if let Some(ref ip) = entry.assigned_ip {
self.ip_index.remove(ip);
}
Ok(entry) Ok(entry)
} }
@@ -128,6 +162,12 @@ impl ClientRegistry {
self.entries.get(client_id) 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). /// Check if a public key is authorized (exists, enabled, not expired).
pub fn is_authorized(&self, public_key: &str) -> bool { pub fn is_authorized(&self, public_key: &str) -> bool {
match self.get_by_key(public_key) { match self.get_by_key(public_key) {
@@ -144,12 +184,22 @@ impl ClientRegistry {
let entry = self.entries.get_mut(client_id) let entry = self.entries.get_mut(client_id)
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
let old_key = entry.public_key.clone(); let old_key = entry.public_key.clone();
let old_ip = entry.assigned_ip.clone();
updater(entry); updater(entry);
// If public key changed, update the index // If public key changed, update the key index
if entry.public_key != old_key { if entry.public_key != old_key {
self.key_index.remove(&old_key); self.key_index.remove(&old_key);
self.key_index.insert(entry.public_key.clone(), client_id.to_string()); 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(()) Ok(())
} }
@@ -193,10 +243,17 @@ mod tests {
security: None, security: None,
priority: None, priority: None,
enabled: None, enabled: None,
server_defined_client_tags: None,
client_defined_client_tags: None,
tags: None, tags: None,
description: None, description: None,
expires_at: None, expires_at: None,
assigned_ip: None, assigned_ip: None,
use_host_ip: None,
use_dhcp: None,
static_ip: None,
force_vlan: None,
vlan_id: None,
} }
} }
@@ -351,6 +408,7 @@ mod tests {
bytes_per_sec: 1_000_000, bytes_per_sec: 1_000_000,
burst_bytes: 2_000_000, burst_bytes: 2_000_000,
}), }),
destination_policy: None,
}); });
let mut reg = ClientRegistry::new(); let mut reg = ClientRegistry::new();
reg.add(entry).unwrap(); reg.add(entry).unwrap();

View File

@@ -22,3 +22,4 @@ pub mod client_registry;
pub mod acl; pub mod acl;
pub mod proxy_protocol; pub mod proxy_protocol;
pub mod userspace_nat; pub mod userspace_nat;
pub mod bridge;

View File

@@ -7,7 +7,7 @@ use tracing::{info, error, warn};
use crate::client::{ClientConfig, VpnClient}; use crate::client::{ClientConfig, VpnClient};
use crate::crypto; use crate::crypto;
use crate::server::{ServerConfig, VpnServer}; use crate::server::{ServerConfig, VpnServer};
use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig, WgServer, WgServerConfig}; use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig};
// ============================================================================ // ============================================================================
// IPC protocol types // 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_client = VpnClient::new();
let mut vpn_server = VpnServer::new(); let mut vpn_server = VpnServer::new();
let mut wg_client = WgClient::new(); let mut wg_client = WgClient::new();
let mut wg_server = WgServer::new();
send_event_stdout("ready", serde_json::json!({ "mode": mode })); 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 { let response = match mode {
"client" => handle_client_request(&request, &mut vpn_client, &mut wg_client).await, "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)), _ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
}; };
send_response_stdout(&response); 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_client = std::sync::Arc::new(Mutex::new(VpnClient::new()));
let vpn_server = std::sync::Arc::new(Mutex::new(VpnServer::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_client = std::sync::Arc::new(Mutex::new(WgClient::new()));
let wg_server = std::sync::Arc::new(Mutex::new(WgServer::new()));
loop { loop {
match listener.accept().await { 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 client = vpn_client.clone();
let server = vpn_server.clone(); let server = vpn_server.clone();
let wg_c = wg_client.clone(); let wg_c = wg_client.clone();
let wg_s = wg_server.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = 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); warn!("Socket connection error: {}", e);
} }
@@ -185,7 +182,6 @@ async fn handle_socket_connection(
vpn_client: std::sync::Arc<Mutex<VpnClient>>, vpn_client: std::sync::Arc<Mutex<VpnClient>>,
vpn_server: std::sync::Arc<Mutex<VpnServer>>, vpn_server: std::sync::Arc<Mutex<VpnServer>>,
wg_client: std::sync::Arc<Mutex<WgClient>>, wg_client: std::sync::Arc<Mutex<WgClient>>,
wg_server: std::sync::Arc<Mutex<WgServer>>,
) -> Result<()> { ) -> Result<()> {
let (reader, mut writer) = stream.into_split(); let (reader, mut writer) = stream.into_split();
let buf_reader = BufReader::new(reader); let buf_reader = BufReader::new(reader);
@@ -241,8 +237,7 @@ async fn handle_socket_connection(
} }
"server" => { "server" => {
let mut server = vpn_server.lock().await; let mut server = vpn_server.lock().await;
let mut wg_s = wg_server.lock().await; handle_server_request(&request, &mut server).await
handle_server_request(&request, &mut server, &mut wg_s).await
} }
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)), _ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
}; };
@@ -381,92 +376,46 @@ async fn handle_client_request(
async fn handle_server_request( async fn handle_server_request(
request: &ManagementRequest, request: &ManagementRequest,
vpn_server: &mut VpnServer, vpn_server: &mut VpnServer,
wg_server: &mut WgServer,
) -> ManagementResponse { ) -> ManagementResponse {
let id = request.id.clone(); let id = request.id.clone();
match request.method.as_str() { match request.method.as_str() {
"start" => { "start" => {
// Check if transportMode is "wireguard" let config: ServerConfig = match serde_json::from_value(
let transport_mode = request.params request.params.get("config").cloned().unwrap_or_default(),
.get("config") ) {
.and_then(|c| c.get("transportMode")) Ok(c) => c,
.and_then(|t| t.as_str()) Err(e) => {
.unwrap_or(""); return ManagementResponse::err(id, format!("Invalid config: {}", e));
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)),
} }
};
match vpn_server.start(config).await {
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
Err(e) => ManagementResponse::err(id, format!("Start failed: {}", e)),
} }
} }
"stop" => { "stop" => {
if wg_server.is_running() { match vpn_server.stop().await {
match wg_server.stop().await { Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})), Err(e) => ManagementResponse::err(id, format!("Stop failed: {}", e)),
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)),
}
} }
} }
"getStatus" => { "getStatus" => {
if wg_server.is_running() { let status = vpn_server.get_status();
ManagementResponse::ok(id, wg_server.get_status()) ManagementResponse::ok(id, status)
} else {
let status = vpn_server.get_status();
ManagementResponse::ok(id, status)
}
} }
"getStatistics" => { "getStatistics" => {
if wg_server.is_running() { let stats = vpn_server.get_statistics().await;
ManagementResponse::ok(id, wg_server.get_statistics().await) match serde_json::to_value(&stats) {
} else { Ok(v) => ManagementResponse::ok(id, v),
let stats = vpn_server.get_statistics().await; Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
match serde_json::to_value(&stats) {
Ok(v) => ManagementResponse::ok(id, v),
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
}
} }
} }
"listClients" => { "listClients" => {
if wg_server.is_running() { let clients = vpn_server.list_clients().await;
let peers = wg_server.list_peers().await; match serde_json::to_value(&clients) {
match serde_json::to_value(&peers) { Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })), Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
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)),
}
} }
} }
"disconnectClient" => { "disconnectClient" => {
@@ -546,9 +495,6 @@ async fn handle_server_request(
) )
} }
"addWgPeer" => { "addWgPeer" => {
if !wg_server.is_running() {
return ManagementResponse::err(id, "WireGuard server not running".to_string());
}
let config: WgPeerConfig = match serde_json::from_value( let config: WgPeerConfig = match serde_json::from_value(
request.params.get("peer").cloned().unwrap_or_default(), 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)); 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!({})), Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
Err(e) => ManagementResponse::err(id, format!("Add peer failed: {}", e)), Err(e) => ManagementResponse::err(id, format!("Add peer failed: {}", e)),
} }
} }
"removeWgPeer" => { "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()) { let public_key = match request.params.get("publicKey").and_then(|v| v.as_str()) {
Some(k) => k.to_string(), Some(k) => k.to_string(),
None => return ManagementResponse::err(id, "Missing publicKey".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!({})), Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
Err(e) => ManagementResponse::err(id, format!("Remove peer failed: {}", e)), Err(e) => ManagementResponse::err(id, format!("Remove peer failed: {}", e)),
} }
} }
"listWgPeers" => { "listWgPeers" => {
if !wg_server.is_running() { let peers = vpn_server.list_wg_peers().await;
return ManagementResponse::err(id, "WireGuard server not running".to_string());
}
let peers = wg_server.list_peers().await;
match serde_json::to_value(&peers) { match serde_json::to_value(&peers) {
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "peers": v })), Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "peers": v })),
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)), Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),

View File

@@ -8,11 +8,16 @@ pub struct IpPool {
/// Network address (e.g., 10.8.0.0) /// Network address (e.g., 10.8.0.0)
network: Ipv4Addr, network: Ipv4Addr,
/// Prefix length (e.g., 24) /// Prefix length (e.g., 24)
#[allow(dead_code)]
prefix_len: u8, prefix_len: u8,
/// Allocated IPs: IP -> client_id /// Allocated IPs: IP -> client_id
allocated: HashMap<Ipv4Addr, String>, allocated: HashMap<Ipv4Addr, String>,
/// Next candidate offset (skipping .0 network and .1 gateway) /// Next candidate offset (skipping .0 network and .1 gateway)
next_offset: u32, 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 { impl IpPool {
@@ -28,11 +33,47 @@ impl IpPool {
anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len); 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 { Ok(Self {
network, network,
prefix_len, prefix_len,
allocated: HashMap::new(), allocated: HashMap::new(),
next_offset: 2, // Skip .0 (network) and .1 (server/gateway) 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 +85,17 @@ impl IpPool {
/// Total number of usable client addresses in the pool. /// Total number of usable client addresses in the pool.
pub fn capacity(&self) -> u32 { pub fn capacity(&self) -> u32 {
let host_bits = 32 - self.prefix_len as u32; self.max_offset.saturating_sub(self.min_offset)
let total = 1u32 << host_bits;
total.saturating_sub(3) // minus network, gateway, broadcast
} }
/// Allocate an IP for a client. Returns the assigned IP. /// Allocate an IP for a client. Returns the assigned IP.
pub fn allocate(&mut self, client_id: &str) -> Result<Ipv4Addr> { 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 // Try to find a free IP starting from next_offset
let start = self.next_offset; let start = self.next_offset;
let mut offset = start; let mut offset = start;
loop { loop {
if offset >= max_offset { if offset >= self.max_offset {
offset = 2; // wrap around offset = self.min_offset; // wrap around
} }
let ip = Ipv4Addr::from(u32::from(self.network) + offset); let ip = Ipv4Addr::from(u32::from(self.network) + offset);
@@ -86,6 +122,16 @@ impl IpPool {
client_id 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. /// Number of currently allocated IPs.
pub fn allocated_count(&self) -> usize { pub fn allocated_count(&self) -> usize {
self.allocated.len() self.allocated.len()

View File

@@ -7,7 +7,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex, RwLock}; use tokio::sync::{mpsc, Mutex, RwLock};
use tracing::{info, error, warn}; use tracing::{debug, info, error, warn};
use crate::acl; use crate::acl;
use crate::client_registry::{ClientEntry, ClientRegistry}; use crate::client_registry::{ClientEntry, ClientRegistry};
@@ -24,6 +24,20 @@ use crate::tunnel::{self, TunConfig};
/// Dead-peer timeout: 3x max keepalive interval (Healthy=60s). /// Dead-peer timeout: 3x max keepalive interval (Healthy=60s).
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180); const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
/// 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). /// Server configuration (matches TS IVpnServerConfig).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -58,6 +72,37 @@ pub struct ServerConfig {
pub proxy_protocol: Option<bool>, pub proxy_protocol: Option<bool>,
/// Server-level IP block list — applied at TCP accept, before Noise handshake. /// Server-level IP block list — applied at TCP accept, before Noise handshake.
pub connection_ip_block_list: Option<Vec<String>>, 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. /// Information about a connected client.
@@ -81,6 +126,8 @@ pub struct ClientInfo {
pub registered_client_id: String, pub registered_client_id: String,
/// Real client IP:port (from PROXY protocol header or direct TCP connection). /// Real client IP:port (from PROXY protocol header or direct TCP connection).
pub remote_addr: Option<String>, pub remote_addr: Option<String>,
/// Transport used for this connection: "websocket", "quic", or "wireguard".
pub transport_type: String,
} }
/// Server statistics. /// Server statistics.
@@ -96,6 +143,14 @@ pub struct ServerStatistics {
pub uptime_seconds: u64, pub uptime_seconds: u64,
pub active_clients: u64, pub active_clients: u64,
pub total_connections: 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. /// The forwarding engine determines how decrypted IP packets are routed.
@@ -104,10 +159,28 @@ pub enum ForwardingEngine {
Tun(tokio::io::WriteHalf<tun::AsyncDevice>), Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel. /// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
Socket(mpsc::Sender<Vec<u8>>), Socket(mpsc::Sender<Vec<u8>>),
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
Bridge(mpsc::Sender<Vec<u8>>),
/// Hybrid — both socket NAT and bridge engines running simultaneously.
/// Per-client routing: look up src_ip in routing_table to decide socket vs bridge.
Hybrid {
socket_tx: mpsc::Sender<Vec<u8>>,
bridge_tx: mpsc::Sender<Vec<u8>>,
/// Fast lookup: VPN IP → true if client uses bridge (host IP), false for socket.
routing_table: Arc<RwLock<HashMap<Ipv4Addr, bool>>>,
},
/// Testing/monitoring — packets are counted but not forwarded. /// Testing/monitoring — packets are counted but not forwarded.
Testing, Testing,
} }
/// Info needed to tear down bridge infrastructure on stop().
pub struct BridgeCleanupInfo {
pub physical_iface: String,
pub bridge_name: String,
pub host_ip: Ipv4Addr,
pub host_prefix: u8,
}
/// Shared server state. /// Shared server state.
pub struct ServerState { pub struct ServerState {
pub config: ServerConfig, pub config: ServerConfig,
@@ -124,12 +197,17 @@ pub struct ServerState {
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>, pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine). /// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
pub tun_shutdown: mpsc::Sender<()>, pub tun_shutdown: mpsc::Sender<()>,
/// Shutdown signal for the bridge engine (bridge/hybrid modes only).
pub bridge_shutdown: Option<mpsc::Sender<()>>,
/// Bridge teardown info (bridge/hybrid modes only).
pub bridge_cleanup: Option<BridgeCleanupInfo>,
} }
/// The VPN server. /// The VPN server.
pub struct VpnServer { pub struct VpnServer {
state: Option<Arc<ServerState>>, state: Option<Arc<ServerState>>,
shutdown_tx: Option<mpsc::Sender<()>>, shutdown_tx: Option<mpsc::Sender<()>>,
wg_command_tx: Option<mpsc::Sender<crate::wireguard::WgCommand>>,
} }
impl VpnServer { impl VpnServer {
@@ -137,6 +215,7 @@ impl VpnServer {
Self { Self {
state: None, state: None,
shutdown_tx: None, shutdown_tx: None,
wg_command_tx: None,
} }
} }
@@ -145,7 +224,15 @@ impl VpnServer {
anyhow::bail!("Server is already running"); 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 config.enable_nat.unwrap_or(false) {
if let Err(e) = crate::network::enable_ip_forwarding() { if let Err(e) = crate::network::enable_ip_forwarding() {
@@ -159,7 +246,6 @@ impl VpnServer {
} }
let link_mtu = config.mtu.unwrap_or(1420); let link_mtu = config.mtu.unwrap_or(1420);
let mode = config.forwarding_mode.as_deref().unwrap_or("testing");
let gateway_ip = ip_pool.gateway_addr(); let gateway_ip = ip_pool.gateway_addr();
// Create forwarding engine based on mode // Create forwarding engine based on mode
@@ -174,9 +260,28 @@ impl VpnServer {
packet_rx: mpsc::Receiver<Vec<u8>>, packet_rx: mpsc::Receiver<Vec<u8>>,
shutdown_rx: mpsc::Receiver<()>, 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<()>,
},
Hybrid {
socket_tx: mpsc::Sender<Vec<u8>>,
socket_rx: mpsc::Receiver<Vec<u8>>,
socket_shutdown_rx: mpsc::Receiver<()>,
bridge_tx: mpsc::Sender<Vec<u8>>,
bridge_rx: mpsc::Receiver<Vec<u8>>,
bridge_shutdown_rx: mpsc::Receiver<()>,
tap_device: tun::AsyncDevice,
routing_table: Arc<RwLock<HashMap<Ipv4Addr, bool>>>,
},
Testing, Testing,
} }
let mut bridge_cleanup_info: Option<BridgeCleanupInfo> = None;
let mut bridge_shut_tx: Option<mpsc::Sender<()>> = None;
let (setup, fwd_shutdown_tx) = match mode { let (setup, fwd_shutdown_tx) = match mode {
"tun" => { "tun" => {
let tun_config = TunConfig { let tun_config = TunConfig {
@@ -197,6 +302,88 @@ impl VpnServer {
let (tx, rx) = mpsc::channel::<()>(1); let (tx, rx) = mpsc::channel::<()>(1);
(ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx) (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);
bridge_cleanup_info = Some(BridgeCleanupInfo {
physical_iface: phys_iface,
bridge_name: bridge_name.to_string(),
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)
}
"hybrid" => {
info!("Starting hybrid forwarding (socket + bridge, per-client routing)");
// Socket engine setup
let (s_tx, s_rx) = mpsc::channel::<Vec<u8>>(4096);
let (s_shut_tx, s_shut_rx) = mpsc::channel::<()>(1);
// Bridge engine setup
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";
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?;
let (b_tx, b_rx) = mpsc::channel::<Vec<u8>>(4096);
let (b_shut_tx, b_shut_rx) = mpsc::channel::<()>(1);
// Build routing table from registered clients
let routing_table = Arc::new(RwLock::new(HashMap::<Ipv4Addr, bool>::new()));
info!("Hybrid mode: socket + bridge (TAP={}, physical={}, IP={}/{})", tap_name, phys_iface, host_ip, host_prefix);
bridge_cleanup_info = Some(BridgeCleanupInfo {
physical_iface: phys_iface,
bridge_name: bridge_name.to_string(),
host_ip,
host_prefix,
});
bridge_shut_tx = Some(b_shut_tx);
// Socket engine uses fwd_shutdown_tx (stored in state.tun_shutdown)
(ForwardingSetup::Hybrid {
socket_tx: s_tx, socket_rx: s_rx, socket_shutdown_rx: s_shut_rx,
bridge_tx: b_tx, bridge_rx: b_rx, bridge_shutdown_rx: b_shut_rx,
tap_device, routing_table,
}, s_shut_tx)
}
_ => { _ => {
info!("Forwarding disabled (testing/monitoring mode)"); info!("Forwarding disabled (testing/monitoring mode)");
let (tx, _rx) = mpsc::channel::<()>(1); let (tx, _rx) = mpsc::channel::<()>(1);
@@ -206,7 +393,7 @@ impl VpnServer {
// Compute effective MTU from overhead // Compute effective MTU from overhead
let overhead = TunnelOverhead::default_overhead(); let overhead = TunnelOverhead::default_overhead();
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu)); let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).min(link_mtu));
// Build client registry from config // Build client registry from config
let registry = ClientRegistry::from_entries( let registry = ClientRegistry::from_entries(
@@ -226,6 +413,8 @@ impl VpnServer {
forwarding_engine: Mutex::new(ForwardingEngine::Testing), forwarding_engine: Mutex::new(ForwardingEngine::Testing),
tun_routes: RwLock::new(HashMap::new()), tun_routes: RwLock::new(HashMap::new()),
tun_shutdown: fwd_shutdown_tx, tun_shutdown: fwd_shutdown_tx,
bridge_shutdown: bridge_shut_tx,
bridge_cleanup: bridge_cleanup_info,
}); });
// Spawn the forwarding background task and set the engine // Spawn the forwarding background task and set the engine
@@ -241,10 +430,13 @@ impl VpnServer {
} }
ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => { ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx); *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( let nat_engine = crate::userspace_nat::NatEngine::new(
gateway_ip, gateway_ip,
link_mtu as usize, link_mtu as usize,
state.clone(), state.clone(),
proxy_protocol,
config.destination_policy.clone(),
); );
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await { if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await {
@@ -252,65 +444,161 @@ 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::Hybrid {
socket_tx, socket_rx, socket_shutdown_rx,
bridge_tx, bridge_rx, bridge_shutdown_rx,
tap_device, routing_table,
} => {
// Populate routing table from registered clients
{
let registry = state.client_registry.read().await;
let mut rt = routing_table.write().await;
for entry in registry.list() {
if let Some(ref ip_str) = entry.assigned_ip {
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
rt.insert(ip, entry.use_host_ip.unwrap_or(false));
}
}
}
}
// Start socket (NAT) engine
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(socket_rx, socket_shutdown_rx).await {
error!("NAT engine error (hybrid): {}", e);
}
});
// Start bridge engine
let bridge_engine = crate::bridge::BridgeEngine::new(state.clone());
tokio::spawn(async move {
if let Err(e) = bridge_engine.run(tap_device, bridge_rx, bridge_shutdown_rx).await {
error!("Bridge engine error (hybrid): {}", e);
}
});
*state.forwarding_engine.lock().await = ForwardingEngine::Hybrid {
socket_tx, bridge_tx, routing_table,
};
}
ForwardingSetup::Testing => {} ForwardingSetup::Testing => {}
} }
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
self.state = Some(state.clone()); self.state = Some(state.clone());
self.shutdown_tx = Some(shutdown_tx);
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(); let listen_addr = config.listen_addr.clone();
match transport_mode { // Determine if WG should be included
"quic" => { let include_wg = config.wg_private_key.is_some()
let quic_addr = config.quic_listen_addr.clone().unwrap_or_else(|| listen_addr.clone()); && matches!(transport_mode, "all" | "wireguard");
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);
// Forward combined shutdown to both listeners // Collect shutdown senders for all listeners
tokio::spawn(async move { let mut listener_shutdown_txs: Vec<mpsc::Sender<()>> = Vec::new();
combined_rx.recv().await;
let _ = shutdown_tx_orig.send(()).await;
let _ = shutdown_tx2.send(()).await;
});
tokio::spawn(async move { // Spawn transport listeners based on mode
if let Err(e) = run_ws_listener(state, listen_addr, &mut shutdown_rx).await { let spawn_ws = matches!(transport_mode, "all" | "both" | "websocket");
error!("WebSocket listener error: {}", e); let spawn_quic = matches!(transport_mode, "all" | "both" | "quic");
}
}); if spawn_ws {
tokio::spawn(async move { let (tx, mut rx) = mpsc::channel::<()>(1);
if let Err(e) = run_quic_listener(state2, quic_addr, idle_timeout, &mut shutdown_rx2).await { listener_shutdown_txs.push(tx);
error!("QUIC listener error: {}", e); 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);
// "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);
} 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); 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(()) Ok(())
} }
@@ -330,6 +618,43 @@ impl VpnServer {
let _ = state.tun_shutdown.send(()).await; let _ = state.tun_shutdown.send(()).await;
*state.forwarding_engine.lock().await = ForwardingEngine::Testing; *state.forwarding_engine.lock().await = ForwardingEngine::Testing;
} }
"bridge" => {
let _ = state.tun_shutdown.send(()).await;
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
// Restore host networking: move IP back and remove bridge
if let Some(ref cleanup) = state.bridge_cleanup {
if let Err(e) = crate::bridge::restore_host_ip(
&cleanup.physical_iface, &cleanup.bridge_name,
cleanup.host_ip, cleanup.host_prefix,
).await {
warn!("Failed to restore host IP: {}", e);
}
if let Err(e) = crate::bridge::remove_bridge(&cleanup.bridge_name).await {
warn!("Failed to remove bridge: {}", e);
}
}
}
"hybrid" => {
// Shut down socket (NAT) engine
let _ = state.tun_shutdown.send(()).await;
// Shut down bridge engine
if let Some(ref bridge_shut) = state.bridge_shutdown {
let _ = bridge_shut.send(()).await;
}
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
// Restore host networking: move IP back and remove bridge
if let Some(ref cleanup) = state.bridge_cleanup {
if let Err(e) = crate::bridge::restore_host_ip(
&cleanup.physical_iface, &cleanup.bridge_name,
cleanup.host_ip, cleanup.host_prefix,
).await {
warn!("Failed to restore host IP: {}", e);
}
if let Err(e) = crate::bridge::remove_bridge(&cleanup.bridge_name).await {
warn!("Failed to remove bridge: {}", e);
}
}
}
_ => {} _ => {}
} }
@@ -346,6 +671,7 @@ impl VpnServer {
if let Some(tx) = self.shutdown_tx.take() { if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(()).await; let _ = tx.send(()).await;
} }
self.wg_command_tx = None;
self.state = None; self.state = None;
info!("VPN server stopped"); info!("VPN server stopped");
Ok(()) Ok(())
@@ -366,7 +692,21 @@ impl VpnServer {
if let Some(ref state) = self.state { if let Some(ref state) = self.state {
let mut stats = state.stats.read().await.clone(); let mut stats = state.stats.read().await.clone();
stats.uptime_seconds = state.started_at.elapsed().as_secs(); 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 stats
} else { } else {
ServerStatistics::default() ServerStatistics::default()
@@ -434,6 +774,54 @@ impl VpnServer {
Ok(()) 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 ─────────────────────────────────── // ── Client Registry (Hub) Methods ───────────────────────────────────
/// Create a new client entry. Generates keypairs and assigns an IP. /// Create a new client entry. Generates keypairs and assigns an IP.
@@ -466,21 +854,53 @@ impl VpnServer {
).ok(), ).ok(),
priority: partial.get("priority").and_then(|v| v.as_u64()).map(|v| v as u32), 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)), 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()) 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), 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), expires_at: partial.get("expiresAt").and_then(|v| v.as_str()).map(String::from),
assigned_ip: Some(assigned_ip.to_string()), assigned_ip: Some(assigned_ip.to_string()),
use_host_ip: partial.get("useHostIp").and_then(|v| v.as_bool()),
use_dhcp: partial.get("useDhcp").and_then(|v| v.as_bool()),
static_ip: partial.get("staticIp").and_then(|v| v.as_str()).map(String::from),
force_vlan: partial.get("forceVlan").and_then(|v| v.as_bool()),
vlan_id: partial.get("vlanId").and_then(|v| v.as_u64()).map(|v| v as u16),
}; };
// Add to registry // Add to registry — release IP on failure to avoid pool leak
state.client_registry.write().await.add(entry.clone())?; if let Err(e) = state.client_registry.write().await.add(entry.clone()) {
state.ip_pool.lock().await.release(&assigned_ip);
return Err(e);
}
// 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 // 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!({ let smartvpn_config = serde_json::json!({
"serverUrl": format!("wss://{}", "serverUrl": smartvpn_server_url,
state.config.listen_addr.replace("0.0.0.0", "localhost")),
"serverPublicKey": state.config.public_key, "serverPublicKey": state.config.public_key,
"clientPrivateKey": noise_priv, "clientPrivateKey": noise_priv,
"clientPublicKey": noise_pub, "clientPublicKey": noise_pub,
@@ -490,15 +910,25 @@ impl VpnServer {
}); });
// Build WireGuard config string // 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!( 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, wg_priv,
assigned_ip, assigned_ip,
state.config.dns.as_ref() state.config.dns.as_ref()
.map(|d| format!("DNS = {}", d.join(", "))) .map(|d| format!("DNS = {}", d.join(", ")))
.unwrap_or_default(), .unwrap_or_default(),
state.config.public_key, wg_server_pubkey,
state.config.listen_addr, wg_allowed_ips,
wg_endpoint,
); );
let entry_json = serde_json::to_value(&entry)?; let entry_json = serde_json::to_value(&entry)?;
@@ -519,6 +949,14 @@ impl VpnServer {
let state = self.state.as_ref() let state = self.state.as_ref()
.ok_or_else(|| anyhow::anyhow!("Server not running"))?; .ok_or_else(|| anyhow::anyhow!("Server not running"))?;
let entry = state.client_registry.write().await.remove(client_id)?; 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 // Release the IP if assigned
if let Some(ref ip_str) = entry.assigned_ip { if let Some(ref ip_str) = entry.assigned_ip {
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() { if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
@@ -563,8 +1001,11 @@ impl VpnServer {
if let Some(enabled) = update.get("enabled").and_then(|v| v.as_bool()) { if let Some(enabled) = update.get("enabled").and_then(|v| v.as_bool()) {
entry.enabled = Some(enabled); entry.enabled = Some(enabled);
} }
if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) { if let Some(tags) = update.get("serverDefinedClientTags").and_then(|v| v.as_array()) {
entry.tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect()); 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()) { if let Some(desc) = update.get("description").and_then(|v| v.as_str()) {
entry.description = Some(desc.to_string()); entry.description = Some(desc.to_string());
@@ -602,6 +1043,14 @@ impl VpnServer {
let state = self.state.as_ref() let state = self.state.as_ref()
.ok_or_else(|| anyhow::anyhow!("Server not running"))?; .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 (noise_pub, noise_priv) = crypto::generate_keypair()?;
let (wg_pub, wg_priv) = crate::wireguard::generate_wg_keypair(); let (wg_pub, wg_priv) = crate::wireguard::generate_wg_keypair();
@@ -620,9 +1069,31 @@ impl VpnServer {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("0.0.0.0"); .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!({ let smartvpn_config = serde_json::json!({
"serverUrl": format!("wss://{}", "serverUrl": smartvpn_server_url,
state.config.listen_addr.replace("0.0.0.0", "localhost")),
"serverPublicKey": state.config.public_key, "serverPublicKey": state.config.public_key,
"clientPrivateKey": noise_priv, "clientPrivateKey": noise_priv,
"clientPublicKey": noise_pub, "clientPublicKey": noise_pub,
@@ -631,14 +1102,24 @@ impl VpnServer {
"keepaliveIntervalSecs": state.config.keepalive_interval_secs, "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!( 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, wg_priv, assigned_ip,
state.config.dns.as_ref() state.config.dns.as_ref()
.map(|d| format!("DNS = {}", d.join(", "))) .map(|d| format!("DNS = {}", d.join(", ")))
.unwrap_or_default(), .unwrap_or_default(),
state.config.public_key, wg_server_pubkey,
state.config.listen_addr, wg_allowed_ips,
wg_endpoint,
); );
Ok(serde_json::json!({ Ok(serde_json::json!({
@@ -662,10 +1143,13 @@ impl VpnServer {
match format { match format {
"smartvpn" => { "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!({ Ok(serde_json::json!({
"config": { "config": {
"serverUrl": format!("wss://{}", "serverUrl": smartvpn_server_url,
state.config.listen_addr.replace("0.0.0.0", "localhost")),
"serverPublicKey": state.config.public_key, "serverPublicKey": state.config.public_key,
"clientPublicKey": entry.public_key, "clientPublicKey": entry.public_key,
"dns": state.config.dns, "dns": state.config.dns,
@@ -675,15 +1159,25 @@ impl VpnServer {
})) }))
} }
"wireguard" => { "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 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!( 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, assigned_ip,
state.config.dns.as_ref() state.config.dns.as_ref()
.map(|d| format!("DNS = {}", d.join(", "))) .map(|d| format!("DNS = {}", d.join(", ")))
.unwrap_or_default(), .unwrap_or_default(),
state.config.public_key, wg_server_pubkey,
state.config.listen_addr, wg_allowed_ips,
wg_endpoint,
); );
Ok(serde_json::json!({ "config": config })) Ok(serde_json::json!({ "config": config }))
} }
@@ -751,6 +1245,7 @@ async fn run_ws_listener(
Box::new(sink), Box::new(sink),
Box::new(stream), Box::new(stream),
remote_addr, remote_addr,
"websocket",
).await { ).await {
warn!("Client connection error: {}", e); warn!("Client connection error: {}", e);
} }
@@ -827,6 +1322,7 @@ async fn run_quic_listener(
Box::new(sink), Box::new(sink),
Box::new(stream), Box::new(stream),
Some(remote), Some(remote),
"quic",
).await { ).await {
warn!("QUIC client error: {}", e); warn!("QUIC client error: {}", e);
} }
@@ -916,6 +1412,7 @@ async fn handle_client_connection(
mut sink: Box<dyn TransportSink>, mut sink: Box<dyn TransportSink>,
mut stream: Box<dyn TransportStream>, mut stream: Box<dyn TransportStream>,
remote_addr: Option<std::net::SocketAddr>, remote_addr: Option<std::net::SocketAddr>,
transport_type: &str,
) -> Result<()> { ) -> Result<()> {
let server_private_key = base64::Engine::decode( let server_private_key = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD, &base64::engine::general_purpose::STANDARD,
@@ -1054,6 +1551,7 @@ async fn handle_client_connection(
authenticated_key: client_pub_key_b64.clone(), authenticated_key: client_pub_key_b64.clone(),
registered_client_id: registered_client_id.clone(), registered_client_id: registered_client_id.clone(),
remote_addr: remote_addr.map(|a| a.to_string()), 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); state.clients.write().await.insert(client_id.clone(), client_info);
@@ -1069,6 +1567,11 @@ async fn handle_client_connection(
{ {
let mut stats = state.stats.write().await; let mut stats = state.stats.write().await;
stats.total_connections += 1; 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 // Send assigned IP info (encrypted), include effective MTU
@@ -1169,6 +1672,20 @@ async fn handle_client_connection(
ForwardingEngine::Socket(sender) => { ForwardingEngine::Socket(sender) => {
let _ = sender.try_send(buf[..len].to_vec()); let _ = sender.try_send(buf[..len].to_vec());
} }
ForwardingEngine::Bridge(sender) => {
let _ = sender.try_send(buf[..len].to_vec());
}
ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => {
if len >= 20 {
let src_ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]);
let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false);
if use_bridge {
let _ = bridge_tx.try_send(buf[..len].to_vec());
} else {
let _ = socket_tx.try_send(buf[..len].to_vec());
}
}
}
ForwardingEngine::Testing => {} ForwardingEngine::Testing => {}
} }
} }

View File

@@ -13,9 +13,14 @@ use tokio::net::{TcpStream, UdpSocket};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::server::ServerState; use crate::acl;
use crate::server::{DestinationPolicyConfig, ServerState};
use crate::tunnel; 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 // Virtual IP device for smoltcp
// ============================================================================ // ============================================================================
@@ -100,7 +105,7 @@ impl Device for VirtualIpDevice {
let mut caps = DeviceCapabilities::default(); let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ip; caps.medium = Medium::Ip;
caps.max_transmission_unit = self.mtu; caps.max_transmission_unit = self.mtu;
caps.max_burst_size = Some(1); caps.max_burst_size = None;
caps caps
} }
} }
@@ -120,9 +125,20 @@ struct SessionKey {
struct TcpSession { struct TcpSession {
smoltcp_handle: SocketHandle, 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)] #[allow(dead_code)]
client_ip: Ipv4Addr, 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 { struct UdpSession {
@@ -191,10 +207,25 @@ pub struct NatEngine {
bridge_rx: mpsc::Receiver<BridgeMessage>, bridge_rx: mpsc::Receiver<BridgeMessage>,
bridge_tx: mpsc::Sender<BridgeMessage>, bridge_tx: mpsc::Sender<BridgeMessage>,
start_time: std::time::Instant, 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 { 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 mut device = VirtualIpDevice::new(mtu);
let config = Config::new(HardwareAddress::Ip); let config = Config::new(HardwareAddress::Ip);
let now = smoltcp::time::Instant::from_millis(0); let now = smoltcp::time::Instant::from_millis(0);
@@ -226,6 +257,8 @@ impl NatEngine {
bridge_rx, bridge_rx,
bridge_tx, bridge_tx,
start_time: std::time::Instant::now(), 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) 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. /// Inject a raw IP packet from a VPN client and handle new session creation.
fn inject_packet(&mut self, packet: Vec<u8>) { fn inject_packet(&mut self, packet: Vec<u8>) {
let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else { let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else {
@@ -256,8 +334,17 @@ impl NatEngine {
// SYN without ACK = new connection // SYN without ACK = new connection
let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0; let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0;
if is_syn && !self.tcp_sessions.contains_key(&key) { // Skip if session exists (including closing sessions — let FIN complete)
self.create_tcp_session(&key); 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 => { 17 => {
@@ -274,7 +361,14 @@ impl NatEngine {
}; };
if !self.udp_sessions.contains_key(&key) { 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 // Update last_activity for existing sessions
@@ -291,7 +385,7 @@ impl NatEngine {
self.device.inject_packet(packet); 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 // Create smoltcp TCP socket
let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]); let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
let tcp_tx_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); 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 { let session = TcpSession {
smoltcp_handle: handle, smoltcp_handle: handle,
bridge_data_tx: data_tx, bridge_data_tx: None,
client_ip: key.src_ip, 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); self.tcp_sessions.insert(key.clone(), session);
// Spawn bridge task that connects to the real destination // NOTE: Bridge task is NOT spawned here — it will be spawned in process()
let bridge_tx = self.bridge_tx.clone(); // once the smoltcp handshake completes (socket.is_active() == true).
let key_clone = key.clone(); // This prevents data from the real server arriving before the VPN client
tokio::spawn(async move { // handshake is done, which would cause silent data loss.
tcp_bridge_task(key_clone, data_rx, bridge_tx).await;
});
debug!( debug!(
"NAT: new TCP session {}:{} -> {}:{}", "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 // Create smoltcp UDP socket
let udp_rx_buf = udp::PacketBuffer::new( let udp_rx_buf = udp::PacketBuffer::new(
vec![udp::PacketMetadata::EMPTY; 32], vec![udp::PacketMetadata::EMPTY; 32],
@@ -368,7 +462,7 @@ impl NatEngine {
let bridge_tx = self.bridge_tx.clone(); let bridge_tx = self.bridge_tx.clone();
let key_clone = key.clone(); let key_clone = key.clone();
tokio::spawn(async move { 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!( debug!(
@@ -384,15 +478,69 @@ impl NatEngine {
self.iface self.iface
.poll(now, &mut self.device, &mut self.sockets); .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 // Bridge: read data from smoltcp TCP sockets → send to bridge tasks
let mut closed_tcp: Vec<SessionKey> = Vec::new(); let mut closed_tcp: Vec<SessionKey> = Vec::new();
let mut active_tcp: Vec<SessionKey> = Vec::new();
for (key, session) in &self.tcp_sessions { for (key, session) in &self.tcp_sessions {
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle); let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
if socket.can_recv() { if session.bridge_started && socket.can_recv() {
let _ = socket.recv(|data| { if let Some(ref sender) = session.bridge_data_tx {
let _ = session.bridge_data_tx.try_send(data.to_vec()); // Reserve channel slot BEFORE consuming from smoltcp.
(data.len(), ()) // 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 // Detect closed connections
if !socket.is_open() && !socket.is_listening() { 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 // Clean up closed TCP sessions
for key in closed_tcp { for key in closed_tcp {
if let Some(session) = self.tcp_sessions.remove(&key) { if let Some(session) = self.tcp_sessions.remove(&key) {
@@ -412,7 +568,9 @@ impl NatEngine {
for (_key, session) in &self.udp_sessions { for (_key, session) in &self.udp_sessions {
let socket = self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle); let socket = self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
while let Ok((data, _meta)) = socket.recv() { 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() { for packet in self.device.drain_tx() {
if let Some(std::net::IpAddr::V4(dst_ip)) = tunnel::extract_dst_ip(&packet) { if let Some(std::net::IpAddr::V4(dst_ip)) = tunnel::extract_dst_ip(&packet) {
if let Some(sender) = routes.get(&dst_ip) { 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) { fn handle_bridge_message(&mut self, msg: BridgeMessage) {
match msg { match msg {
BridgeMessage::TcpData { key, data } => { 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 = let socket =
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle); self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
if socket.can_send() { if socket.can_send() && !session.pending_send.is_empty() {
let _ = socket.send_slice(&data); 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 } => { BridgeMessage::TcpClosed { key } => {
if let Some(session) = self.tcp_sessions.remove(&key) { if let Some(session) = self.tcp_sessions.get_mut(&key) {
let socket = let socket =
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle); self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
socket.close(); socket.close();
session.closing = true;
// Don't remove from SocketSet yet — let smoltcp send FIN // Don't remove from SocketSet yet — let smoltcp send FIN
// It will be cleaned up in process() when is_open() returns false // It will be cleaned up in process() when is_open() returns false
self.tcp_sessions.insert(key, session);
} }
} }
BridgeMessage::UdpData { key, data } => { 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. /// Main async event loop for the NAT engine.
pub async fn run( pub async fn run(
mut self, mut self,
@@ -492,9 +696,13 @@ impl NatEngine {
mut shutdown_rx: mpsc::Receiver<()>, mut shutdown_rx: mpsc::Receiver<()>,
) -> Result<()> { ) -> Result<()> {
info!("Userspace NAT engine started"); 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)); 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 { loop {
tokio::select! { tokio::select! {
Some(packet) = packet_rx.recv() => { Some(packet) = packet_rx.recv() => {
@@ -505,18 +713,26 @@ impl NatEngine {
self.handle_bridge_message(msg); self.handle_bridge_message(msg);
self.process().await; self.process().await;
} }
_ = timer.tick() => { () = &mut poll_sleep => {
// Periodic poll for smoltcp maintenance (TCP retransmit, etc.) // Periodic poll for smoltcp maintenance (TCP retransmit, etc.)
self.process().await; self.process().await;
} }
_ = cleanup_timer.tick() => { _ = cleanup_timer.tick() => {
self.cleanup_idle_udp_sessions(); self.cleanup_idle_udp_sessions();
self.cleanup_idle_tcp_sessions();
} }
_ = shutdown_rx.recv() => { _ = shutdown_rx.recv() => {
info!("Userspace NAT engine shutting down"); info!("Userspace NAT engine shutting down");
break; 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(()) Ok(())
@@ -531,20 +747,20 @@ async fn tcp_bridge_task(
key: SessionKey, key: SessionKey,
mut data_rx: mpsc::Receiver<Vec<u8>>, mut data_rx: mpsc::Receiver<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>, bridge_tx: mpsc::Sender<BridgeMessage>,
proxy_protocol: bool,
connect_addr: SocketAddr,
) { ) {
let addr = SocketAddr::new(key.dst_ip.into(), key.dst_port); // 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
// Connect to real destination with timeout
let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr)).await
{ {
Ok(Ok(s)) => s, Ok(Ok(s)) => s,
Ok(Err(e)) => { 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; let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
return; return;
} }
Err(_) => { 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; let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
return; return;
} }
@@ -552,6 +768,18 @@ async fn tcp_bridge_task(
let (mut reader, mut writer) = stream.into_split(); 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 // Read from real socket → send to NAT engine
let bridge_tx2 = bridge_tx.clone(); let bridge_tx2 = bridge_tx.clone();
let key2 = key.clone(); let key2 = key.clone();
@@ -594,6 +822,7 @@ async fn udp_bridge_task(
key: SessionKey, key: SessionKey,
mut data_rx: mpsc::Receiver<Vec<u8>>, mut data_rx: mpsc::Receiver<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>, bridge_tx: mpsc::Sender<BridgeMessage>,
connect_addr: SocketAddr,
) { ) {
let socket = match UdpSocket::bind("0.0.0.0:0").await { let socket = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s, Ok(s) => s,
@@ -602,7 +831,7 @@ async fn udp_bridge_task(
return; return;
} }
}; };
let dest = SocketAddr::new(key.dst_ip.into(), key.dst_port); let dest = connect_addr;
let socket = Arc::new(socket); let socket = Arc::new(socket);
let socket2 = socket.clone(); let socket2 = socket.clone();

File diff suppressed because it is too large Load Diff

View File

@@ -211,8 +211,8 @@ tap.test('throttled connection: handshake succeeds through throttle', async () =
}); });
tap.test('sustained keepalive under throttle', async () => { tap.test('sustained keepalive under throttle', async () => {
// Wait for at least 2 keepalive cycles (3s interval) // Wait for at least 1 keepalive cycle (3s interval)
await delay(8000); await delay(4000);
const client = allClients[0]; const client = allClients[0];
const stats = await client.getStatistics(); const stats = await client.getStatistics();
@@ -262,14 +262,14 @@ tap.test('rate limiting combined with network throttle', async () => {
await server.removeClientRateLimit(targetId); await server.removeClientRateLimit(targetId);
}); });
tap.test('burst waves: 3 waves of 3 clients', async () => { tap.test('burst waves: 2 waves of 2 clients', async () => {
const initialCount = (await server.listClients()).length; const initialCount = (await server.listClients()).length;
for (let wave = 0; wave < 3; wave++) { for (let wave = 0; wave < 2; wave++) {
const waveClients: VpnClient[] = []; const waveClients: VpnClient[] = [];
// Connect 3 clients // Connect 2 clients
for (let i = 0; i < 3; i++) { for (let i = 0; i < 2; i++) {
const c = await createConnectedClient(proxyPort); const c = await createConnectedClient(proxyPort);
waveClients.push(c); waveClients.push(c);
} }
@@ -277,7 +277,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
// Verify all connected // Verify all connected
await waitFor(async () => { await waitFor(async () => {
const all = await server.listClients(); const all = await server.listClients();
return all.length === initialCount + 3; return all.length === initialCount + 2;
}); });
// Disconnect all wave clients // Disconnect all wave clients
@@ -296,7 +296,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
// Verify total connections accumulated // Verify total connections accumulated
const stats = await server.getStatistics(); const stats = await server.getStatistics();
expect(stats.totalConnections).toBeGreaterThanOrEqual(9 + initialCount); expect(stats.totalConnections).toBeGreaterThanOrEqual(4 + initialCount);
// Original clients still connected // Original clients still connected
const remaining = await server.listClients(); const remaining = await server.listClients();
@@ -315,7 +315,7 @@ tap.test('aggressive throttle: 10 KB/s', async () => {
expect(status.state).toEqual('connected'); expect(status.state).toEqual('connected');
// Wait for keepalive exchange (might take longer due to throttle) // Wait for keepalive exchange (might take longer due to throttle)
await delay(10000); await delay(4000);
const stats = await client.getStatistics(); const stats = await client.getStatistics();
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1); expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
@@ -332,7 +332,7 @@ tap.test('post-load health: direct connection still works', async () => {
const status = await directClient.getStatus(); const status = await directClient.getStatus();
expect(status.state).toEqual('connected'); expect(status.state).toEqual('connected');
await delay(5000); await delay(3500);
const stats = await directClient.getStatistics(); const stats = await directClient.getStatistics();
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1); expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvpn', name: '@push.rocks/smartvpn',
version: '1.10.0', version: '1.19.2',
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon' description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
} }

View File

@@ -12,6 +12,7 @@ import type {
IWgPeerInfo, IWgPeerInfo,
IClientEntry, IClientEntry,
IClientConfigBundle, IClientConfigBundle,
IDestinationPolicy,
TVpnServerCommands, TVpnServerCommands,
} from './smartvpn.interfaces.js'; } from './smartvpn.interfaces.js';
@@ -21,6 +22,10 @@ import type {
export class VpnServer extends plugins.events.EventEmitter { export class VpnServer extends plugins.events.EventEmitter {
private bridge: VpnBridge<TVpnServerCommands>; private bridge: VpnBridge<TVpnServerCommands>;
private options: IVpnServerOptions; private options: IVpnServerOptions;
private nft?: plugins.smartnftables.SmartNftables;
private nftHealthInterval?: ReturnType<typeof setInterval>;
private nftSubnet?: string;
private nftPolicy?: IDestinationPolicy;
constructor(options: IVpnServerOptions) { constructor(options: IVpnServerOptions) {
super(); super();
@@ -50,6 +55,11 @@ export class VpnServer extends plugins.events.EventEmitter {
const cfg = config || this.options.config; const cfg = config || this.options.config;
if (cfg) { if (cfg) {
await this.bridge.sendCommand('start', { config: 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,11 +239,129 @@ export class VpnServer extends plugins.events.EventEmitter {
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>); 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. * Stop the daemon bridge.
*/ */
public stop(): void { public async stop(): Promise<void> {
// Clean up nftables rules
if (this.nftHealthInterval) {
clearInterval(this.nftHealthInterval);
this.nftHealthInterval = undefined;
}
if (this.nft) {
try {
await this.nft.cleanup();
} catch (e) {
console.warn(`[smartvpn] nftables cleanup failed: ${e}`);
}
this.nft = undefined;
}
// Wait for bridge process to exit (with timeout)
const exitPromise = new Promise<void>((resolve) => {
if (!this.bridge.running) {
resolve();
return;
}
const timeout = setTimeout(() => resolve(), 5000);
this.bridge.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
this.bridge.stop(); this.bridge.stop();
await exitPromise;
} }
/** /**

View File

@@ -57,6 +57,8 @@ export interface IVpnClientConfig {
wgEndpoint?: string; wgEndpoint?: string;
/** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */ /** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */
wgAllowedIps?: string[]; wgAllowedIps?: string[];
/** Client-defined tags reported to the server after connection (informational, not for access control) */
clientDefinedClientTags?: string[];
} }
export interface IVpnClientOptions { export interface IVpnClientOptions {
@@ -90,18 +92,22 @@ export interface IVpnServerConfig {
/** Enable NAT/masquerade for client traffic */ /** Enable NAT/masquerade for client traffic */
enableNat?: boolean; enableNat?: boolean;
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT), /** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
* 'bridge' (L2 bridge to host LAN), 'hybrid' (per-client socket+bridge),
* or 'testing' (monitoring only). Default: 'testing'. */ * or 'testing' (monitoring only). Default: 'testing'. */
forwardingMode?: 'tun' | 'socket' | 'testing'; forwardingMode?: 'tun' | 'socket' | 'bridge' | 'hybrid' | 'testing';
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */ /** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
defaultRateLimitBytesPerSec?: number; defaultRateLimitBytesPerSec?: number;
/** Default burst size for new clients (bytes). Omit for unlimited. */ /** Default burst size for new clients (bytes). Omit for unlimited. */
defaultBurstBytes?: number; defaultBurstBytes?: number;
/** Transport mode: 'both' (default, WS+QUIC), 'websocket', 'quic', or 'wireguard' */ /** Transport mode: 'all' (default, WS+QUIC+WG if configured), 'both' (WS+QUIC),
transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard'; * 'websocket', 'quic', or 'wireguard' */
transportMode?: 'websocket' | 'quic' | 'both' | 'all' | 'wireguard';
/** QUIC listen address (host:port). Defaults to listenAddr. */ /** QUIC listen address (host:port). Defaults to listenAddr. */
quicListenAddr?: string; quicListenAddr?: string;
/** QUIC idle timeout in seconds (default: 30) */ /** QUIC idle timeout in seconds (default: 30) */
quicIdleTimeoutSecs?: number; quicIdleTimeoutSecs?: number;
/** WireGuard: server X25519 private key (base64). Required when transport includes WG. */
wgPrivateKey?: string;
/** WireGuard: UDP listen port (default: 51820) */ /** WireGuard: UDP listen port (default: 51820) */
wgListenPort?: number; wgListenPort?: number;
/** WireGuard: configured peers */ /** WireGuard: configured peers */
@@ -115,6 +121,56 @@ export interface IVpnServerConfig {
/** Server-level IP block list — applied at TCP accept, before Noise handshake. /** Server-level IP block list — applied at TCP accept, before Noise handshake.
* Supports exact IPs, CIDR, wildcards, ranges. */ * Supports exact IPs, CIDR, wildcards, ranges. */
connectionIpBlockList?: string[]; 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 { export interface IVpnServerOptions {
@@ -171,11 +227,21 @@ export interface IVpnClientInfo {
registeredClientId: string; registeredClientId: string;
/** Real client IP:port (from PROXY protocol or direct TCP connection) */ /** Real client IP:port (from PROXY protocol or direct TCP connection) */
remoteAddr?: string; remoteAddr?: string;
/** Transport used: "websocket", "quic", or "wireguard" */
transportType: string;
} }
export interface IVpnServerStatistics extends IVpnStatistics { export interface IVpnServerStatistics extends IVpnStatistics {
activeClients: number; activeClients: number;
totalConnections: 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 { export interface IVpnKeypair {
@@ -261,6 +327,10 @@ export interface IClientSecurity {
maxConnections?: number; maxConnections?: number;
/** Per-client rate limiting. */ /** Per-client rate limiting. */
rateLimit?: IClientRateLimit; 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 +350,11 @@ export interface IClientEntry {
priority?: number; priority?: number;
/** Whether this client is enabled (default: true) */ /** Whether this client is enabled (default: true) */
enabled?: boolean; 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[]; tags?: string[];
/** Optional description */ /** Optional description */
description?: string; description?: string;
@@ -288,6 +362,21 @@ export interface IClientEntry {
expiresAt?: string; expiresAt?: string;
/** Assigned VPN IP address (set by server) */ /** Assigned VPN IP address (set by server) */
assignedIp?: string; assignedIp?: string;
// Per-client bridge/host-IP settings
/** If true, client gets a host network IP via bridge mode (L2 to LAN).
* If false (default), client gets a VPN subnet IP via socket/NAT mode. */
useHostIp?: boolean;
/** If true and useHostIp is true, obtain IP via DHCP relay.
* If false or omitted, use staticIp or auto-assign from bridge IP range. */
useDhcp?: boolean;
/** Static LAN IP when useHostIp is true and useDhcp is false. */
staticIp?: string;
/** If true, assign this client to a specific 802.1Q VLAN on the bridge. */
forceVlan?: boolean;
/** 802.1Q VLAN ID (1-4094). Required when forceVlan is true. */
vlanId?: number;
} }
/** /**

View File

@@ -8,7 +8,8 @@ import * as events from 'events';
export { path, fs, os, url, events }; export { path, fs, os, url, events };
// @push.rocks // @push.rocks
import * as smartnftables from '@push.rocks/smartnftables';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartrust from '@push.rocks/smartrust'; import * as smartrust from '@push.rocks/smartrust';
export { smartpath, smartrust }; export { smartnftables, smartpath, smartrust };