Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 773eb6426e | |||
| c520220df2 | |||
| f8bdb991c8 | |||
| d4bad38908 | |||
| a293986d6d | |||
| 96a3159c5d | |||
| 3f40506246 | |||
| 180282ba86 |
+13
-7
@@ -26,11 +26,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"registries": [
|
"targets": {
|
||||||
"https://verdaccio.lossless.digital",
|
"npm": {
|
||||||
"https://registry.npmjs.org"
|
"registries": [
|
||||||
],
|
"https://verdaccio.lossless.digital",
|
||||||
"accessLevel": "public"
|
"https://registry.npmjs.org"
|
||||||
}
|
],
|
||||||
}
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemaVersion": 2
|
||||||
|
},
|
||||||
|
"@ship.zone/szci": {}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-12 - 1.19.3
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- update release config schema, bump dependencies, and refresh runtime documentation (release)
|
||||||
|
- migrates .smartconfig.json release settings to the targets-based schema with schemaVersion 2
|
||||||
|
- bumps runtime and development dependencies including smartnftables and smartrust
|
||||||
|
- clarifies README details for custom Rust binaries, transport behavior, runtime events, and protocol frame types
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-31 - 1.18.0 - feat(server)
|
||||||
add bridge forwarding mode and per-client destination policy overrides
|
add bridge forwarding mode and per-client destination policy overrides
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.18.0",
|
"version": "1.19.3",
|
||||||
"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",
|
||||||
@@ -29,16 +29,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartnftables": "1.1.0",
|
"@push.rocks/smartnftables": "1.2.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartrust": "^1.3.2"
|
"@push.rocks/smartrust": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.4.0",
|
"@git.zone/tsbuild": "^4.4.1",
|
||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.4",
|
||||||
"@git.zone/tsrust": "^1.3.2",
|
"@git.zone/tsrust": "^1.3.4",
|
||||||
"@git.zone/tstest": "^3.6.3",
|
"@git.zone/tstest": "^3.6.6",
|
||||||
"@types/node": "^25.5.0"
|
"@types/node": "^25.7.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
|||||||
Generated
+2134
-2808
File diff suppressed because it is too large
Load Diff
@@ -2,17 +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)
|
||||||
📊 **Per-transport metrics**: active clients and total connections broken down by websocket, QUIC, and WireGuard
|
- 📊 **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, or testing mode
|
- 🌐 **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
|
- 🏠 **Bridge mode**: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network
|
||||||
🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration
|
- 🔀 **Hybrid mode**: per-client routing — some clients bridge to the LAN, others use userspace NAT, all on the same server
|
||||||
⚡ **Handshake-driven WireGuard state**: peers appear as "connected" only after a successful WireGuard handshake, and auto-disconnect on idle timeout
|
- 🏷️ **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
|
||||||
|
|
||||||
@@ -22,11 +24,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @push.rocks/smartvpn
|
pnpm install @push.rocks/smartvpn
|
||||||
# or
|
|
||||||
npm install @push.rocks/smartvpn
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The package ships with pre-compiled Rust binaries for **linux/amd64** and **linux/arm64**. No Rust toolchain required at runtime.
|
The package ships with pre-compiled Rust binaries for **linux/amd64** and **linux/arm64**. No Rust toolchain is required at runtime. Set `SMARTVPN_RUST_BINARY` if you want the TypeScript bridge to use a custom daemon binary.
|
||||||
|
|
||||||
## Architecture 🏗️
|
## Architecture 🏗️
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
|
|||||||
│ TypeScript Control Plane │ ◄─────────────────────► │ Rust Data Plane Daemon │
|
│ TypeScript Control Plane │ ◄─────────────────────► │ Rust Data Plane Daemon │
|
||||||
│ │ stdio or Unix sock │ │
|
│ │ stdio or Unix sock │ │
|
||||||
│ VpnServer / VpnClient │ │ Noise IK handshake │
|
│ VpnServer / VpnClient │ │ Noise IK handshake │
|
||||||
│ Typed IPC commands │ │ XChaCha20-Poly1305 │
|
│ Typed IPC commands │ │ Noise transport encryption │
|
||||||
│ 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 │
|
||||||
@@ -43,7 +43,7 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
|
|||||||
└──────────────────────────────┘ └───────────────────────────────┘
|
└──────────────────────────────┘ └───────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**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 the hot path with async I/O, framed packet codecs, and the Noise transport state after authentication.
|
||||||
|
|
||||||
### IPC Transport Modes
|
### IPC Transport Modes
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ await server.start({
|
|||||||
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: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
|
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
|
||||||
forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'testing'
|
forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'hybrid' | 'testing'
|
||||||
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
|
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'],
|
||||||
@@ -140,7 +140,7 @@ 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 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).
|
The server runs with `transportMode: 'all'` by default: WebSocket and QUIC are enabled, and WireGuard joins the same server when `wgPrivateKey` is configured. All server transports share the same forwarding pipeline (`ForwardingEngine`), IP pool, client registry, and statistics, so WireGuard peers can use the same userspace NAT, bridge/hybrid routing, and monitoring model as WS/QUIC clients. Native SmartVPN clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS).
|
||||||
|
|
||||||
### 📊 Per-Transport Metrics
|
### 📊 Per-Transport Metrics
|
||||||
|
|
||||||
@@ -267,13 +267,14 @@ await server.start({
|
|||||||
|
|
||||||
### 📦 Packet Forwarding Modes
|
### 📦 Packet Forwarding Modes
|
||||||
|
|
||||||
SmartVPN supports four forwarding modes, configurable per-server and per-client:
|
SmartVPN supports five forwarding modes, configurable per-server:
|
||||||
|
|
||||||
| Mode | Flag | Description | Root Required |
|
| Mode | Flag | Description | Root Required |
|
||||||
|------|------|-------------|---------------|
|
|------|------|-------------|---------------|
|
||||||
| **TUN** | `'tun'` | Kernel TUN device — real packet forwarding with system routing | ✅ Yes |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No |
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -294,6 +295,13 @@ await server.start({
|
|||||||
bridgeIpRangeEnd: 250,
|
bridgeIpRangeEnd: 250,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Server with hybrid mode — per-client routing
|
||||||
|
await server.start({
|
||||||
|
// ...
|
||||||
|
forwardingMode: 'hybrid',
|
||||||
|
bridgePhysicalInterface: 'eth0', // for bridge clients
|
||||||
|
});
|
||||||
|
|
||||||
// Client with TUN device
|
// Client with TUN device
|
||||||
const { assignedIp } = await client.connect({
|
const { assignedIp } = await client.connect({
|
||||||
// ...
|
// ...
|
||||||
@@ -305,6 +313,52 @@ The **userspace NAT** mode extracts destination IP/port from IP packets, opens a
|
|||||||
|
|
||||||
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 **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`)
|
||||||
@@ -425,37 +479,23 @@ const unit = VpnInstaller.generateServiceUnit({
|
|||||||
|
|
||||||
You can also call `generateSystemdUnit()` or `generateLaunchdPlist()` directly for platform-specific options like custom descriptions.
|
You can also call `generateSystemdUnit()` or `generateLaunchdPlist()` directly for platform-specific options like custom descriptions.
|
||||||
|
|
||||||
### 📢 Events
|
### 📢 Runtime Events
|
||||||
|
|
||||||
Both `VpnServer` and `VpnClient` extend `EventEmitter` and emit typed events:
|
`VpnServer` and `VpnClient` extend `EventEmitter`. The high-level wrappers currently forward bridge lifecycle events:
|
||||||
|
|
||||||
```typescript
|
```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('exit', ({ code, signal }) => { /* daemon process exited */ });
|
||||||
server.on('reconnected', () => { /* socket transport reconnected */ });
|
server.on('reconnected', () => { /* socket transport reconnected */ });
|
||||||
|
client.on('exit', ({ code, signal }) => { /* daemon process exited */ });
|
||||||
```
|
```
|
||||||
|
|
||||||
| Event | Emitted By | Payload |
|
| 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 |
|
| `exit` | Both | `{ code, signal }` — daemon process exited |
|
||||||
| `reconnected` | Both | `void` — socket transport reconnected |
|
| `reconnected` | Both | `void` — socket transport reconnected |
|
||||||
|
|
||||||
|
For connection state and telemetry, use `getStatus()`, `getStatistics()`, `listClients()`, and `getClientTelemetry()`.
|
||||||
|
|
||||||
## API Reference 📖
|
## API Reference 📖
|
||||||
|
|
||||||
### Classes
|
### Classes
|
||||||
@@ -473,9 +513,9 @@ server.on('reconnected', () => { /* socket transport reconnected */ });
|
|||||||
|
|
||||||
| Interface | Purpose |
|
| Interface | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge, clients, proxy protocol, destination policy) |
|
| `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, forwarding mode, WG options, client-defined tags) |
|
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options, client-defined tags) |
|
||||||
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry) |
|
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry, bridge/VLAN settings) |
|
||||||
| `IClientSecurity` | Per-client ACLs, rate limits, and destination policy override (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()` — includes SmartVPN config, WireGuard .conf, and secrets |
|
| `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets |
|
||||||
@@ -485,7 +525,7 @@ server.on('reconnected', () => { /* socket transport reconnected */ });
|
|||||||
| `IVpnMtuInfo` | TUN MTU, effective MTU, overhead bytes, oversized packet stats |
|
| `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) |
|
| `IDestinationPolicy` | Destination routing policy (forceTarget / block / allow with allow/block lists) |
|
||||||
| `IVpnEventMap` | Typed event map for server and client EventEmitter |
|
| `IVpnEventMap` | Exported event payload shapes for lifecycle and daemon event integrations |
|
||||||
|
|
||||||
### Server IPC Commands
|
### Server IPC Commands
|
||||||
|
|
||||||
@@ -544,8 +584,8 @@ All transport modes share the same `forwardingMode` — WireGuard peers can use
|
|||||||
// Explicit QUIC with certificate pinning
|
// Explicit QUIC with certificate pinning
|
||||||
{ transport: 'quic', serverUrl: '1.2.3.4:4433', serverCertHash: '<sha256-base64>' }
|
{ transport: 'quic', serverUrl: '1.2.3.4:4433', serverCertHash: '<sha256-base64>' }
|
||||||
|
|
||||||
// WireGuard
|
// WireGuard clients use the standard .conf returned by createClient()
|
||||||
{ transport: 'wireguard', wgPrivateKey: '...', wgEndpoint: 'vpn.example.com:51820', ... }
|
// or generated via WgConfigGenerator.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cryptography 🔑
|
## Cryptography 🔑
|
||||||
@@ -554,7 +594,7 @@ All transport modes share the same `forwardingMode` — WireGuard peers can use
|
|||||||
|-------|-----------|---------|
|
|-------|-----------|---------|
|
||||||
| **Handshake** | Noise IK (X25519 + ChaChaPoly + BLAKE2s) | Mutual authentication + key exchange |
|
| **Handshake** | Noise IK (X25519 + ChaChaPoly + BLAKE2s) | Mutual authentication + key exchange |
|
||||||
| **Transport** | Noise transport state (ChaChaPoly) | All post-handshake data encryption |
|
| **Transport** | Noise transport state (ChaChaPoly) | All post-handshake data encryption |
|
||||||
| **Additional** | XChaCha20-Poly1305 | Extended nonce space for data-at-rest |
|
| **Utility** | XChaCha20-Poly1305 helper | Nonce-safe symmetric encryption helper in the Rust crypto module |
|
||||||
| **WireGuard** | X25519 + ChaCha20-Poly1305 (via boringtun) | Standard WireGuard crypto |
|
| **WireGuard** | X25519 + ChaCha20-Poly1305 (via boringtun) | Standard WireGuard crypto |
|
||||||
|
|
||||||
## Binary Protocol 📡
|
## Binary Protocol 📡
|
||||||
@@ -568,6 +608,9 @@ All frames use `[type:1B][length:4B][payload:NB]` with a 64KB max payload:
|
|||||||
| IpPacket | `0x10` | Bidirectional | Encrypted tunnel data |
|
| IpPacket | `0x10` | Bidirectional | Encrypted tunnel data |
|
||||||
| Keepalive | `0x20` | Client → Server | App-level keepalive (not WS ping) |
|
| Keepalive | `0x20` | Client → Server | App-level keepalive (not WS ping) |
|
||||||
| KeepaliveAck | `0x21` | Server → Client | Keepalive response with RTT payload |
|
| KeepaliveAck | `0x21` | Server → Client | Keepalive response with RTT payload |
|
||||||
|
| SessionResume | `0x30` | Client → Server | Session resume attempt |
|
||||||
|
| SessionResumeOk | `0x31` | Server → Client | Session resume accepted |
|
||||||
|
| SessionResumeErr | `0x32` | Server → Client | Session resume rejected |
|
||||||
| Disconnect | `0x3F` | Bidirectional | Graceful disconnect |
|
| Disconnect | `0x3F` | Bidirectional | Graceful disconnect |
|
||||||
|
|
||||||
## Development 🛠️
|
## Development 🛠️
|
||||||
@@ -618,6 +661,7 @@ smartvpn/
|
|||||||
│ ├── transport_trait.rs # Transport abstraction (Sink/Stream)
|
│ ├── 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)
|
||||||
|
│ ├── bridge.rs # Linux bridge/TAP integration
|
||||||
│ ├── 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
|
||||||
@@ -635,7 +679,7 @@ smartvpn/
|
|||||||
|
|
||||||
## 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) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license.md](./license.md) 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.
|
||||||
|
|
||||||
|
|||||||
+45
-1
@@ -13,7 +13,7 @@ use std::net::Ipv4Addr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
@@ -225,6 +225,50 @@ pub async fn enable_proxy_arp(iface: &str) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Create a TAP device (L2) using the tun crate.
|
||||||
pub fn create_tap(name: &str, mtu: u16) -> Result<tun::AsyncDevice> {
|
pub fn create_tap(name: &str, mtu: u16) -> Result<tun::AsyncDevice> {
|
||||||
let mut config = tun::Configuration::default();
|
let mut config = tun::Configuration::default();
|
||||||
|
|||||||
@@ -60,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 {
|
||||||
@@ -236,6 +249,11 @@ mod tests {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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>,
|
||||||
|
|||||||
+194
-3
@@ -161,10 +161,26 @@ pub enum ForwardingEngine {
|
|||||||
Socket(mpsc::Sender<Vec<u8>>),
|
Socket(mpsc::Sender<Vec<u8>>),
|
||||||
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
|
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
|
||||||
Bridge(mpsc::Sender<Vec<u8>>),
|
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,
|
||||||
@@ -181,6 +197,10 @@ 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.
|
||||||
@@ -246,9 +266,22 @@ impl VpnServer {
|
|||||||
tap_device: tun::AsyncDevice,
|
tap_device: tun::AsyncDevice,
|
||||||
shutdown_rx: mpsc::Receiver<()>,
|
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 {
|
||||||
@@ -292,10 +325,65 @@ impl VpnServer {
|
|||||||
|
|
||||||
info!("Bridge {} created: TAP={}, physical={}, IP={}/{}", bridge_name, tap_name, phys_iface, host_ip, host_prefix);
|
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 (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||||
let (tx, rx) = mpsc::channel::<()>(1);
|
let (tx, rx) = mpsc::channel::<()>(1);
|
||||||
(ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx)
|
(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);
|
||||||
@@ -305,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(
|
||||||
@@ -325,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
|
||||||
@@ -363,6 +453,51 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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 => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,10 +867,18 @@ impl VpnServer {
|
|||||||
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)
|
// Register WG peer with the running WG listener (if active)
|
||||||
if self.wg_command_tx.is_some() {
|
if self.wg_command_tx.is_some() {
|
||||||
@@ -1495,6 +1675,17 @@ async fn handle_client_connection(
|
|||||||
ForwardingEngine::Bridge(sender) => {
|
ForwardingEngine::Bridge(sender) => {
|
||||||
let _ = sender.try_send(buf[..len].to_vec());
|
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 => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -319,10 +319,12 @@ fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: use the first IPv4 address from any prefix length
|
// Fallback: use the first non-unspecified IPv4 address from any prefix length
|
||||||
for aip in allowed_ips {
|
for aip in allowed_ips {
|
||||||
if let IpAddr::V4(v4) = aip.addr {
|
if let IpAddr::V4(v4) = aip.addr {
|
||||||
return Some(v4);
|
if !v4.is_unspecified() {
|
||||||
|
return Some(v4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -579,6 +581,17 @@ pub async fn run_wg_listener(
|
|||||||
ForwardingEngine::Bridge(sender) => {
|
ForwardingEngine::Bridge(sender) => {
|
||||||
let _ = sender.try_send(packet.to_vec());
|
let _ = sender.try_send(packet.to_vec());
|
||||||
}
|
}
|
||||||
|
ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => {
|
||||||
|
if packet.len() >= 20 {
|
||||||
|
let src_ip = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
|
||||||
|
let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false);
|
||||||
|
if use_bridge {
|
||||||
|
let _ = bridge_tx.try_send(packet.to_vec());
|
||||||
|
} else {
|
||||||
|
let _ = socket_tx.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ForwardingEngine::Testing => {}
|
ForwardingEngine::Testing => {}
|
||||||
}
|
}
|
||||||
peer.stats.bytes_received += pkt_len;
|
peer.stats.bytes_received += pkt_len;
|
||||||
@@ -614,6 +627,17 @@ pub async fn run_wg_listener(
|
|||||||
ForwardingEngine::Bridge(sender) => {
|
ForwardingEngine::Bridge(sender) => {
|
||||||
let _ = sender.try_send(packet.to_vec());
|
let _ = sender.try_send(packet.to_vec());
|
||||||
}
|
}
|
||||||
|
ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => {
|
||||||
|
if packet.len() >= 20 {
|
||||||
|
let src_ip = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
|
||||||
|
let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false);
|
||||||
|
if use_bridge {
|
||||||
|
let _ = bridge_tx.try_send(packet.to_vec());
|
||||||
|
} else {
|
||||||
|
let _ = socket_tx.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ForwardingEngine::Testing => {}
|
ForwardingEngine::Testing => {}
|
||||||
}
|
}
|
||||||
peer.stats.bytes_received += pkt_len;
|
peer.stats.bytes_received += pkt_len;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.18.0',
|
version: '1.19.3',
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,17 +333,35 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Stop the daemon bridge.
|
* Stop the daemon bridge.
|
||||||
*/
|
*/
|
||||||
public stop(): void {
|
public async stop(): Promise<void> {
|
||||||
// Clean up nftables rules
|
// Clean up nftables rules
|
||||||
if (this.nftHealthInterval) {
|
if (this.nftHealthInterval) {
|
||||||
clearInterval(this.nftHealthInterval);
|
clearInterval(this.nftHealthInterval);
|
||||||
this.nftHealthInterval = undefined;
|
this.nftHealthInterval = undefined;
|
||||||
}
|
}
|
||||||
if (this.nft) {
|
if (this.nft) {
|
||||||
this.nft.cleanup().catch(() => {}); // best-effort cleanup
|
try {
|
||||||
|
await this.nft.cleanup();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[smartvpn] nftables cleanup failed: ${e}`);
|
||||||
|
}
|
||||||
this.nft = undefined;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ 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' | 'bridge' | '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. */
|
||||||
@@ -361,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user