Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfa91fd419 | |||
| 8eb26e1920 | |||
| e513f8686b | |||
| e06667b298 |
15
changelog.md
15
changelog.md
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-30 - 1.13.0 - feat(client-registry)
|
||||||
separate trusted server-defined client tags from client-reported tags with legacy tag compatibility
|
separate trusted server-defined client tags from client-reported tags with legacy tag compatibility
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.13.0",
|
"version": "1.15.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -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
190
readme.md
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +76,8 @@ 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).
|
||||||
@@ -261,6 +277,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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -194,10 +195,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 +243,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 +251,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 {
|
||||||
@@ -261,7 +309,14 @@ 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) {
|
if is_syn && !self.tcp_sessions.contains_key(&key) {
|
||||||
self.create_tcp_session(&key);
|
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 +333,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 +357,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]);
|
||||||
@@ -323,12 +385,12 @@ impl NatEngine {
|
|||||||
};
|
};
|
||||||
self.tcp_sessions.insert(key.clone(), session);
|
self.tcp_sessions.insert(key.clone(), session);
|
||||||
|
|
||||||
// Spawn bridge task that connects to the real destination
|
// Spawn bridge task that connects to the resolved destination
|
||||||
let bridge_tx = self.bridge_tx.clone();
|
let bridge_tx = self.bridge_tx.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
let proxy_protocol = self.proxy_protocol;
|
let proxy_protocol = self.proxy_protocol;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol).await;
|
tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol, connect_addr).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -337,7 +399,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 +435,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!(
|
||||||
@@ -537,20 +599,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 +625,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 +673,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 +682,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();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.13.0',
|
version: '1.15.0',
|
||||||
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,27 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user