2 Commits

7 changed files with 303 additions and 24 deletions

View File

@@ -1,5 +1,13 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvpn",
"version": "1.14.0",
"version": "1.15.0",
"private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module",
@@ -29,6 +29,7 @@
],
"license": "MIT",
"dependencies": {
"@push.rocks/smartnftables": "1.1.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartrust": "^1.3.2"
},

11
pnpm-lock.yaml generated
View File

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

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
📡 **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
🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration
## 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 │
│ Hub: client management │ │ TUN device, IP pool, NAT │
│ WireGuard .conf generation │ │ Rate limiting, ACLs, QoS │
│ nftables destination policy │ │ Destination routing, nftables│
└──────────────────────────────┘ └───────────────────────────────┘
```
**Split-plane design** — TypeScript handles orchestration, config, and DX; Rust handles every hot-path byte with zero-copy async I/O (tokio, mimalloc).
### IPC Transport Modes
The bridge between TypeScript and Rust supports two transport modes:
| Mode | Use Case | How It Works |
|------|----------|-------------|
| **stdio** | Development, testing | Spawns the Rust daemon as a child process, communicates over stdin/stdout |
| **socket** | Production | Connects to an already-running daemon via Unix domain socket, with optional auto-reconnect |
```typescript
// Development: spawn the daemon
const server = new VpnServer({ transport: { transport: 'stdio' } });
// Production: connect to running daemon
const server = new VpnServer({
transport: {
transport: 'socket',
socketPath: '/var/run/smartvpn.sock',
autoReconnect: true,
reconnectBaseDelayMs: 100,
reconnectMaxDelayMs: 5000,
maxReconnectAttempts: 10,
},
});
```
## Quick Start 🚀
### 1. Start a VPN Server (Hub)
@@ -54,8 +82,8 @@ await server.start({
privateKey: '<server-noise-private-key-base64>',
publicKey: '<server-noise-public-key-base64>',
subnet: '10.8.0.0/24',
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing'
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing'
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
enableNat: true,
dns: ['1.1.1.1', '8.8.8.8'],
@@ -67,7 +95,7 @@ await server.start({
```typescript
const bundle = await server.createClient({
clientId: 'alice-laptop',
tags: ['engineering'],
serverDefinedClientTags: ['engineering'], // trusted tags for access control
security: {
destinationAllowList: ['10.0.0.0/8'], // can only reach internal network
destinationBlockList: ['10.0.0.99'], // except this host
@@ -155,6 +183,47 @@ await server.start({
- `remoteAddr` field on `IVpnClientInfo` exposes the real client IP for monitoring
- **Security**: must be `false` (default) when accepting direct connections — only enable behind a trusted proxy
### 🎯 Destination Routing Policy
Control where decrypted VPN client traffic goes — force it to a specific target, block it, or allow it through. Evaluated per-packet before per-client ACLs.
```typescript
await server.start({
// ...
forwardingMode: 'socket', // userspace NAT mode
destinationPolicy: {
default: 'forceTarget', // redirect all traffic to a target
target: '127.0.0.1', // target IP for 'forceTarget' mode
allowList: ['10.0.0.0/8'], // these destinations pass through directly
blockList: ['10.0.0.99'], // always blocked (deny overrides allow)
},
});
```
**Policy modes:**
| Mode | Behavior |
|------|----------|
| `'forceTarget'` | Rewrites destination IP to `target` — funnels all traffic through a single endpoint |
| `'block'` | Drops all traffic not explicitly in `allowList` |
| `'allow'` | Passes all traffic through (default, backward compatible) |
In **TUN mode**, destination policies are enforced via **nftables** rules (using `@push.rocks/smartnftables`). A 60-second health check automatically re-applies rules if they're removed externally.
In **socket mode**, the policy is evaluated in the userspace NAT engine before per-client ACLs.
### 🔗 Socket Forward Proxy Protocol
When using `forwardingMode: 'socket'` (userspace NAT), you can prepend **PROXY protocol v2 headers** on outbound TCP connections. This conveys the VPN client's tunnel IP as the source address to downstream services (e.g., SmartProxy):
```typescript
await server.start({
// ...
forwardingMode: 'socket',
socketForwardProxyProtocol: true, // downstream sees VPN client IP, not 127.0.0.1
});
```
### 📦 Packet Forwarding Modes
SmartVPN supports three forwarding modes, configurable per-server and per-client:
@@ -190,6 +259,30 @@ The userspace NAT mode extracts destination IP/port from IP packets, opens a rea
- **Dead-peer detection**: 180s inactivity timeout
- **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
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...)
await server.updateClient('bob-phone', {
security: { destinationAllowList: ['0.0.0.0/0'] },
tags: ['mobile', 'field-ops'],
serverDefinedClientTags: ['mobile', 'field-ops'],
});
// Enable / Disable
@@ -243,46 +336,100 @@ const conf = WgConfigGenerator.generateClientConfig({
// → standard WireGuard .conf compatible with wg-quick, iOS, Android
```
Server configs too:
```typescript
const serverConf = WgConfigGenerator.generateServerConfig({
privateKey: '<server-wg-private-key>',
address: '10.8.0.1/24',
listenPort: 51820,
enableNat: true,
natInterface: 'eth0',
peers: [
{ publicKey: '<client-wg-public-key>', allowedIps: ['10.8.0.2/32'] },
],
});
```
### 🖥️ System Service Installation
Generate systemd (Linux) or launchd (macOS) service units:
```typescript
import { VpnInstaller } from '@push.rocks/smartvpn';
const unit = VpnInstaller.generateServiceUnit({
binaryPath: '/usr/local/bin/smartvpn_daemon',
socketPath: '/var/run/smartvpn.sock',
mode: 'server',
configPath: '/etc/smartvpn/server.json',
});
// unit.platform → 'linux' | 'macos'
// unit.content → systemd unit file or launchd plist
// unit.platform → 'linux' | 'macos'
// unit.content → systemd unit file or launchd plist
// unit.installPath → /etc/systemd/system/smartvpn-server.service
```
You can also call `generateSystemdUnit()` or `generateLaunchdPlist()` directly for platform-specific options like custom descriptions.
### 📢 Events
Both `VpnServer` and `VpnClient` extend `EventEmitter` and emit typed events:
```typescript
server.on('client-connected', (info: IVpnClientInfo) => {
console.log(`${info.registeredClientId} connected from ${info.remoteAddr} via ${info.transportType}`);
});
server.on('client-disconnected', ({ clientId, reason }) => {
console.log(`${clientId} disconnected: ${reason}`);
});
client.on('status', (status: IVpnStatus) => {
console.log(`State: ${status.state}, IP: ${status.assignedIp}`);
});
// Both server and client emit:
server.on('exit', ({ code, signal }) => { /* daemon process exited */ });
server.on('reconnected', () => { /* socket transport reconnected */ });
```
| Event | Emitted By | Payload |
|-------|-----------|---------|
| `status` | Both | `IVpnStatus` — connection state changes |
| `error` | Both | `{ message, code? }` |
| `client-connected` | Server | `IVpnClientInfo` — full client info including transport type |
| `client-disconnected` | Server | `{ clientId, reason? }` |
| `exit` | Both | `{ code, signal }` — daemon process exited |
| `reconnected` | Both | `void` — socket transport reconnected |
## API Reference 📖
### Classes
| Class | Description |
|-------|-------------|
| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD. |
| `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, telemetry. |
| `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). |
| `VpnConfig` | Static config validation and file I/O. |
| `VpnInstaller` | Generates systemd/launchd service files. |
| `WgConfigGenerator` | Generates standard WireGuard `.conf` files. |
| `VpnServer` | Manages the Rust daemon in server mode. Hub methods for client CRUD, telemetry, rate limits, WireGuard peer management. |
| `VpnClient` | Manages the Rust daemon in client mode. Connect, disconnect, status, telemetry. |
| `VpnBridge<T>` | Low-level typed IPC bridge (stdio or Unix socket). Handles spawn, connect, reconnect, and typed command dispatch. |
| `VpnConfig` | Static config validation and JSON file I/O. Validates keys, addresses, CIDRs, MTU, etc. |
| `VpnInstaller` | Generates systemd/launchd service files for daemon deployment. |
| `WgConfigGenerator` | Generates standard WireGuard `.conf` files (client and server). |
### Key Interfaces
| Interface | Purpose |
|-----------|---------|
| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode, clients, proxy protocol) |
| `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options) |
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, tags, expiry) |
| `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, client-defined tags) |
| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry) |
| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) |
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
| `IClientConfigBundle` | Full config bundle returned by `createClient()` |
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr) |
| `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets |
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr, transport type) |
| `IVpnConnectionQuality` | RTT, jitter, loss ratio, link health |
| `IVpnMtuInfo` | TUN MTU, effective MTU, overhead bytes, oversized packet stats |
| `IVpnKeypair` | Base64-encoded public/private key pair |
| `IDestinationPolicy` | Destination routing policy (forceTarget / block / allow with allow/block lists) |
| `IVpnEventMap` | Typed event map for server and client EventEmitter |
### Server IPC Commands
@@ -317,7 +464,7 @@ const unit = VpnInstaller.generateServiceUnit({
// All transports simultaneously (default) — WS + QUIC + WireGuard
{ 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' }
// WebSocket only
@@ -376,7 +523,7 @@ pnpm install
# Build (TypeScript + Rust cross-compile)
pnpm build
# Run all tests (79 TS + 132 Rust = 211 tests)
# Run all tests
pnpm test
# Run Rust tests directly
@@ -393,6 +540,7 @@ smartvpn/
├── ts/ # TypeScript control plane
│ ├── index.ts # All exports
│ ├── smartvpn.interfaces.ts # Interfaces, types, IPC command maps
│ ├── smartvpn.plugins.ts # Dependency imports
│ ├── smartvpn.classes.vpnserver.ts
│ ├── smartvpn.classes.vpnclient.ts
│ ├── smartvpn.classes.vpnbridge.ts
@@ -417,7 +565,7 @@ smartvpn/
│ ├── ratelimit.rs # Token bucket
│ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy
│ └── ... # tunnel, network, telemetry, qos, mtu, reconnect
├── test/ # 9 test files (79 tests)
├── test/ # Test files
├── dist_ts/ # Compiled TypeScript
└── dist_rust/ # Cross-compiled binaries (linux amd64 + arm64)
```

View File

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

View File

@@ -12,6 +12,7 @@ import type {
IWgPeerInfo,
IClientEntry,
IClientConfigBundle,
IDestinationPolicy,
TVpnServerCommands,
} from './smartvpn.interfaces.js';
@@ -21,6 +22,10 @@ import type {
export class VpnServer extends plugins.events.EventEmitter {
private bridge: VpnBridge<TVpnServerCommands>;
private options: IVpnServerOptions;
private nft?: plugins.smartnftables.SmartNftables;
private nftHealthInterval?: ReturnType<typeof setInterval>;
private nftSubnet?: string;
private nftPolicy?: IDestinationPolicy;
constructor(options: IVpnServerOptions) {
super();
@@ -50,6 +55,11 @@ export class VpnServer extends plugins.events.EventEmitter {
const cfg = config || this.options.config;
if (cfg) {
await this.bridge.sendCommand('start', { config: cfg });
// For TUN mode with a destination policy, set up nftables rules
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
}
}
}
@@ -229,10 +239,110 @@ export class VpnServer extends plugins.events.EventEmitter {
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>);
}
// ── TUN Destination Policy via nftables ──────────────────────────────
/**
* Set up nftables rules for TUN mode destination policy.
* Also starts a 60-second health check interval to re-apply if rules are removed externally.
*/
private async setupTunDestinationPolicy(subnet: string, policy: IDestinationPolicy): Promise<void> {
this.nftSubnet = subnet;
this.nftPolicy = policy;
this.nft = new plugins.smartnftables.SmartNftables({
tableName: 'smartvpn_tun',
dryRun: process.getuid?.() !== 0,
});
await this.nft.initialize();
await this.applyDestinationPolicyRules();
// Health check: re-apply rules if they disappear
this.nftHealthInterval = setInterval(async () => {
if (!this.nft) return;
try {
const exists = await this.nft.tableExists();
if (!exists) {
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
this.nft = new plugins.smartnftables.SmartNftables({
tableName: 'smartvpn_tun',
});
await this.nft.initialize();
await this.applyDestinationPolicyRules();
}
} catch (err) {
console.warn(`[smartvpn] nftables health check failed: ${err}`);
}
}, 60_000);
}
/**
* Apply destination policy as nftables rules.
* Order: blockList (drop) → allowList (accept) → default action.
*/
private async applyDestinationPolicyRules(): Promise<void> {
if (!this.nft || !this.nftSubnet || !this.nftPolicy) return;
const subnet = this.nftSubnet;
const policy = this.nftPolicy;
const family = 'ip';
const table = 'smartvpn_tun';
const commands: string[] = [];
// 1. Block list (deny wins — evaluated first)
if (policy.blockList) {
for (const dest of policy.blockList) {
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} drop`
);
}
}
// 2. Allow list (pass through directly — skip DNAT)
if (policy.allowList) {
for (const dest of policy.allowList) {
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} accept`
);
}
}
// 3. Default action
switch (policy.default) {
case 'forceTarget': {
const target = policy.target || '127.0.0.1';
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} dnat to ${target}`
);
break;
}
case 'block':
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} drop`
);
break;
case 'allow':
// No rule needed — kernel default allows
break;
}
if (commands.length > 0) {
await this.nft.applyRuleGroup('vpn-destination-policy', commands);
}
}
/**
* Stop the daemon bridge.
*/
public stop(): void {
// Clean up nftables rules
if (this.nftHealthInterval) {
clearInterval(this.nftHealthInterval);
this.nftHealthInterval = undefined;
}
if (this.nft) {
this.nft.cleanup().catch(() => {}); // best-effort cleanup
this.nft = undefined;
}
this.bridge.stop();
}

View File

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