16 Commits

Author SHA1 Message Date
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
13 changed files with 831 additions and 90 deletions

View File

@@ -1,5 +1,62 @@
# Changelog # Changelog
## 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) ## 2026-03-30 - 1.12.0 - feat(server)
add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvpn", "name": "@push.rocks/smartvpn",
"version": "1.12.0", "version": "1.16.4",
"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,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

190
readme.md
View File

@@ -10,6 +10,7 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust
🔄 **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), or testing mode 🌐 **Unified forwarding pipeline**: all transports share the same engine — TUN (kernel), userspace NAT (no root), or testing mode
🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration
## Issue Reporting and Security ## Issue Reporting and Security
@@ -36,11 +37,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)
@@ -54,8 +82,8 @@ 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: 'all', // WebSocket + QUIC + WireGuard simultaneously (default) transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing' forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or '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'],
@@ -67,7 +95,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
@@ -155,6 +183,47 @@ 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.
### 🔗 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 ### 📦 Packet Forwarding Modes
SmartVPN supports three forwarding modes, configurable per-server and per-client: SmartVPN supports three forwarding modes, configurable per-server and per-client:
@@ -190,6 +259,30 @@ The userspace NAT mode extracts destination IP/port from IP packets, opens a rea
- **Dead-peer detection**: 180s inactivity timeout - **Dead-peer detection**: 180s inactivity timeout
- **MTU management**: Automatic overhead calculation (IP+TCP+WS+Noise = 79 bytes) - **MTU management**: Automatic overhead calculation (IP+TCP+WS+Noise = 79 bytes)
### 🏷️ 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
The server acts as a **hub** — one API to manage all clients: The server acts as a **hub** — one API to manage all clients:
@@ -205,7 +298,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
@@ -243,46 +336,100 @@ 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, forwarding mode, clients, proxy protocol) | | `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode, clients, proxy protocol, destination policy) |
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, 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) |
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) | | `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) |
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) | | `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
| `IClientConfigBundle` | Full config bundle returned by `createClient()` | | `IClientConfigBundle` | Full config bundle returned by `createClient()` — 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) |
| `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
@@ -317,7 +464,7 @@ const unit = VpnInstaller.generateServiceUnit({
// All transports simultaneously (default) — WS + QUIC + WireGuard // All transports simultaneously (default) — WS + QUIC + WireGuard
{ transportMode: 'all', listenAddr: '0.0.0.0:443', wgPrivateKey: '...', wgListenPort: 51820 } { transportMode: 'all', listenAddr: '0.0.0.0:443', wgPrivateKey: '...', wgListenPort: 51820 }
// WS + QUIC only (backward compat) // WS + QUIC only
{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' } { transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' }
// WebSocket only // WebSocket only
@@ -376,7 +523,7 @@ pnpm install
# Build (TypeScript + Rust cross-compile) # Build (TypeScript + Rust cross-compile)
pnpm build pnpm build
# Run all tests (79 TS + 132 Rust = 211 tests) # Run all tests
pnpm test pnpm test
# Run Rust tests directly # Run Rust tests directly
@@ -393,6 +540,7 @@ 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.classes.vpnserver.ts │ ├── smartvpn.classes.vpnserver.ts
│ ├── smartvpn.classes.vpnclient.ts │ ├── smartvpn.classes.vpnclient.ts
│ ├── smartvpn.classes.vpnbridge.ts │ ├── smartvpn.classes.vpnbridge.ts
@@ -417,7 +565,7 @@ smartvpn/
│ ├── ratelimit.rs # Token bucket │ ├── ratelimit.rs # Token bucket
│ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy │ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy
│ └── ... # tunnel, network, telemetry, qos, mtu, reconnect │ └── ... # tunnel, network, telemetry, qos, mtu, reconnect
├── test/ # 9 test files (79 tests) ├── 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)
``` ```

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;

View File

@@ -44,7 +44,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>,
@@ -90,7 +95,11 @@ impl ClientRegistry {
/// 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)
@@ -193,6 +202,8 @@ 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,

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)]
#[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")]
@@ -62,12 +76,22 @@ pub struct ServerConfig {
/// PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's /// PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's
/// tunnel IP as the source address. /// tunnel IP as the source address.
pub socket_forward_proxy_protocol: Option<bool>, 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. /// WireGuard: server X25519 private key (base64). Required when transport includes WG.
pub wg_private_key: Option<String>, pub wg_private_key: Option<String>,
/// WireGuard: UDP listen port (default: 51820). /// WireGuard: UDP listen port (default: 51820).
pub wg_listen_port: Option<u16>, pub wg_listen_port: Option<u16>,
/// WireGuard: pre-configured peers. /// WireGuard: pre-configured peers.
pub wg_peers: Option<Vec<crate::wireguard::WgPeerConfig>>, 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>>,
} }
/// Information about a connected client. /// Information about a connected client.
@@ -261,6 +285,7 @@ impl VpnServer {
link_mtu as usize, link_mtu as usize,
state.clone(), state.clone(),
proxy_protocol, 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 {
@@ -347,6 +372,28 @@ impl VpnServer {
} }
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(())
} }
@@ -551,9 +598,16 @@ 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()),
@@ -562,10 +616,27 @@ impl VpnServer {
// Add to registry // Add to registry
state.client_registry.write().await.add(entry.clone())?; state.client_registry.write().await.add(entry.clone())?;
// Register WG peer with the running WG listener (if active)
if self.wg_command_tx.is_some() {
let wg_peer_config = crate::wireguard::WgPeerConfig {
public_key: wg_pub.clone(),
preshared_key: None,
allowed_ips: vec![format!("{}/32", assigned_ip)],
endpoint: None,
persistent_keepalive: Some(25),
};
if let Err(e) = self.add_wg_peer(wg_peer_config).await {
warn!("Failed to register WG peer for client {}: {}", client_id, e);
}
}
// Build SmartVPN client config // 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,
@@ -575,15 +646,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)?;
@@ -604,6 +685,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>() {
@@ -648,8 +737,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());
@@ -687,6 +779,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();
@@ -705,9 +805,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,
@@ -716,14 +838,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!({
@@ -747,10 +879,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,
@@ -760,15 +895,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 }))
} }

View File

@@ -13,7 +13,8 @@ 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;
// ============================================================================ // ============================================================================
@@ -100,7 +101,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
} }
} }
@@ -123,6 +124,14 @@ struct TcpSession {
bridge_data_tx: mpsc::Sender<Vec<u8>>, bridge_data_tx: 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,
} }
struct UdpSession { struct UdpSession {
@@ -194,10 +203,22 @@ pub struct NatEngine {
/// When true, outbound TCP connections prepend PROXY protocol v2 headers /// When true, outbound TCP connections prepend PROXY protocol v2 headers
/// with the VPN client's tunnel IP as source address. /// with the VPN client's tunnel IP as source address.
proxy_protocol: bool, 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>, proxy_protocol: bool) -> 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);
@@ -230,6 +251,7 @@ impl NatEngine {
bridge_tx, bridge_tx,
start_time: std::time::Instant::now(), start_time: std::time::Instant::now(),
proxy_protocol, proxy_protocol,
destination_policy,
} }
} }
@@ -237,6 +259,40 @@ 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.
fn evaluate_destination(&self, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction {
let policy = match &self.destination_policy {
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 {
@@ -260,8 +316,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(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 => {
@@ -278,7 +343,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(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
@@ -295,7 +367,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]);
@@ -314,22 +386,23 @@ impl NatEngine {
let handle = self.sockets.add(socket); let handle = self.sockets.add(socket);
// Channel for sending data from NAT engine to bridge task // Channel for sending data from NAT engine to bridge task
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256); 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: data_tx,
client_ip: key.src_ip, client_ip: key.src_ip,
bridge_started: false,
connect_addr,
pending_send: Vec::new(),
closing: false,
}; };
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
let proxy_protocol = self.proxy_protocol; // handshake is done, which would cause silent data loss.
tokio::spawn(async move {
tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol).await;
});
debug!( debug!(
"NAT: new TCP session {}:{} -> {}:{}", "NAT: new TCP session {}:{} -> {}:{}",
@@ -337,7 +410,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],
@@ -373,7 +446,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!(
@@ -389,13 +462,54 @@ 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;
// Recreate the data channel — the old receiver was dropped
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
session.bridge_data_tx = 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 tcp_outbound: Vec<(mpsc::Sender<Vec<u8>>, Vec<u8>)> = 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 sender = session.bridge_data_tx.clone();
let _ = socket.recv(|data| { let _ = socket.recv(|data| {
let _ = session.bridge_data_tx.try_send(data.to_vec()); tcp_outbound.push((sender.clone(), data.to_vec()));
(data.len(), ()) (data.len(), ())
}); });
} }
@@ -405,6 +519,13 @@ impl NatEngine {
} }
} }
// Send TCP data to bridge tasks (outside borrow of self.tcp_sessions)
for (sender, data) in tcp_outbound {
if sender.try_send(data).is_err() {
debug!("NAT: bridge channel full, TCP data dropped");
}
}
// 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) {
@@ -417,7 +538,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");
}
} }
} }
@@ -426,7 +549,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);
}
} }
} }
} }
@@ -435,22 +560,51 @@ 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) {
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() {
let _ = socket.send_slice(&data); // Try to write directly first
let all_data = if session.pending_send.is_empty() {
&data
} else {
session.pending_send.extend_from_slice(&data);
&session.pending_send.clone()
};
match socket.send_slice(all_data) {
Ok(written) if written < all_data.len() => {
// Partial write — buffer the rest
if session.pending_send.is_empty() {
session.pending_send = data[written..].to_vec();
} else {
session.pending_send.drain(..written);
}
}
Ok(_) => {
// Full write — clear any pending data
session.pending_send.clear();
}
Err(_) => {
// Write failed — buffer everything
if session.pending_send.is_empty() {
session.pending_send = data;
}
}
}
} else {
// Can't send yet — buffer for later
session.pending_send.extend_from_slice(&data);
} }
} }
} }
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 } => {
@@ -537,20 +691,19 @@ async fn tcp_bridge_task(
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, 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;
} }
@@ -564,7 +717,7 @@ async fn tcp_bridge_task(
let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port); let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port);
let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst); let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst);
if let Err(e) = writer.write_all(&pp_header).await { if let Err(e) = writer.write_all(&pp_header).await {
debug!("NAT: failed to send PP v2 header to {}: {}", addr, e); debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e);
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await; let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
return; return;
} }
@@ -612,6 +765,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,
@@ -620,7 +774,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();

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use boringtun::noise::errors::WireGuardError;
use boringtun::noise::rate_limiter::RateLimiter; use boringtun::noise::rate_limiter::RateLimiter;
use boringtun::noise::{Tunn, TunnResult}; use boringtun::noise::{Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret}; use boringtun::x25519::{PublicKey, StaticSecret};
@@ -99,6 +100,13 @@ pub fn generate_wg_keypair() -> (String, String) {
(pub_b64, priv_b64) (pub_b64, priv_b64)
} }
/// Derive the WireGuard public key (base64) from a private key (base64).
pub fn wg_public_key_from_private(private_key_b64: &str) -> Result<String> {
let private = parse_private_key(private_key_b64)?;
let public = PublicKey::from(&private);
Ok(BASE64.encode(public.to_bytes()))
}
fn parse_private_key(b64: &str) -> Result<StaticSecret> { fn parse_private_key(b64: &str) -> Result<StaticSecret> {
let bytes = BASE64.decode(b64)?; let bytes = BASE64.decode(b64)?;
if bytes.len() != 32 { if bytes.len() != 32 {
@@ -215,8 +223,8 @@ struct PeerState {
} }
impl PeerState { impl PeerState {
fn matches_dst(&self, dst_ip: IpAddr) -> bool { fn matches_allowed_ips(&self, ip: IpAddr) -> bool {
self.allowed_ips.iter().any(|aip| aip.matches(dst_ip)) self.allowed_ips.iter().any(|aip| aip.matches(ip))
} }
} }
@@ -286,9 +294,10 @@ pub struct WgListenerConfig {
pub peers: Vec<WgPeerConfig>, pub peers: Vec<WgPeerConfig>,
} }
/// Extract the first /32 IPv4 address from a list of AllowedIp entries. /// Extract the peer's VPN IP from AllowedIp entries.
/// This is the peer's VPN IP used for return-packet routing. /// Prefers /32 entries (exact match); falls back to any IPv4 address.
fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> { fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> {
// Prefer /32 entries (exact peer VPN IP)
for aip in allowed_ips { for aip in allowed_ips {
if let IpAddr::V4(v4) = aip.addr { if let IpAddr::V4(v4) = aip.addr {
if aip.prefix_len == 32 { if aip.prefix_len == 32 {
@@ -296,6 +305,12 @@ fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> {
} }
} }
} }
// Fallback: use the first IPv4 address from any prefix length
for aip in allowed_ips {
if let IpAddr::V4(v4) = aip.addr {
return Some(v4);
}
}
None None
} }
@@ -495,7 +510,7 @@ pub async fn run_wg_listener(
break; break;
} }
TunnResult::WriteToTunnelV4(packet, addr) => { TunnResult::WriteToTunnelV4(packet, addr) => {
if peer.matches_dst(IpAddr::V4(addr)) { if peer.matches_allowed_ips(IpAddr::V4(addr)) {
let pkt_len = packet.len() as u64; let pkt_len = packet.len() as u64;
// Forward via shared forwarding engine // Forward via shared forwarding engine
let mut engine = state.forwarding_engine.lock().await; let mut engine = state.forwarding_engine.lock().await;
@@ -519,7 +534,7 @@ pub async fn run_wg_listener(
break; break;
} }
TunnResult::WriteToTunnelV6(packet, addr) => { TunnResult::WriteToTunnelV6(packet, addr) => {
if peer.matches_dst(IpAddr::V6(addr)) { if peer.matches_allowed_ips(IpAddr::V6(addr)) {
let pkt_len = packet.len() as u64; let pkt_len = packet.len() as u64;
let mut engine = state.forwarding_engine.lock().await; let mut engine = state.forwarding_engine.lock().await;
match &mut *engine { match &mut *engine {
@@ -586,6 +601,9 @@ pub async fn run_wg_listener(
udp_socket.send_to(packet, endpoint).await?; udp_socket.send_to(packet, endpoint).await?;
} }
} }
TunnResult::Err(WireGuardError::ConnectionExpired) => {
warn!("WG peer {} connection expired", peer.public_key_b64);
}
TunnResult::Err(e) => { TunnResult::Err(e) => {
debug!("Timer error for WG peer {}: {:?}", debug!("Timer error for WG peer {}: {:?}",
peer.public_key_b64, e); peer.public_key_b64, e);
@@ -796,12 +814,12 @@ impl WgClient {
let state = self.state.clone(); let state = self.state.clone();
let assigned_ip = config.address.clone(); let assigned_ip = config.address.clone();
// Update state // Update state — handshake hasn't completed yet
{ {
let mut s = state.write().await; let mut s = state.write().await;
s.state = "connected".to_string(); s.state = "handshaking".to_string();
s.assigned_ip = Some(assigned_ip.clone()); s.assigned_ip = Some(assigned_ip.clone());
s.connected_since = Some(chrono_now()); s.connected_since = None;
} }
// Spawn client loop // Spawn client loop
@@ -868,7 +886,7 @@ async fn wg_client_loop(
endpoint: SocketAddr, endpoint: SocketAddr,
_allowed_ips: Vec<AllowedIp>, _allowed_ips: Vec<AllowedIp>,
shared_stats: Arc<RwLock<WgPeerStats>>, shared_stats: Arc<RwLock<WgPeerStats>>,
_state: Arc<RwLock<WgClientState>>, state: Arc<RwLock<WgClientState>>,
mut shutdown_rx: oneshot::Receiver<()>, mut shutdown_rx: oneshot::Receiver<()>,
) -> Result<()> { ) -> Result<()> {
let mut udp_buf = vec![0u8; MAX_UDP_PACKET]; let mut udp_buf = vec![0u8; MAX_UDP_PACKET];
@@ -876,6 +894,7 @@ async fn wg_client_loop(
let mut dst_buf = vec![0u8; WG_BUFFER_SIZE]; let mut dst_buf = vec![0u8; WG_BUFFER_SIZE];
let mut timer = tokio::time::interval(std::time::Duration::from_millis(TIMER_TICK_MS)); let mut timer = tokio::time::interval(std::time::Duration::from_millis(TIMER_TICK_MS));
let mut stats_timer = tokio::time::interval(std::time::Duration::from_secs(1)); let mut stats_timer = tokio::time::interval(std::time::Duration::from_secs(1));
let mut handshake_complete = false;
let (mut tun_reader, mut tun_writer) = tokio::io::split(tun_device); let (mut tun_reader, mut tun_writer) = tokio::io::split(tun_device);
@@ -916,14 +935,37 @@ async fn wg_client_loop(
tun_writer.write_all(packet).await?; tun_writer.write_all(packet).await?;
local_stats.bytes_received += pkt_len; local_stats.bytes_received += pkt_len;
local_stats.packets_received += 1; local_stats.packets_received += 1;
if !handshake_complete {
handshake_complete = true;
let mut s = state.write().await;
s.state = "connected".to_string();
s.connected_since = Some(chrono_now());
info!("WireGuard handshake completed, tunnel active");
}
} }
TunnResult::WriteToTunnelV6(packet, _addr) => { TunnResult::WriteToTunnelV6(packet, _addr) => {
let pkt_len = packet.len() as u64; let pkt_len = packet.len() as u64;
tun_writer.write_all(packet).await?; tun_writer.write_all(packet).await?;
local_stats.bytes_received += pkt_len; local_stats.bytes_received += pkt_len;
local_stats.packets_received += 1; local_stats.packets_received += 1;
if !handshake_complete {
handshake_complete = true;
let mut s = state.write().await;
s.state = "connected".to_string();
s.connected_since = Some(chrono_now());
info!("WireGuard handshake completed, tunnel active");
}
} }
TunnResult::Done => {} TunnResult::Done => {}
TunnResult::Err(WireGuardError::ConnectionExpired) => {
warn!("WireGuard session expired during decapsulate, re-initiating handshake");
match tunn.format_handshake_initiation(&mut dst_buf, true) {
TunnResult::WriteToNetwork(packet) => {
udp_socket.send_to(packet, endpoint).await?;
}
_ => {}
}
}
TunnResult::Err(e) => { TunnResult::Err(e) => {
debug!("Client decapsulate error: {:?}", e); debug!("Client decapsulate error: {:?}", e);
} }
@@ -955,6 +997,19 @@ async fn wg_client_loop(
TunnResult::WriteToNetwork(packet) => { TunnResult::WriteToNetwork(packet) => {
udp_socket.send_to(packet, endpoint).await?; udp_socket.send_to(packet, endpoint).await?;
} }
TunnResult::Err(WireGuardError::ConnectionExpired) => {
warn!("WireGuard connection expired, re-initiating handshake");
match tunn.format_handshake_initiation(&mut dst_buf, true) {
TunnResult::WriteToNetwork(packet) => {
udp_socket.send_to(packet, endpoint).await?;
debug!("Sent handshake re-initiation after expiry");
}
TunnResult::Err(e) => {
warn!("Failed to re-initiate handshake: {:?}", e);
}
_ => {}
}
}
TunnResult::Err(e) => { TunnResult::Err(e) => {
debug!("Client timer error: {:?}", e); debug!("Client timer error: {:?}", e);
} }
@@ -1028,6 +1083,19 @@ mod tests {
assert_eq!(public.to_bytes(), derived_public.to_bytes()); assert_eq!(public.to_bytes(), derived_public.to_bytes());
} }
#[test]
fn test_wg_public_key_from_private() {
let (pub_b64, priv_b64) = generate_wg_keypair();
let derived = wg_public_key_from_private(&priv_b64).unwrap();
assert_eq!(derived, pub_b64);
}
#[test]
fn test_wg_public_key_from_private_invalid() {
assert!(wg_public_key_from_private("not-valid").is_err());
assert!(wg_public_key_from_private("AAAA").is_err());
}
#[test] #[test]
fn test_parse_invalid_key() { fn test_parse_invalid_key() {
assert!(parse_private_key("not-valid-base64!!!").is_err()); assert!(parse_private_key("not-valid-base64!!!").is_err());

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvpn', name: '@push.rocks/smartvpn',
version: '1.12.0', version: '1.16.4',
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,10 +239,110 @@ 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 stop(): void {
// Clean up nftables rules
if (this.nftHealthInterval) {
clearInterval(this.nftHealthInterval);
this.nftHealthInterval = undefined;
}
if (this.nft) {
this.nft.cleanup().catch(() => {}); // best-effort cleanup
this.nft = undefined;
}
this.bridge.stop(); this.bridge.stop();
} }

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 {
@@ -123,6 +125,35 @@ export interface IVpnServerConfig {
* tunnel IP as the source address. This allows downstream services (e.g. SmartProxy) * 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. */ * to see the real VPN client identity instead of 127.0.0.1. */
socketForwardProxyProtocol?: boolean; 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[];
}
/**
* 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 {
@@ -290,7 +321,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;

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 };