From 8eb26e1920c081fa06c79e6b8a5f5e620f014dfd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 30 Mar 2026 14:32:02 +0000 Subject: [PATCH] feat(vpnserver): add nftables-backed destination policy enforcement for TUN mode --- changelog.md | 8 ++ package.json | 1 + pnpm-lock.yaml | 11 ++ readme.md | 190 +++++++++++++++++++++++++++---- ts/00_commitinfo_data.ts | 2 +- ts/smartvpn.classes.vpnserver.ts | 110 ++++++++++++++++++ ts/smartvpn.plugins.ts | 3 +- 7 files changed, 302 insertions(+), 23 deletions(-) diff --git a/changelog.md b/changelog.md index 7dbbdfe..198abcb 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index ad6b274..6610bd7 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcda4df..5afeb49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/readme.md b/readme.md index 903f40c..6b80fdc 100644 --- a/readme.md +++ b/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 πŸ“‘ **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: '', publicKey: '', 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: '', // 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: '', + address: '10.8.0.1/24', + listenPort: 51820, + enableNat: true, + natInterface: 'eth0', + peers: [ + { publicKey: '', 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` | 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` | 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) ``` diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 23dcc53..3091704 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/smartvpn.classes.vpnserver.ts b/ts/smartvpn.classes.vpnserver.ts index d17a701..8b5bf84 100644 --- a/ts/smartvpn.classes.vpnserver.ts +++ b/ts/smartvpn.classes.vpnserver.ts @@ -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; private options: IVpnServerOptions; + private nft?: plugins.smartnftables.SmartNftables; + private nftHealthInterval?: ReturnType; + 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); } + // ── 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 { + 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 { + 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(); } diff --git a/ts/smartvpn.plugins.ts b/ts/smartvpn.plugins.ts index 8d9b3ac..5822ed0 100644 --- a/ts/smartvpn.plugins.ts +++ b/ts/smartvpn.plugins.ts @@ -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 };