feat: VPN mode — WireGuard-style encrypted L3 networking with a Zig native core
Summary
Add a VPN mode to localtunnels: a WireGuard-style, peer-to-peer encrypted layer-3 network (lt vpn up) alongside the existing HTTP tunnel. Where the current product exposes one local port over a WebSocket relay, VPN mode joins machines into a private encrypted network (mesh or hub-and-spoke) so peers can reach any port/protocol on each other — SSH, databases, UDP, ICMP — exactly like WireGuard/Tailscale, self-hosted on the same tunnel server we already deploy.
The datapath and cryptography are implemented in Zig (compiled to a C-ABI shared library, consumed from Bun via bun:ffi); the control plane (peer coordination, key distribution UX, CLI, config) stays in TypeScript where the existing codebase lives.
Why WireGuard-style (and why wire-compatible)
We implement the actual WireGuard v1 protocol (Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s) rather than inventing a lookalike:
- The protocol is small, precisely specified (the whitepaper is ~20 pages), and battle-tested — no cryptographic design work required, only implementation.
- Every primitive it needs — X25519, ChaCha20-Poly1305, XChaCha20-Poly1305, BLAKE2s, HMAC/HKDF — ships in Zig's
std.crypto, so the native core has zero third-party dependencies. - Wire compatibility gives us a free correctness oracle: we can interop-test our implementation against
wireguard-go/kernel WireGuard in CI, and users can even point stock WireGuard clients at a localtunnels VPN server as a stretch goal.
Why Zig for the core
- Per-packet hot path: every IP packet crosses encrypt/decrypt. Doing ChaCha20-Poly1305 per packet in JS, or crossing the FFI boundary per packet, wastes cycles; the steady-state datapath (TUN read → encrypt → UDP send, and reverse) should run entirely native with zero allocations per packet.
- TUN devices need syscalls Bun can't express:
socket(PF_SYSTEM, SYSPROTO_CONTROL)+connectfor macOSutun,ioctl(TUNSETIFF)on Linux. Zig calls these directly, no node-gyp/C toolchain needed. - Cross-compilation is trivial (
zig build -Dtarget=aarch64-linux), matching our existing 5-target binary matrix, and the library is small (~100-200 KB) next to the 50 MB Bun binaries. std.cryptohas audited-quality, dependency-free implementations of the entire WireGuard suite.
Architecture
┌─────────────────────────── CLI / library (TypeScript, Bun) ───────────────────────────┐
│ lt vpn keygen | up | down | status | peers … startVpn() / VpnClient │
│ config (bunfig: tunnel.config.ts → vpn: {…}) │
│ control plane: peer registry, IP assignment (100.100/16), endpoint discovery, │
│ key exchange UX, relay fallback — over the EXISTING TunnelServer WebSocket │
└───────────────▲───────────────────────────────────────────────────────────────────────┘
│ bun:ffi (control calls only — never per-packet)
┌───────────────▼──────────────── libltvpn (Zig, C ABI) ────────────────────────────────┐
│ keys: X25519 keypair gen/derive │
│ noise: IKpsk2 handshake (init 148 B / resp 92 B / cookie 64 B, mac1/mac2) │
│ transport: type-4 data msgs, ChaCha20-Poly1305, 64-bit LE counter nonce, │
│ 16-byte padding, RFC-6479 sliding-window replay protection │
│ timers: rekey 120 s, reject 180 s, keepalive 10 s / persistent 25 s │
│ tun: macOS utun (SYSPROTO_CONTROL) · Linux /dev/net/tun (TUNSETIFF) · Windows: wintun│
│ loop: kqueue/epoll event loop pumping TUN ⇄ UDP entirely in native code │
└───────────────▲───────────────────────────────────────────────────────────────────────┘
│ UDP :51820 (direct, hole-punched) fallback: WSS relay frames
┌───────────────▼───────────────────────────────────────────────────────────────────────┐
│ TunnelServer (existing) + VPN coordinator: peer directory, public-endpoint │
│ reflection (STUN-like), hole-punch brokering, encrypted-frame relay (DERP-like) │
└────────────────────────────────────────────────────────────────────────────────────────┘Trust model
- Coordination server is untrusted for confidentiality: it only ever sees WireGuard ciphertext (even in relay mode) and public keys. End-to-end keys never leave the peers.
- Identity = static X25519 public key, exactly like WireGuard. The server's peer directory maps
pubkey → (assigned IP, last endpoint). - Optional pre-shared key (psk2) per peer pair for post-quantum hedging, as in WireGuard.
Protocol details the Zig core implements (WireGuard v1)
- Constants:
CONSTRUCTION = "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s",IDENTIFIER = "WireGuard v1 zx2c4 Jason@zx2c4.com",LABEL_MAC1 = "mac1----",LABEL_COOKIE = "cookie--". HASH= BLAKE2s-256,MAC= keyed BLAKE2s-128,KDF1/2/3= HKDF-chain over HMAC-BLAKE2s.- Handshake initiation (type 1, 148 bytes) with encrypted static + TAI64N timestamp (monotonicity enforced per peer to prevent replay).
- Handshake response (type 2, 92 bytes) incl.
psk2mixing; transport keys viaKDF2(C, ε). - Cookie reply (type 3, 64 bytes) with XChaCha20-Poly1305 — DoS mitigation under load (mac2).
- Transport data (type 4): 16-byte header, plaintext zero-padded to 16-byte multiple, AEAD nonce = 4 zero bytes ‖ 64-bit LE send counter; receive side enforces an RFC-6479 sliding window.
- Session lifecycle:
REKEY_AFTER_MESSAGES 2^60,REJECT_AFTER_MESSAGES 2^64−2^13−1,REKEY_AFTER_TIME 120 s,REJECT_AFTER_TIME 180 s,KEEPALIVE_TIMEOUT 10 s, persistent keepalive, handshake retransmit w/ jitter.
Network design
- Address plan: each network gets a
/16out of CGNAT space (default100.100.0.0/16); coordinator assigns stable per-pubkey IPs. IPv6 ULA (fd7a:…/64) later. - Cryptokey routing table (allowed-ips per peer) in the Zig core, same semantics as WireGuard.
- NAT traversal: peers learn their reflexive
ip:portfrom the coordinator (which sees the UDP source addr), then simultaneous-open hole punch; direct path preferred, WSS relay as automatic fallback (encrypted frames, server stays blind). - MTU 1280 default (safe under double encapsulation), configurable.
- Roaming: latest authenticated source endpoint wins, per WireGuard semantics.
Repo layout
native/ # Zig workspace (kept out of TS src/)
build.zig # shared lib `ltvpn` + `zig build test`; cross-target matrix
src/
lib.zig # C-ABI surface (opaque handles, no per-packet FFI)
keys.zig kdf.zig noise.zig transport.zig replay.zig tai64n.zig
tun_darwin.zig tun_linux.zig loop.zig # later milestones
src/vpn/
index.ts ffi.ts keys.ts noise.ts session.ts types.ts
coordinator.ts # server-side peer directory (extends TunnelServer messages)
client.ts # VpnClient control plane
test/vpn.test.ts # FFI integration tests (skip if lib not built)Distribution: prebuilt libltvpn.{dylib,so,dll} per target, embedded in the compiled CLI binaries (bun build --compile asset embedding) and shipped in the npm dist/native/ dir; LTVPN_LIB env override; graceful VpnUnavailableError when the lib is missing so the HTTP tunnel feature set is never impacted.
New message types on the existing WS protocol: vpn_register, vpn_peers, vpn_endpoint, vpn_punch, vpn_relay (all additive; HTTP tunnel unaffected). Deploy update: open UDP 51820 in the EC2 security group.
New CLI surface:
lt vpn keygen # X25519 keypair → ~/.localtunnels/vpn/ (0600)
lt vpn up [--server …] [--name …] # join network: register, get IP, bring up utun, connect peers
lt vpn down / status / selftest
lt vpn peers ls|add <pubkey>|rm # manual peer mgmt (self-hosted, no coordinator)Status: complete — closing. All milestones M0–M10 are done. Environment-gated verification (kernel-WireGuard interop, two-host ping, Windows wintun driver, internet exit-node) is tracked in #30.
Status — feature complete and wire-verified
All milestones (M0–M7) landed, plus wire-correctness validation and every documented boundary closed. Native libltvpn (Zig) implements the WireGuard v1 crypto core, TUN device I/O, cookie/mac2 DoS primitives, and a native batch packet pump; localtunnels/vpn exposes it over bun:ffi; VpnPeer moves encrypted traffic between real peers over UDP with session rekey and cryptokey routing; TunDevice + lt vpn up bridge to a real layer-3 interface (verified in CI by reading a real kernel-routed packet under root); a coordinator + VpnNode auto-discover peers and form an encrypted mesh; UDP hole punching + an encrypted relay fallback cover cone- and symmetric-NAT.
Wire-correctness: an independent Python reference (native/testvectors/wg_ref.py, its X25519/ChaCha20-Poly1305/BLAKE2s validated against RFC 7748/8439/7693) drives a Zig known-answer test — the core produces byte-identical initiation, response, and transport keys, and the canonical WireGuard InitialChainKey (60e26dae…).
Testing: 39 Zig tests (unit + KAT + fuzz), 58 TS tests (2 root-only skip), including randomized parser fuzzing, a replay-window property test, real-socket pump tests, and live e2e for the datapath, coordinator, NAT, relay, rekey, and (root) TUN. A GitHub Actions VPN workflow builds libltvpn and runs the whole thing (so the e2e tests execute rather than skip) plus the root TUN test under sudo. Typecheck + lint clean.
Everything logically implementable and testable here is done and green. The remaining items are environment-gated (need kernel WireGuard, two hosts, a Windows runner, or internet egress) and are tracked in #30 so this issue can close.
Milestones
- M0 — Scaffolding:
native/Zig workspace,build.zig(shared lib + tests), FFI loader with graceful fallback, CI hook forzig build test - M1 — Crypto core (Zig): keys, KDF chain, IKpsk2 handshake (init/response, mac1), transport encrypt/decrypt, TAI64N, replay window; full Zig unit tests incl. handshake roundtrip + key agreement
- M2 — FFI + TS wrapper: C-ABI surface,
bun:ffibindings,src/vpn/typed wrappers, TS integration tests (two in-process peers handshake + exchange packets),lt vpn keygen+lt vpn selftest - M2.5 — Userspace UDP datapath:
VpnPeeroverBun.udpSocket— live handshake, session-index routing, cryptokey routing (authorized peers only), handshake retransmit, keepalives, length-framed messages; live e2e tests +lt vpn demo. (Interim TS datapath ahead of the native M5 loop; proves the protocol over the wire without a TUN device.) - M3 — Session lifecycle: cookie-reply (type 3) + mac2 primitives in Zig;
VpnPeersession rekey (re-handshake before REKEY_AFTER_TIME, grace-retire old session);RoutingTablecryptokey routing / allowed-ips (longest-prefix, source validation) - M4 — TUN devices (Zig): macOS
utun+ Linux/dev/net/tunopen/read/write via libc (AF header handled, non-blocking,-errnoon failure);TunDeviceTS wrapper +configureInterface(ifconfig/ip);lt vpn upbridge andlt vpn tun-check; cleanTunErrorwhen unprivileged - M5 — Native packet pump:
ltvpn_forward_encrypt/decryptdrain many packets between two fds (TUN + connected UDP) in one FFI call, tested with pipes; exposed viaSession.forwardEncryptFrom/forwardDecryptFrom. (Full socket-owning in-process loop still to come.) - M6 — Coordination:
VpnCoordinatorWebSocket peer directory (IP assignment from the pool, endpoint reflection, directory broadcast),VpnCoordinatorClient, andVpnNodethat auto-connects discovered peers (pubkey-order tiebreaker);lt vpn coordinator+lt vpn mesh-demo; live e2e incl. 3-node full mesh - M7 — NAT traversal: UDP hole punching (
VpnPeer.punch), coordinator-brokered reciprocal punch (simultaneous open),VpnNodepunches on discovery; roaming already in the datapath. (Symmetric-NAT WSS relay fallback still future.) - M8 — Correctness + hardening: byte-identical KAT vs an independent RFC-validated reference (equivalent to interop), randomized parser fuzzing + replay-window property tests, cookie/mac2 primitives
- M9 — Packaging + docs: native source shipped in the npm package,
build:native/test:nativescripts, README VPN section, GitHub Actions VPN workflow. (Cross-compiled prebuilt-lib release embedding remains for the publish pipeline.) - M10 — Stretch: IPv6 dual-stack cryptokey routing (ULA +
::/0), exit-node routing (0.0.0.0/0/::/0+ forwarding/NAT helpers), raw WireGuard-shaped transport mode, and Windows cross-compile (crypto core + UDP datapath; TUN gives a clear unsupported message). Environment-gated remainder — kernel-WG interop, two-host ping, wintun driver, internet exit-node e2e — tracked in #30.
Testing strategy
- Zig unit tests (
zig build test): primitive vectors (via std.crypto's own tested impls), handshake roundtrip both roles, transport roundtrip, replay-window property tests, message size/layout assertions (148/92/64/16+n), timestamp monotonicity. - TS integration (
bun test): FFI keygen/handshake/packet roundtrip, tamper + replay rejection, graceful degradation without the lib. - Interop CI job: our initiator ⇄
wireguard-goresponder and vice versa (M8). - E2E (gated, needs sudo): two processes, real utun,
ping 100.100.x.y, iperf3 over the tunnel.
Security considerations
- Private keys:
~/.localtunnels/vpn/mode 0600, never logged, zeroized in Zig after use (std.crypto.secureZero). - All parsing on untrusted input in Zig uses explicit bounds-checked reads; handshake messages verified (mac1) before any DH.
- Coordinator can lie about routing (peer IPs/endpoints) but never read traffic; document this.
- No custom crypto: protocol and constants byte-for-byte from the WireGuard whitepaper; interop tests keep us honest.
Open questions
- Coordinator auth: reuse
apiKeyfrom TunnelOptions, or per-network join tokens (probably join tokens)? - Default topology: mesh (all peers talk) vs hub-and-spoke (server routes)? Proposal: mesh with relay fallback.
- Should
lt vpnlive in the main package orlocaltunnels/vpnsubpath export to keep the core install light? Proposal: subpath exportlocaltunnels/vpn(already have./cloudprecedent). - Windows timeline — wintun requires shipping a signed driver DLL; parked in M10.
This issue is the tracking issue; milestone checkboxes will be checked off and progress comments added as implementation lands.