@push.rocks/smartvpn
A high-performance VPN solution with a TypeScript control plane and a Rust data plane daemon. Enterprise-ready client authentication, triple transport support (WebSocket + QUIC + WireGuard), and a typed hub API for managing clients from code.
- 🔐 Noise IK mutual authentication — per-client X25519 keypairs, server-side registry
- 🚀 Triple transport: WebSocket (Cloudflare-friendly), raw QUIC (datagrams), and WireGuard (standard protocol)
- 🛡️ ACL engine — deny-overrides-allow IP filtering, aligned with SmartProxy conventions
- 🔀 PROXY protocol v2 — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum)
- 📊 Per-transport metrics: active clients and total connections broken down by websocket, QUIC, and WireGuard
- 🔄 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), L2 bridge, hybrid, or testing mode
- 🏠 Bridge mode: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network
- 🔀 Hybrid mode: per-client routing — some clients bridge to the LAN, others use userspace NAT, all on the same server
- 🏷️ VLAN support: assign individual clients to 802.1Q VLANs on the bridge
- 🎯 Destination routing policy: force-target, block, or allow traffic per destination with nftables integration
- ⚡ Authenticated WireGuard state: peers appear as "connected" after their first authenticated, source-authorized tunnel packet, and auto-disconnect on idle timeout
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install 📦
pnpm install @push.rocks/smartvpn
The package ships with pre-compiled Rust binaries for linux/amd64 and linux/arm64. No Rust toolchain is required at runtime. Set SMARTVPN_RUST_BINARY if you want the TypeScript bridge to use a custom daemon binary.
Architecture 🏗️
┌──────────────────────────────┐ JSON-lines IPC ┌───────────────────────────────┐
│ TypeScript Control Plane │ ◄─────────────────────► │ Rust Data Plane Daemon │
│ │ stdio or Unix sock │ │
│ VpnServer / VpnClient │ │ Noise IK handshake │
│ Typed IPC commands │ │ Noise transport encryption │
│ 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 the hot path with async I/O, framed packet codecs, and the Noise transport state after authentication.
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 |
// 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)
import { VpnServer } from '@push.rocks/smartvpn';
const server = new VpnServer({ transport: { transport: 'stdio' } });
await server.start({
listenAddr: '0.0.0.0:443',
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' | 'socket' | 'bridge' | 'hybrid' | 'testing'
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
enableNat: true,
dns: ['1.1.1.1', '8.8.8.8'],
});
2. Create a Client (One Call = Everything)
const bundle = await server.createClient({
clientId: 'alice-laptop',
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
rateLimit: { bytesPerSec: 10_000_000, burstBytes: 20_000_000 },
},
});
// bundle.smartvpnConfig → typed IVpnClientConfig, ready to use
// bundle.wireguardConfig → standard WireGuard .conf string
// bundle.secrets → { noisePrivateKey, wgPrivateKey } — shown ONCE
3. Connect a Client
import { VpnClient } from '@push.rocks/smartvpn';
const client = new VpnClient({ transport: { transport: 'stdio' } });
await client.start();
const { assignedIp } = await client.connect(bundle.smartvpnConfig);
console.log(`Connected! VPN IP: ${assignedIp}`);
Features ✨
Caller-owned client network namespace
A Linux stdio client accepts networkNamespaceFd in IVpnClientOptions. Pass an
already open network namespace descriptor; the caller retains ownership and keeps
it valid through each start(). The child validates its inherited duplicate,
enters the namespace before creating runtime threads or sockets, then closes the
duplicate. Invalid descriptors and failed entry cannot produce a ready client.
Stopping the client waits for process termination before the caller releases its
namespace owner. A running child remains in its namespace if the caller closes
the original descriptor, but a later start still requires a valid descriptor.
The caller must provide the hub endpoint's underlay route and any required DNS reachability inside that namespace before connecting. SmartVPN creates neither the namespace nor that underlay. Managed TUN setup still validates the authenticated assignment and rejects routes that capture the actual hub endpoint. Namespace placement does not grant workload access or replace packet policy.
The option is process configuration, separate from VPN connection configuration.
It is rejected for socket transport and server bridges. The native equivalent is
--management --mode client --network-namespace-fd 3, with an inherited descriptor
at FD 3. Server facade firewall setup runs in its parent process, so server
namespace placement requires a separately coordinated policy owner.
The Linux amd64 musl candidate passed offline Linux 6.18.35 qualification with
all native threads in the target namespace, child descriptor closure, retained
caller ownership and denied-entry rejection. The shared serve.zone/testing
scenario also passed real WS/Noise traffic through inherited namespace entry,
managed TUN creation, allow/deny, reconnect, revocation, route-conflict rollback
and child-termination cleanup. Privileged ARM64 and Pallet integration remain
separate qualification requirements.
Confirmed bridge shutdown
Always await client.stop(), server.stop() or bridge.stop() before replacing
an owned worker. These use Smartrust's confirmed termination API; a timeout is
not treated as successful shutdown. In stdio mode they wait for child exit and
stdio closure. In socket mode they confirm the local IPC connection has closed,
not that the externally supervised daemon process has exited.
server.stop() drains an in-flight start and firewall health check before
cleaning up its policy resources. Cleanup errors reject and retain ownership
for an explicit retry. If a socket-connected facade installed TUN policy, it
first requires the managed server's stop acknowledgement before closing IPC
and removing that policy. A socket connection without owned policy only closes
IPC; use stopServer() explicitly to request remote listener shutdown.
await client.stop();
await server.stop();
This bridge lifecycle guarantee is separate from per-client packet/session revocation inside the running Rust daemon; see the managed-network qualification boundaries below.
The Rust server now waits for configured listeners and initial WireGuard peers
before acknowledging startup. A listener startup failure shuts down the owned
runtime. stopServer() cancels and drains server-owned packet tasks, including
pre-authentication connections, WireGuard return relays, and NAT socket readers;
QUIC also closes its connections and waits for protocol shutdown. Per-client
transport and queued forwarding teardown are described below. Transactional
rollback of standalone kernel network setup remains outstanding; the isolated
managed-client qualification below does not establish that guarantee for other modes.
When a raw WireGuard declaration uses a registered client's key, its allowedIps
must be exactly that client's assigned /32. Raw peers without a matching client
registry entry retain their separately configured prefix lists.
Client runtime shutdown
Always await client.disconnect(): it cancels and joins the native/WireGuard
packet runtime before returning, including the native keepalive monitor. A blocked
write cannot turn a timeout into a successful disconnect. Failed connection
attempts clear their startup state; unexpected packet-task panics remain errors.
Packet-loop exit clears connection and MTU metadata; native clients also retire
their monitor before reconnect can reuse the client. Dropping the Rust client requests cancellation but
cannot provide an awaited cleanup guarantee. Linux native managed-client TUN
cleanup is qualified below; standalone native/WireGuard host-route cleanup is not.
🔐 Enterprise Authentication (Noise IK)
Every client authenticates with a Noise IK handshake (Noise_IK_25519_ChaChaPoly_BLAKE2s). The server verifies the client's static public key against its registry — unauthorized clients are rejected before any data flows.
- Per-client X25519 keypair generated server-side
- Client registry with enable/disable, expiry, tags
- Key rotation with
rotateClientKey()— generates new keys, returns fresh config bundle, disconnects old session
🌐 Triple Transport
| Transport | Protocol | Best For |
|---|---|---|
| WebSocket | TLS over TCP | Firewall-friendly, Cloudflare compatible |
| QUIC | UDP (via quinn) | Low latency, datagram support for IP packets |
| WireGuard | UDP (via boringtun) | Standard WG clients (iOS, Android, wg-quick) |
The server runs with transportMode: 'all' by default: WebSocket and QUIC are enabled, and WireGuard joins the same server when wgPrivateKey is configured. All server transports share the same forwarding pipeline (ForwardingEngine), IP pool, client registry, and statistics, so WireGuard peers can use the same userspace NAT, bridge/hybrid routing, and monitoring model as WS/QUIC clients. Native SmartVPN clients auto-negotiate with transport: 'auto' (tries QUIC first, falls back to WS).
📊 Per-Transport Metrics
Server statistics include per-transport breakdowns so you can see exactly how many clients use each protocol:
const stats = await server.getStatistics();
// Aggregate
console.log(stats.activeClients); // total connected clients
console.log(stats.totalConnections); // total connections since start
// Per-transport active clients
console.log(stats.activeClientsWebsocket); // currently connected via WS
console.log(stats.activeClientsQuic); // currently connected via QUIC
console.log(stats.activeClientsWireguard); // currently connected via WireGuard
// Per-transport total connections
console.log(stats.totalConnectionsWebsocket);
console.log(stats.totalConnectionsQuic);
console.log(stats.totalConnectionsWireguard);
WireGuard connection state follows authenticated tunnel traffic — a configured peer has no live return route until its first authenticated, source-authorized tunnel packet. Handshake or keepalive traffic alone does not admit a packet session. Peers disconnect after 180 seconds of inactivity, registry expiry, or when boringtun reports ConnectionExpired. Disconnect retains the configured peer but resets its cryptographic state, so subsequent traffic requires a fresh handshake. Raw peer connection IDs use wg- followed by the full public key.
🛡️ ACL Engine (SmartProxy-Aligned)
Security policies per client, using the same ipAllowList / ipBlockList naming convention as @push.rocks/smartproxy:
security: {
ipAllowList: ['192.168.1.0/24'], // source IPs allowed to connect
ipBlockList: ['192.168.1.100'], // deny overrides allow
destinationAllowList: ['10.0.0.0/8'], // VPN destinations permitted
destinationBlockList: ['10.0.0.99'], // deny overrides allow
maxConnections: 5,
rateLimit: { bytesPerSec: 1_000_000, burstBytes: 2_000_000 },
}
Supports exact IPs, CIDR, wildcards (192.168.1.*), and ranges (1.1.1.1-1.1.1.100).
🔀 PROXY Protocol v2
When the VPN server sits behind a reverse proxy, enable PROXY protocol v2 to receive the real client IP instead of the proxy's address. This makes ipAllowList / ipBlockList ACLs work correctly through load balancers.
await server.start({
// ... other config ...
proxyProtocol: true, // parse PP v2 headers on WS connections
connectionIpBlockList: ['198.51.100.0/24'], // server-wide block list (pre-handshake)
});
Two-phase ACL with real IPs:
| Phase | When | What Happens |
|---|---|---|
| Pre-handshake | After TCP accept | Server-level connectionIpBlockList rejects known-bad IPs — zero crypto cost |
| Post-handshake | After Noise IK identifies client | Per-client ipAllowList / ipBlockList checked against real source IP |
- Parses the PP v2 binary header from raw TCP before WebSocket upgrade
- 5-second timeout protects against stalling attacks
- LOCAL command (proxy health checks) handled gracefully
- IPv4 and IPv6 addresses supported
remoteAddrfield onIVpnClientInfoexposes 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.
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.
Per-client override — individual clients can have their own destination policy that overrides the server-level default:
await server.createClient({
clientId: 'restricted-client',
security: {
destinationPolicy: {
default: 'block', // block everything by default
allowList: ['10.0.0.0/8'], // except internal network
},
// ... other security settings
},
});
🔗 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):
await server.start({
// ...
forwardingMode: 'socket',
socketForwardProxyProtocol: true, // downstream sees VPN client IP, not 127.0.0.1
});
📦 Packet Forwarding Modes
SmartVPN supports five forwarding modes, configurable per-server:
| Mode | Flag | Description | Root Required |
|---|---|---|---|
| TUN | 'tun' |
Kernel TUN device — real packet forwarding with system routing | ✅ Yes |
| Userspace NAT | 'socket' |
Userspace TCP/UDP proxy via connect(2) — no TUN, no root needed |
❌ No |
| Bridge | 'bridge' |
L2 bridge — VPN clients get IPs from a physical LAN subnet | ✅ Yes |
| Hybrid | 'hybrid' |
Per-client routing: some clients use socket NAT, others use bridge — both engines run simultaneously | ✅ Yes |
| Testing | 'testing' |
Monitoring only — packets are counted but not forwarded | ❌ No |
// Server with userspace NAT (no root required)
await server.start({
// ...
forwardingMode: 'socket',
enableNat: true,
});
// Server with bridge mode — VPN clients appear on the LAN
await server.start({
// ...
forwardingMode: 'bridge',
bridgeLanSubnet: '192.168.1.0/24', // LAN subnet to bridge into
bridgePhysicalInterface: 'eth0', // auto-detected if omitted
bridgeIpRangeStart: 200, // clients get .200–.250 (defaults)
bridgeIpRangeEnd: 250,
});
// Server with hybrid mode — per-client routing
await server.start({
// ...
forwardingMode: 'hybrid',
bridgePhysicalInterface: 'eth0', // for bridge clients
});
// Client with TUN device
const { assignedIp } = await client.connect({
// ...
forwardingMode: 'tun',
});
The userspace NAT mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring CAP_NET_ADMIN or root privileges.
The bridge mode assigns VPN clients IPs from a real LAN subnet instead of a virtual VPN subnet. Clients appear as if they're directly on the physical network — perfect for remote access to home labs, office networks, or IoT devices.
The hybrid mode runs both engines simultaneously. Each authenticated session's useHostIp setting determines whether its packets go through the bridge or socket NAT; routing does not look up identity from the packet source.
Inner packet MTU
mtu means inner IP bytes, not the outer network link MTU. It defaults to 1420
and must be an integer from 576 through 65472, leaving room for the supported
Noise and WireGuard framing. Larger previous values now reject before startup
effects. The server uses this same ceiling for its TUN/TAP, userspace stack and
native assignment; it does not subtract an estimated encapsulation overhead.
Native clients now send their receive ceiling inside the authenticated Noise IK
handshake. The server retains min(server MTU, client MTU) on that session,
advertises it with the actual assignment subnet, and applies it to ingress,
return encryption and the session's userspace TCP stack. Client configuration
defaults to 1420; the client uses the negotiated ceiling unchanged for its TUN
and both packet directions. Assignment fields must be valid before device setup;
there is no guessed /24, gateway or MTU fallback. This changes the native wire
contract: both ends must be upgraded together. Standard WireGuard framing is unchanged.
Eligible oversized outbound client IPv4 packets receive feedback through the client TUN using its assigned address and subnet. An incomplete or failed device write is an error, never counted as successful feedback or retried as a second packet. Oversized inbound packets are discarded before TUN writes.
WireGuard clients use the public flat wgPrivateKey, serverPublicKey,
wgAddress, wgAddressPrefix, wgEndpoint and nonempty wgAllowedIps fields.
The old Rust-only nested peer input is removed, with no translation fallback.
Addresses, prefixes, keys, endpoint and MTU validate before TUN setup. The configured
prefix supplies the device netmask; AllowedIPs route networks are canonicalized.
forwardingMode: 'testing' creates no TUN or routes; the existing WireGuard default
remains 'tun' (native defaults to 'testing'). Endpoint hostnames resolve in Rust.
AllowedIPs authorizes decrypted source addresses and outbound destinations, after
validating IP headers. The local configured MTU applies unchanged in both directions;
standard WireGuard has no native SmartVPN MTU negotiation.
client.getMtuInfo() now reports the live native or WireGuard runtime, or null when inactive.
tunMtu is null in testing mode because no interface exists; unmeasured
linkMtu and overheadBytes are also null. Counters belong to the current
connection and icmpTooBigSent counts only complete TUN writes. These nullable
results replace the previous hard-coded values.
Authenticated native and integrated WireGuard packets are size-checked after source/ACL/rate admission and before forwarding. Return packets are checked before encryption. Oversized packets are dropped even when no safe error can be sent. Eligible IPv4 DF packets receive a bounded fragmentation-needed response from the owned tunnel gateway or bridge host address, through their exact session. Invalid headers, ICMP errors, noninitial fragments, multicast and known broadcasts do not generate errors. Feedback is limited to 10 packets/second with a burst of 10 per server; internal accounting distinguishes dropped packets from feedback queued.
This is an inner admission ceiling, not IP fragmentation/reassembly or measured path-MTU discovery. Raw WG IPv6 remains kernel/testing-only and oversized IPv6 is dropped without fabricating an IPv6 router address. Standalone host-network cleanup and privileged WireGuard TUN qualification remain outstanding; the unprivileged packet fixtures do not establish host readiness for those modes.
🏠 Per-Client Bridge & VLAN Settings
When using bridge or hybrid mode, each client can be individually configured for LAN bridging, static IPs, DHCP, and 802.1Q VLAN assignment:
// Client that bridges to the LAN with a static IP
await server.createClient({
clientId: 'office-printer',
useHostIp: true, // bridge to LAN instead of VPN subnet
staticIp: '192.168.1.210', // fixed LAN IP
});
// Client that gets a LAN IP via DHCP
await server.createClient({
clientId: 'roaming-laptop',
useHostIp: true,
useDhcp: true, // obtain IP from LAN DHCP server
});
// Client on a specific VLAN
await server.createClient({
clientId: 'iot-sensor',
useHostIp: true,
forceVlan: true,
vlanId: 100, // 802.1Q VLAN ID (1-4094)
});
// Regular NAT client (default, no bridge)
await server.createClient({
clientId: 'remote-worker',
// useHostIp defaults to false → uses socket NAT
});
| Field | Type | Description |
|---|---|---|
useHostIp |
boolean |
true = bridge to LAN (host IP), false = VPN subnet via NAT (default) |
useDhcp |
boolean |
When useHostIp is true, obtain IP via DHCP relay instead of static/auto-assign |
staticIp |
string |
Fixed LAN IP when useHostIp is true and useDhcp is false |
forceVlan |
boolean |
Assign this client to a specific 802.1Q VLAN on the bridge |
vlanId |
number |
VLAN ID (1-4094), required when forceVlan is true |
VLAN support uses Linux bridge VLAN filtering — each client's TAP port gets tagged with the specified VLAN ID, isolating traffic at Layer 2.
📊 Telemetry & QoS
- Connection quality: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (
healthy/degraded/critical) - Adaptive keepalives: Interval adjusts based on link health (60s → 30s → 10s)
- Per-client rate limiting: Token bucket with configurable bytes/sec and burst
- Dead-peer detection: 180s inactivity timeout (all transports)
- MTU management: Enforced inner packet ceilings and live counters; unmeasured outer-path values remain null
- Per-transport stats: Active client and total connection counts broken down by websocket, QUIC, and WireGuard
🏷️ 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 |
// 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:
createClient() accepts IClientCreateOptions with a required clientId;
updateClient() accepts TClientUpdateOptions. Both reject unknown fields,
server-owned identity/key/address fields, malformed security, invalid IPv4 ACL
patterns, out-of-range integers and invalid VLAN combinations. Rejected settings
do not change the registry or consume a new address. forceTarget requires an
explicit IPv4 target; malformed policies are also rejected at server startup.
Omitting an update field preserves it; explicit null clears an optional setting.
Responses omit absent optional fields (including nested security/policy fields),
matching the TypeScript interfaces; null is a clearing input, not an output value.
A supplied security object replaces the whole security object, not individual
nested fields. Clear forceVlan together with vlanId; an enabled VLAN requires
an ID from 1 to 4094. Use serverDefinedClientTags; the shipped deprecated tags
input remains accepted, but supplying both names is rejected as ambiguous.
Preloaded entries also reject both populated tag fields; accepted old tags are
consumed into the canonical field and are not retained in registry output.
Input validation is separate from transport ownership and queued forwarding
revocation; the runtime ownership rules below apply after successful admission.
Native WebSocket/QUIC handshakes also recheck the exact registry incarnation and revision after sending the Noise response, before local admission. A successful update, disable/re-enable or remove/recreate invalidates an in-flight handshake; a rejected update does not. Network response writes do not hold the registry lock.
Native and integrated WireGuard transports share one live session owner per registered client. Authenticated reconnect cancels and joins the previous transport generation before admitting its replacement. Disconnect, successful settings changes, disable, expiry, removal and key rotation retire the old transport and its WireGuard return relay. Queued WireGuard returns carry their original session identity and cannot target a replacement. Failed key-rotation reconciliation does not restore old credentials; inspect the registry and retry the operation to obtain fresh credentials.
Registry assignments are reserved before listeners start and reused unchanged across native/WireGuard connections. Disconnect retains that reservation; record removal releases it only after transport teardown. Explicit assignments, including raw WireGuard reservations, precede dynamic allocation. A failed create cannot release an address belonging to a concurrent registry revision or recreated record. Decrypted registered-client packets must have a valid IPv4 header and exactly the assigned source address. Connection ACLs use the outer transport endpoint; destination ACLs use the inner packet destination. Runtime rate-limit adjustments apply to the current session only; use registry security settings for reconnects.
Socket and bridge forwarding queues retain authenticated session ownership until their packet work is destroyed. Socket mode gives each session its own smoltcp stack; policy and PROXY identity come from that captured session, never a mutable registry lookup by packet source. Its TCP/UDP flows and socket readers are joined on revocation, and replies return only to their original owner. Flow incarnations also fence late responses when a tuple is reused. UDP flows sharing a destination are selected by received source endpoint, and real UDP sockets accept replies only from their configured target.
Return queues are also generation-owned: cancellation purges queued payloads without waiting for a consumer, including WireGuard's shared return queue. Dequeued packets retain their destination lifetime, and internal relay packets retain their source lifetime too, until I/O finishes or the payload is destroyed. An interrupted native ciphertext write retires that destination tunnel rather than continuing a partially written frame or an advanced Noise nonce sequence. WireGuard returns require a current encryption session. While rekeying without one, application packets are dropped and counted; only a handshake is requested, so plaintext cannot escape owner cancellation through BoringTun's private queue. Queues are bounded (256 per session, 1024 for shared WG returns); full or closed queues reject admission. Managed relay uses these same two-owner lifetimes.
Socket NAT is bounded to 256 active engines and 1,024 flow incarnations per server
dispatcher. The per-client default is 128 TCP/UDP flows combined; a registered
client's security.maxConnections overrides that local limit without bypassing
the global cap. Late task/message references retain capacity until destroyed.
Protocol and dispatch queues are bounded. This IPv4 userspace build does not
reassemble fragments and rejects them before creating flows.
These guarantees cover daemon-owned work, not traffic already handed to an external network or kernel. Managed relay is described below. Transactional cleanup and privileged qualification of standalone kernel modes remain outstanding; packet fixtures do not prove production network readiness.
// Create (generates keys, assigns IP, returns config bundle)
const bundle = await server.createClient({ clientId: 'bob-phone' });
// Read
const entry = await server.getClient('bob-phone');
const all = await server.listRegisteredClients();
// Update (ACLs, tags, description, rate limits...)
await server.updateClient('bob-phone', {
security: { destinationAllowList: ['0.0.0.0/0'] },
serverDefinedClientTags: ['mobile', 'field-ops'],
});
// Enable / Disable
await server.disableClient('bob-phone'); // disconnects + blocks reconnection
await server.enableClient('bob-phone');
// Key rotation
const newBundle = await server.rotateClientKey('bob-phone');
// Export config (without secrets)
const wgConf = await server.exportClientConfig('bob-phone', 'wireguard');
// Remove
await server.removeClient('bob-phone');
Caller-owned managed networks
forwardingMode: 'managed' is an exclusive IPv4 node relay. The hub creates no
TUN, NAT, bridge or host routes. Start it with a fixed subnet and required
managedAuthorityId, then call reconcileManagedNetwork(snapshot). Cloudly (or
another trusted controller) owns durable node IDs, keys, control addresses,
workload prefixes and policy; SmartVPN owns volatile enforcement, not persistence.
Protect the management IPC endpoint: the authority ID is a binding, not a credential.
import type { IManagedNetworkSnapshot } from '@push.rocks/smartvpn';
const snapshot: IManagedNetworkSnapshot = {
schemaVersion: 1,
authorityId: 'cloudly',
revision: 1,
nodes: [{
nodeId: 'worker-a',
publicKey: '<caller-owned-noise-public-key-base64>',
controlAddress: '10.88.0.2',
controlPolicyDomain: 'control',
enabled: true,
workloadPrefixes: [{ cidr: '10.90.0.0/24', policyDomain: 'task.vc' }],
}],
grants: [], // Absent grants deny, including within the same domain.
};
const applied = await server.reconcileManagedNetwork(snapshot);
const status = await server.getManagedNetworkStatus();
const projection = await server.getManagedNodeProjection('worker-a');
Snapshots are complete replacements. Canonical array order makes exact replay
idempotent; stale revisions and conflicting equal revisions reject. Revisions
are positive JSON-safe integers scoped to lifetimeId. Every daemon restart
starts empty and requires the caller to reconcile its current durable state.
Node keys must be unique canonical/contributory X25519 keys, including across
Noise and WireGuard. Control addresses are exact usable hosts in the fixed subnet;
workload prefixes cannot overlap that subnet or any other owned prefix.
Grants are explicit (sourceDomain, destinationDomain) pairs. They neither imply
same-domain permission nor reverse/reply permission. Different workload prefixes
on one node may belong to different domains. Disabled, expired, missing, offline
or denied destinations drop without host fallback. Traffic that never crosses
the hub needs local enforcement by the node executor; local workload prefixes
are never routed back to the hub merely because a grant involves them.
Invalid preparation preserves active authority. A valid change quarantines and
cancels only affected node generations, joins their packet work, prepares WG
crypto and commits the whole snapshot atomically before acknowledging. Unchanged
nodes keep their sessions. A post-drain failure leaves state: 'failed', its
pendingRevision and bounded lastError; old affected admission stays closed.
Only the exact pending snapshot can retry. An IPC timeout or disconnected caller
does not cancel the process-owned operation. Inspect status and replay that exact
target; never assume a timed-out request did not commit. Separate Unix IPC
connections can inspect status while apply drains; stdio remains sequential.
Standalone create/update/remove/enable/disable/rotate/disconnect/rate-limit and
raw WG-peer mutations reject in managed mode, including direct WG-loop commands.
Standalone startup clients, raw peers, DNS, NAT, bridge and destination-routing
configuration reject before effects. Managed WireGuard requires an explicit
serverEndpoint and starts without peers until reconciliation.
Native clients opt in with managedNetwork: { authorityId: 'cloudly', nodeId: 'worker-a' }.
Missing, unexpected or differently bound managed assignments reject before host
effects; a managed client cannot downgrade to a standalone subnet assignment.
Native encrypted assignments carry schema, node/authority/lifetime/revision,
local ownership and remote outgoing routes. Client getStatus().managedNetwork
reports only the live validated assignment and clears on retirement. Native Linux
TUN setup uses a fresh nonpersistent /32 device with strict exclusive route adds,
not shell commands or route-conflict suppression. Preflight rejects malformed,
overlapping, default, local-prefix and active-hub-endpoint capture. The netlink
owner is polled inline; failure/cancellation drops the device and its owned routes.
No DNS or default-route mutation is requested; native forwardingMode: 'testing'
has no host effects. Managed TUN requires Linux.
The Linux amd64 musl build passed the isolated serve.zone/testing VM scenario:
real WS/Noise transport and TUN workload traffic between nodes connected only to
the hub, directed allow/deny, reconnect, natural revocation, partial route-conflict
rollback, empty-authority restart, and child termination. Cleanup removed owned
namespaces and preserved DNS and IPv4/IPv6 default routes. This qualification
does not cover WSS/private CAs, IPv6 workload traffic, privileged ARM64 networking,
standalone kernel modes, or Pallet/Cloudly production integration.
WireGuard has no assignment channel. getManagedNodeProjection(nodeId) returns
the applied control /32, per-prefix domains, outgoing destinations, incoming
sources, MTU and optional public WG configuration, never a private key. Client
WG AllowedIPs is the union of remote outgoing/incoming prefixes plus the hub's
MTU-feedback gateway /32; the hub independently enforces direction. Server WG
AllowedIPs contains the node's owned source prefixes. Authenticated WG keepalive
confirmation admits receive-only managed nodes without an inner probe. Projection
ready means applied/admissible configuration, not connectivity, node installation
or deployment readiness; disabled, absent and quarantined nodes return no config.
The controller must apply WG projections through its owned node runtime.
Bounds are 1,024 nodes, 128 workload prefixes per node, 16,384 owned prefixes, 16,384 grants, 128-byte IDs, 1,024 routes per node per direction, and 262,144 total projected routes/grants. Managed relay enforces both live negotiated MTUs and returns eligible IPv4 fragmentation-needed feedback to the exact source owner. The complete third-party notice index covers the locked Cargo graph and statically linked compiler/runtime components. Keep its accompanying notice files with separately redistributed executables.
📝 WireGuard Config Generation
Generate standard .conf files for any WireGuard client:
import { WgConfigGenerator } from '@push.rocks/smartvpn';
const conf = WgConfigGenerator.generateClientConfig({
privateKey: '<client-wg-private-key>',
address: '10.8.0.2/24',
dns: ['1.1.1.1'],
peer: {
publicKey: '<server-wg-public-key>',
endpoint: 'vpn.example.com:51820',
allowedIps: ['0.0.0.0/0'],
persistentKeepalive: 25,
},
});
// → standard WireGuard .conf compatible with wg-quick, iOS, Android
Server configs too:
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:
import { VpnInstaller } from '@push.rocks/smartvpn';
const unit = VpnInstaller.generateServiceUnit({
binaryPath: '/usr/local/bin/smartvpn_daemon',
socketPath: '/var/run/smartvpn.sock',
mode: 'server',
});
// 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.
📢 Runtime Events
VpnServer and VpnClient extend EventEmitter. The high-level wrappers currently forward bridge lifecycle events:
server.on('exit', ({ code, signal }) => { /* daemon process exited */ });
server.on('reconnected', () => { /* socket transport reconnected */ });
client.on('exit', ({ code, signal }) => { /* daemon process exited */ });
| Event | Emitted By | Payload |
|---|---|---|
exit |
Both | { code, signal } — daemon process exited |
reconnected |
Both | void — socket transport reconnected |
stderr |
Both | string — daemon diagnostic line, forwarded without automatic logging |
For connection state and telemetry, use getStatus(), getStatistics(), listClients(), and getClientTelemetry().
API Reference 📖
Classes
| Class | Description |
|---|---|
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 incl. bridge/hybrid, 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, bridge/VLAN settings) |
IClientSecurity |
Per-client ACLs, rate limits, and destination policy override (SmartProxy-aligned naming) |
IClientRateLimit |
Rate limiting config (bytesPerSec, burstBytes) |
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) |
IVpnServerStatistics |
Server stats with per-transport breakdowns (activeClientsWebsocket/Quic/Wireguard, totalConnections*) |
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 |
Exported event payload shapes for lifecycle and daemon event integrations |
Server IPC Commands
| Command | Description |
|---|---|
start / stop |
Start/stop the VPN listener |
reconcileManagedNetwork |
Validate, drain and atomically apply a complete managed snapshot |
getManagedNetworkStatus / getManagedNodeProjection |
Authority/lifetime/revision status and caller-applied node configuration |
createClient |
Generate keys, assign IP, return config bundle |
removeClient / getClient / listRegisteredClients |
Client registry CRUD |
updateClient / enableClient / disableClient |
Modify client state |
rotateClientKey |
Fresh keypairs + new config bundle |
exportClientConfig |
Re-export as SmartVPN config or WireGuard .conf |
listClients / disconnectClient |
Manage live connections |
setClientRateLimit / removeClientRateLimit |
Runtime rate limit adjustments |
getStatus / getStatistics / getClientTelemetry |
Monitoring (stats include per-transport breakdowns) |
generateKeypair / generateWgKeypair / generateClientKeypair |
Key generation |
addWgPeer / removeWgPeer / listWgPeers |
WireGuard peer management |
Client IPC Commands
| Command | Description |
|---|---|
connect / disconnect |
Manage the tunnel |
getStatus / getStatistics |
Connection state and traffic stats |
getConnectionQuality |
RTT, jitter, loss, link health |
getMtuInfo |
MTU and overhead details |
Transport Modes 🔀
Server Configuration
// All transports simultaneously (default) — WS + QUIC + WireGuard
{ transportMode: 'all', listenAddr: '0.0.0.0:443', wgPrivateKey: '...', wgListenPort: 51820 }
// WS + QUIC only
{ transportMode: 'both', listenAddr: '0.0.0.0:443', quicListenAddr: '0.0.0.0:4433' }
// WebSocket only
{ transportMode: 'websocket', listenAddr: '0.0.0.0:443' }
// QUIC only
{ transportMode: 'quic', listenAddr: '0.0.0.0:443' }
// WireGuard only
{ transportMode: 'wireguard', wgPrivateKey: '...', wgListenPort: 51820, wgPeers: [...] }
All transport modes share the same forwardingMode — WireGuard peers can use 'socket' (userspace NAT) just like WS/QUIC clients.
Client Configuration
// Auto (tries QUIC first, falls back to WS)
{ transport: 'auto', serverUrl: 'wss://vpn.example.com' }
// Explicit QUIC with certificate pinning
{ transport: 'quic', serverUrl: '1.2.3.4:4433', serverCertHash: '<sha256-base64>' }
// WireGuard clients use the standard .conf returned by createClient()
// or generated via WgConfigGenerator.
Cryptography 🔑
| Layer | Algorithm | Purpose |
|---|---|---|
| Handshake | Noise IK (X25519 + ChaChaPoly + BLAKE2s) | Mutual authentication + key exchange |
| Transport | Noise transport state (ChaChaPoly) | All post-handshake data encryption |
| Utility | XChaCha20-Poly1305 helper | Nonce-safe symmetric encryption helper in the Rust crypto module |
| WireGuard | X25519 + ChaCha20-Poly1305 (via boringtun) | Standard WireGuard crypto |
Binary Protocol 📡
All frames use [type:1B][length:4B][payload:NB] with a 64KB max payload:
| Type | Hex | Direction | Description |
|---|---|---|---|
| HandshakeInit | 0x01 |
Client → Server | Noise IK first message |
| HandshakeResp | 0x02 |
Server → Client | Noise IK response |
| IpPacket | 0x10 |
Bidirectional | Encrypted tunnel data |
| Keepalive | 0x20 |
Client → Server | App-level keepalive (not WS ping) |
| KeepaliveAck | 0x21 |
Server → Client | Keepalive response with RTT payload |
| SessionResume | 0x30 |
Client → Server | Session resume attempt |
| SessionResumeOk | 0x31 |
Server → Client | Session resume accepted |
| SessionResumeErr | 0x32 |
Server → Client | Session resume rejected |
| Disconnect | 0x3F |
Bidirectional | Graceful disconnect |
Development 🛠️
Native binary selection and build prerequisites
The package builds smartvpn_daemon_linux_amd64_musl and
smartvpn_daemon_linux_arm64_musl with the pinned Rust 1.95.0 toolchain and
locked Cargo dependencies. Both targets require genuine musl C toolchains for
the native ring and mimalloc dependencies. Put x86_64-linux-musl-gcc and
aarch64-linux-musl-gcc on PATH before building or testing; GNU libc headers
are not a substitute. The existing build and test commands remain unchanged.
In stdio mode the bridge selects exactly the bundled executable for Linux x64
or arm64. SMARTVPN_RUST_BINARY selects an explicit development executable on
any supported native build platform. Missing, non-executable, and empty explicit
selections fail: there is no fallback to old GNU artifacts, the current working
directory's Rust builds, platform packages, or a daemon found on PATH.
Other platforms must provide that explicit executable. Socket transport connects
to an independently managed daemon and does not require a local binary.
For an unprivileged build host, musl's upstream musl-gcc wrappers can provide
the C toolchains using existing native and cross GCC compilers. Build musl 1.2.5
in separate disposable prefixes for each architecture, with --disable-shared
and an explicitly prefix-local --syslibdir. Use the two musl security patches
from Rust 1.95.0's build recipe.
Expose the generated wrappers under the target-specific names above; never
install a replacement libc or dynamic linker into the host's system directories.
# Install dependencies
pnpm install
# Build (TypeScript + Rust cross-compile)
pnpm build
# Run all tests
pnpm test
# Run Rust tests directly
cd rust && cargo test
# Run a specific TS test
tstest test/test.flowcontrol.node.ts --verbose
Project Structure
smartvpn/
├── ts/ # TypeScript control plane
│ ├── index.ts # All exports
│ ├── smartvpn.interfaces.ts # Interfaces, types, IPC command maps
│ ├── smartvpn.plugins.ts # Dependency imports
│ ├── smartvpn.paths.ts # Binary path resolution
│ ├── smartvpn.classes.vpnserver.ts
│ ├── smartvpn.classes.vpnclient.ts
│ ├── smartvpn.classes.vpnbridge.ts
│ ├── smartvpn.classes.vpnconfig.ts
│ ├── smartvpn.classes.vpninstaller.ts
│ └── smartvpn.classes.wgconfig.ts
├── rust/ # Rust data plane daemon
│ └── src/
│ ├── main.rs # CLI entry point
│ ├── server.rs # VPN server + hub methods
│ ├── client.rs # VPN client
│ ├── crypto.rs # Noise IK + XChaCha20
│ ├── client_registry.rs # Client database
│ ├── acl.rs # ACL engine
│ ├── proxy_protocol.rs # PROXY protocol v2 parser
│ ├── management.rs # JSON-lines IPC
│ ├── transport.rs # WebSocket transport
│ ├── transport_trait.rs # Transport abstraction (Sink/Stream)
│ ├── quic_transport.rs # QUIC transport
│ ├── wireguard.rs # WireGuard (boringtun)
│ ├── bridge.rs # Linux bridge/TAP integration
│ ├── codec.rs # Binary frame protocol
│ ├── keepalive.rs # Adaptive keepalives
│ ├── ratelimit.rs # Token bucket
│ ├── userspace_nat.rs # Userspace TCP/UDP NAT proxy
│ ├── tunnel.rs # TUN device management
│ ├── network.rs # IP pool + networking
│ ├── telemetry.rs # RTT/jitter/loss tracking
│ ├── qos.rs # Priority queues + smart dropping
│ ├── mtu.rs # MTU + ICMP too-big
│ └── reconnect.rs # Exponential backoff + session tokens
├── test/ # Test files
├── dist_ts/ # Compiled TypeScript
└── dist_rust/ # Static musl binaries (Linux amd64 + arm64)
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.