ReviewOS

stacks/localtunnels

VPN platform: the step-by-step path from working engine to shippable product (control plane, macOS UI, us-west fleet)

#33
Open glennmichael123 opened this 9 days ago ·
9 days ago

We have a working VPN engine. We do not yet have a VPN platform. This issue separates those two things, surveys how the industry standardizes the second one, and lays out the step-by-step path from here to there — including the macOS UI in system-cleaner and a us-west server fleet.

Follow-up to #28 / #31. Engine gaps stay in #30; this issue is strictly about what sits above the datapath.


1. Where we actually stand

Verified locally on zig 0.17.0-dev.2163, bun 1.4.3-canary.1:

CheckResult
bun run build:nativeclean
bun run test:native50 Zig tests pass
bun test packages81 pass, 2 skip, 0 fail
vpn:selftestkeygen / IKpsk2 / transport / replay all ok
vpn:demo (real UDP)handshake 1.8 ms, 100/100 both ways, ~15.7k msg/s
vpn:mesh-democoordinator assigned 100.100.0.2/.3, 100/100 through the mesh

That is a real WireGuard v1 implementation: ~2.8k lines of dependency-free Zig (libltvpn) with Noise_IKpsk2, ChaCha20-Poly1305, RFC-6479 replay, DoS cookies and TUN I/O, plus ~2.3k lines of TS control plane, 7 wired CLI commands and IaC deploys. The commit history is hardening, not greenfield.

The engine is done. Everything in the rest of this issue is the part that isn't.


2. How VPN platforms standardize

There are thousands of VPN products and approximately six planes. Every product is a recombination of the same stack, and knowing which plane you are missing is more useful than knowing which competitor you resemble.

PlaneWhat it doesIndustry convergence
1. Protocol / dataEncrypt and move packetsSettled on WireGuard (Noise_IKpsk2). OpenVPN is legacy, IKEv2 is enterprise/MDM
2. ControlKey distribution, network map, IP allocationTailscale coordination server; NetBird splits management + signal; Headscale reimplements Tailscale's server protocol
3. NAT traversalGet two peers talking through NATSTUN/ICE for discovery, encrypted relay fallback (Tailscale DERP, NetBird TURN). Relays see ciphertext only
4. Identity & policyWho may join, which device, reaching whatOIDC/SSO + pre-auth "setup keys" for headless, ACL-as-code, device key expiry + re-auth
5. Name & routing servicesReach peers by name; subnet routers; exit nodes; split DNSMagicDNS-style host.tailnet.ts.net, advertised routes, exit-node flag
6. Client & fleetPer-OS clients, server fleet, regions, observability, billingPer-OS tunnel providers; fleet of exit nodes; RAM-only/no-logs posture + third-party audits

The two product archetypes

This matters because they imply different roadmaps, and we are currently ambiguous between them:

  • Archetype A — mesh / private network (Tailscale, NetBird, ZeroTier, Nebula, Headscale). Peer-to-peer, identity-centric, ACL-driven. The coordinator is the product. Sold per-user/per-device. Hard part: plane 4.
  • Archetype B — consumer privacy VPN (Mullvad, Proton, Nord, IVPN). Client → exit node, fleet-centric, no-logs/RAM-only/audits. The server fleet is the product. Sold per-subscription. Hard part: plane 6.

We have the engine for both and the product for neither. Our coordinator shape points at A; the immediate "us-west exit node I actually use" goal is B. Both are reachable from here, and Phase 1 is shared, but the ordering after that differs. This is the first decision below.

De facto standards worth conforming to

Adopting these is mostly free and buys interoperability and user familiarity:

  • wg(8) conf format + wg-quick semantics for config interchange — we already emit client-lt.conf
  • CGNAT space 100.64.0.0/10 (RFC 6598) for tunnel addresses. Our 100.100.0.0/16 default already sits inside it ✅
  • OIDC device authorization grant (RFC 8628) for headless enrollment, plus pre-auth keys for CI/servers
  • ACL-as-code policy file, versioned in git, compiled down to per-peer allowed-ips
  • STUN (RFC 5389) / ICE (RFC 8445) / TURN (RFC 8656) for traversal
  • Device key expiry defaults (Tailscale uses 180 d) with explicit rotation and revocation
  • Relay carries ciphertext only — ours already does ✅

Honest plane-by-plane audit

PlaneStatusGap
1. Protocol/data✅ DoneLive kernel-WG interop still unproven (#30)
2. Control🟡 PrototypeVpnCoordinator is an in-memory Map. No persistence. IP allocation is a monotonic nextHost++ that never reclaims
3. NAT traversal🟡 PartialHole punching (MSG_PUNCH) and relay fallback both work and are tested. But endpoint discovery reflects the WebSocket source address + self-reported port — not STUN. Correct for LAN/same-NAT; symmetric NAT falls to relay. Docs oversell this slightly
4. Identity & policy🔴 MissingVpnCoordinator has no authentication whatsoever — any node that connects is registered and handed an IP. No users, orgs, devices, ACLs, expiry or revocation. This is the single biggest gap
5. Name & routing🟡 PartialExit node + RoutingTable + cryptokey routing done. No DNS/naming, no subnet routers, no advertised-route plumbing
6. Client & fleet🔴 Mostly missingCLI only. Deploy is one box, one region, hardcoded Hetzner fsn1 (Germany). No macOS/Windows GUI, no fleet, no health/latency, no observability

3. The roadmap

Phase 0 — close the engine's last gaps (prereq, small)

  • 0.1 Live interop vs kernel WireGuard / wireguard-go, both directions — see #30. Our wg_ref.py byte-for-byte vectors are strong evidence but are a second implementation by the same author, not the canonical one. Docs claim stock-client compatibility in two places; this either proves it or finds the one byte that's off
  • 0.2 Replace WS-reflected endpoints with real STUN binding discovery, so symmetric-NAT peers get a direct path instead of silently relaying
  • 0.3 Reconcile docs with reality on NAT traversal (docs/features/vpn.md feature summary vs the coordinator.ts comment)
  • 0.4 (defer unless Windows is in scope) wintun — #30 item 3

Phase 1 — control plane: identity, persistence, policy (the real platform work)

This is the phase that turns the engine into a platform. Shared by both archetypes.

  • 1.1 Persistent store. SQLite behind the coordinator: orgs, users, devices, device_keys, ip_leases, policies, audit_log. Replaces the in-memory Map
  • 1.2 Authentication — close the open coordinator. Staged: (a) pre-auth/setup keys (single-use + reusable, TTL, tag-scoped) which unblocks CI and servers immediately; (b) OIDC device flow (RFC 8628) for interactive enrollment
  • 1.3 Device lifecycle. Enrollment, stable device identity, key expiry (default 180 d), rotation, revocation that actually evicts from peers' network maps
  • 1.4 ACL-as-code. Policy file (HuJSON-ish), git-versioned, compiled to per-peer allowed-ips. Default-deny for new orgs
  • 1.5 IP lease management. Stable per-device addresses, reclamation, explicit subnet config. Fixes the nextHost++ leak
  • 1.6 Network-map push. Incremental map updates on peer join/leave/revoke rather than full re-broadcast

Phase 2 — network services

  • 2.1 DNS / naming — MagicDNS equivalent: device.<net>.<domain>, split-DNS resolver config per platform
  • 2.2 Subnet routers — advertise and approve CIDRs so one node fronts a whole LAN
  • 2.3 Exit-node advertisement in the control plane (today it's a vpn:up flag), so clients can choose an exit
  • 2.4 Standalone relay — DERP-equivalent as its own deployable, with a relay map served by the coordinator and latency-based home-relay selection

Phase 3 — server fleet, us-west first

  • 3.1 Region abstraction in deploy/. Today deploy/config.ts hardcodes HETZNER_LOCATION = 'fsn1' and defaults AWS_REGION to us-east-1. Introduce --region with a provider→location→size map
  • 3.2 us-west box. Options below; Hetzner hil (Hillsboro, OR) is the price/perf pick, AWS us-west-2 (Oregon) if we want to stay in AWS
  • 3.3 Multi-node fleet state. deploy/.state.json holds one box; make it a fleet keyed by region, so verify/destroy act per node
  • 3.4 Health + latency endpoint per node so clients pick the nearest exit
  • 3.5 Hardening posture. Diskless/RAM-only where the provider allows, no-logs by construction, firewall defaults, unattended upgrades, reproducible provisioning

Region gotchas worth knowing before 3.2. Hetzner's US locations (hil Hillsboro OR, ash Ashburn VA) are cloud-only and do not carry the cx Intel line we currently use — BOX_SIZES.hetzner = 'cx23' will not provision in hil. US needs the cpx (shared AMD) or ccx (dedicated AMD) lines. More importantly for a VPN: US boxes include ~1 TB egress vs ~20 TB in the EU, which is the binding constraint on an exit node, not CPU. On AWS, us-west-2 works but egress is ~$0.09/GB, which is the expensive way to run a tunnel. Hetzner hil + cpx is the recommendation, with the 1 TB cap monitored.

Phase 4 — clients

  • 4.1 Headless/CLI — ✅ done
  • 4.2 macOS GUI via system-cleaner — see section 4
  • 4.3 Windows (needs 0.4), Linux GUI, iOS/Android — later, and mobile means a NetworkExtension provider on Apple and a VpnService on Android

Phase 5 — platform surface

  • 5.1 Admin API + web dashboard (device list, ACL editor, key management)
  • 5.2 Observability — per-node metrics, handshake success rate, relay-vs-direct ratio, egress accounting
  • 5.3 Billing/licensing, if this becomes commercial
  • 5.4 Transparency posture — audit trail, documented data retention, eventually third-party audit. Note the industry lesson: RAM-only hardware proves nothing about what the software ships off-box, and an audit is a point-in-time check

4. The macOS VPN UI in system-cleaner

system-cleaner is a Stacks app with a Craft desktop launcher — already the right shape for this, and it already does privileged system work.

Existing patterns to follow: resources/components/*Panel.stx + resources/views/app/*.stx + an entry in resources/components/AppRail.stx + POST routes in routes/api.ts. Data binds client-side through x-data.

Three constraints from its CLAUDE.md that shape the design:

  1. The bundle serves prerendered HTML — nothing machine-specific may come from a <script server> block, or it freezes to the build machine forever. All VPN state must travel over the API and bind client-side, exactly like /api/dashboard-stats does.
  2. x-data literals: no // comments, no double quotes, and never mutate reactive state in place (assign a new value; concat, not push).
  3. stx only — signals/composables, Crosswind utilities, Iconify icons. No vanilla JS, no hand-rolled SVG.

The privilege question is the real design decision. A macOS app that moves packets has two viable shapes:

OptionHowTrade-off
(a) Privileged helper + utun ← recommended for v1A launchd root helper runs the existing lt vpn:up datapath; the UI talks to it over loopback/XPCReuses the engine as-is. No Apple entitlement needed. Fits the current Developer ID signing and the existing NSSystemAdministrationUsageDescription. Does not appear in macOS's own VPN settings
(b) NetworkExtension system extensionA NEPacketTunnelProvider packaged as a system extension with com.apple.developer.networking.networkextension = packet-tunnel-provider-systemextensionThe "proper" integration — shows up in System Settings, survives reboots, no root helper. But the provider must be a Swift/ObjC system extension; Bun cannot be it. Developer ID builds require the -systemextension suffix (app-extension packaging is Mac App Store only). Significant new surface

Recommend (a) for v1 and treat (b) as a Phase 4 follow-up if we want App Store distribution or native VPN-settings integration.

Staged delivery:

  • 4.2.1 Read-only status panel — no privilege required. VpnPanel.stx + resources/views/app/vpn.stx + AppRail entry (i-f7-lock-shield-fill). Card row (state / exit region / tunnel IP / throughput) and a peer table, following ProcessesPanel.stx. Backed by a new POST /api/vpn-status that shells the existing lt vpn:* commands. Ships a real screen with zero new privileged code
  • 4.2.2 Connect / disconnect — the root helper from (a), plus POST /api/vpn-connect / /api/vpn-disconnect, with the live indicator reusing the live-indicator / feedConnected pattern already in ProcessesPanel.stx
  • 4.2.3 Region picker — POST /api/vpn-regions fed by the Phase 3.4 health/latency endpoint, so the user picks "us-west" and sees latency
  • 4.2.4 Identity & keys — surface vpn:keygen identity, device name, key expiry; wire to Phase 1 enrollment

Note system-cleaner is a separate repo — these items will be mirrored into an issue there; this list is the contract between the two.


5. us-west servers

Immediate, independent of everything above, and the thing to do first since it's the stated goal:

  • 5.a Parameterize region in deploy/config.ts (--region, provider→location→size map), replacing the hardcoded HETZNER_LOCATION = 'fsn1'
  • 5.b Switch the US size to the cpx line — cx23 cannot provision in Hetzner US locations
  • 5.c Deploy localtunnels-vpn-usw to Hetzner hil (Hillsboro, OR) and run bun run verify:vpn against it
  • 5.d Add egress monitoring — the ~1 TB US allowance is the real limit on an exit node
  • 5.e Confirm deploy/lt-e2e.sh (netns two-host ping + exit routing) passes in hil, which also partly discharges #30 items 2 and 4

6. Decisions needed before Phase 1

  1. Archetype A or B — identity-driven mesh, or exit-node fleet? Phase 1 is shared; Phase 2 vs Phase 3 ordering flips on the answer. (Current signal: coordinator says A, the us-west goal says B.)
  2. Self-hosted-first or hosted service? Decides whether the coordinator needs multi-tenancy in Phase 1.1 or can stay single-org.
  3. Wire-compat as a hard guarantee? If stock WireGuard clients must keep working, 0.1 becomes a CI gate rather than a one-off check.
  4. macOS: ship (a) and stop, or commit to (b)? Only (b) gets App Store distribution and native VPN-settings integration.

Sources for the standardization survey

Tailscale DERP servers · NAT traversal · policy file syntax · connect to devices · mesh VPN comparison 2026 · NetBird vs Headscale · Apple Network Extension entitlement · appex vs sysex on macOS · Hetzner locations · cx not available in US · RAM-only servers

🤖 Generated with Claude Code

9 days ago

Progress: 5 commits on main

CommitWhat
a575a68--region for the deploy, defaulting to us-west (3.1, 5.a, 5.b)
a9d459aRegion docs + NAT traversal claim reconciled (0.3)
95417b3Coordinator registration gated behind an auth key (1.2 partial, 1.5 partial)
95b53c9vpn:coordinator mints a key instead of running open
72ce4d7Drive-by: --host was unusable on start/server/vpn:coordinator

Verification: typecheck clean, 50 Zig tests pass, 89 TS tests pass / 0 fail (was 81), vpn:selftest and vpn:mesh-demo both healthy — the mesh demo now runs over an authenticated coordinator.

Done

  • 3.1 / 5.a / 5.b — regions are provider-agnostic aliases resolved to a native location plus an orderable size. cx23 is gone from US locations, where it could never have provisioned. Region is recorded in deploy state so verify/destroy follow it.
  • 0.3 — the NAT traversal claim now says which NAT types actually reach a direct path.
  • Coordinator auth — a shared secret compared in constant time over SHA-256 digests; a public key can be claimed by one live connection at a time; tunnel IPs are leased per key and survive reconnects. 8 new tests.

Partially done, still open

  • 1.2 — pre-auth keys land here, but it is one shared secret for the whole network, not per-device keys with TTL and revocation. OIDC device flow untouched.
  • 1.5 — leases are now stable per key, which fixes the nextHost++ burn on reconnect, but nothing reclaims an address from a key that never returns.

Not started

5.c — no server deployed yet; deliberately held off pending a provider decision (Hetzner hil needs a token on the deploy machine; AWS us-west-2 works today but meters egress per GB, which is the expensive way to run a tunnel).

New finding

lt vpn:up has no --coordinator flag and never has, so the coordinator-driven mesh is reachable only through the VpnNode API and vpn:mesh-demo. The CLI banner was advertising that flag; it now shows the API instead. Wiring VpnNode into vpn:up belongs in 4.1/2.x and is not yet filed as its own item.

9 days ago

VPN logic: the two halves now meet

Four more commits. The gap this closes was not on the original list, because I only saw it when reading vpn:up next to VpnNode:

VpnPeer + TUN could carry real IP packets but only to peers named on the command line. VpnNode could discover peers through the coordinator but only ever delivered application messages. A coordinator-driven mesh could not carry a packet, and the datapath that could carry one knew nothing about discovery. Neither half was a usable VPN.

CommitWhat
5febc1cTunBridge extracted, inbound source verification added
87d01feconfigureInterface sets the MTU
65099d0VpnInterface — joins a coordinator and carries real traffic
683a0b7vpn:up --coordinator, and it now works on macOS

119 TS tests pass / 0 fail (was 89), 50 Zig tests pass, typecheck and lint clean.

Three bugs found while building, all fixed

  1. Inbound cryptokey routing was never applied. The table had isAllowed and the datapath never called it, so an authorized peer could source packets as any address — another peer's tunnel IP, or a host off the tunnel entirely — and the kernel accepted them. The bridge now drops those. The exit-node case needs no exemption: a client routing 0.0.0.0/0 to its server already routes every source back to that server.
  2. vpn:up could never have run on macOS. It brought the interface up with its own ip addr add / ip link set calls while configureInterface — which handles ifconfig and the utun point-to-point address — sat unused beside it. Both platforms now go through that helper.
  3. Stale routes when a peer's address changes in place. Reconciling by public key read a reassigned peer as "already routed", leaving the new address resolving to nobody and the old one still resolving to the peer that moved — and the peer could still source the old address but not its real one. This is what a coordinator restart looks like, since that clears leases and reissues addresses. Reconciliation is now by route, not by key.

Verified

vpn:up --coordinator against a live authenticated coordinator: the right key clears auth, gets an address, and fails only at the privileged TUN step; a wrong or missing key is refused with coordinator rejected registration: missing or invalid auth key.

Not verified: the privileged TUN open/configure step itself — sudo needs a password here, so it could not run unattended. Command generation is unit-tested and shared with the previously-tested helper; on macOS it emits ifconfig utun4 100.100.0.2 100.100.0.1 up / ifconfig utun4 mtu 1420 / route -n add -net 100.100.0.0/16 -interface utun4. Worth one sudo lt vpn:up run before relying on it.

This is the API the desktop app drives

const vpn = new VpnInterface({ keyPair, coordinatorUrl, authKey })
const { name, address } = await vpn.up()   // utun4, 100.100.0.2
vpn.stats                                   // packet counters + drop reasons
vpn.down()

4.2.1 (read-only status panel) can be built against vpn.stats and the peerAdded/peerRemoved/link events now.

Still open in Phase 1/2

2.3 exit-node advertisement is the next real gap for the us-west use case: a peer cannot yet tell the coordinator "I am an exit node", so a client cannot discover one and route 0.0.0.0/0 through it. Static mode can do this with --allowed-ips 0.0.0.0/0; coordinator mode cannot.

9 days ago

2.3 exit-node advertisement: done

Three commits. The coordinator could say where a peer is and which address it owns, never what it offers — so an exit node was only reachable in static mode by naming it with --allowed-ips 0.0.0.0/0.

CommitWhat
c116357A node can advertise itself as an exit; the coordinator relays it
23c33abVpnInterface routes through a discovered exit (useExit)
991e71bvpn:up --use-exit, and --exit-node advertises on a coordinator

134 TS tests pass / 0 fail (was 119), 50 Zig tests, typecheck + lint clean.

The us-west flow now works end to end

# On the box
sudo lt vpn:up --coordinator ws://coord:51821 --auth-key "$KEY" --exit-node

# On the laptop
sudo lt vpn:up --coordinator ws://coord:51821 --auth-key "$KEY" --use-exit <box-pubkey>

Verified against a live coordinator

Ran the real chain — coordinator, an advertising exit, a plain peer, and a laptop — then fed the directory the laptop actually received into the routing policy. All 14 checks passed:

  • the advertising peer is chosen, and internet traffic routes to it
  • a plain mesh peer still routes direct (longest prefix beats the default)
  • the exit may source internet replies inbound; a plain peer may not
  • with no exit configured, internet traffic has no route and is dropped, rather than leaking out of the host's own interface
  • a pinned key is honoured, including when that peer does not advertise

Design note on trust

The advertisement is a claim the coordinator cannot verify — any member holding the auth key can make it. So:

  • auto takes the lowest-sorted advertising peer. Deterministic, so every node agrees and a reconnect does not quietly move traffic to a different volunteer. But it still trusts the claim.
  • Pinning a public key is honoured whenever that peer is present, advertising or not, because naming a key is already the decision.

This is called out in the docs and at the field declaration. Per-device keys (1.2) are what would make the claim checkable; with one shared secret it cannot be.

Implementation note: the default route is folded into the exit peer's own route set rather than tracked beside it, so changing exit is just a change of route set and the existing signature diff handles it — the old exit loses the default route and keeps its own address in one step.

Only the IPv4 default is installed. Coordinator mode assigns a v4 address, so the kernel has no reason to put v6 on the interface, and a ::/0 route would only disguise having nowhere to send it.

Still unverified

The privileged TUN step. sudo needs a password here, so nothing above exercised a real interface — one sudo lt vpn:up run would close that out.

Next: 4.2.1, the system-cleaner status panel.

9 days ago

4.2.1 done: the VPN screen in system-cleaner

Three commits in stacksjs/system-cleaner: 4874d74 (status behind /api/vpn-status), 0931e32 (probe fix), 2eddf41 (rail entry, view, panel).

322 tests pass / 0 fail, typecheck clean, 0 lint errors.

Verified in the real app, not just built

Served the prerendered UI through the desktop agent (app/Desktop/server.ts, the same binary the .app runs) and drove it in a browser. It renders this Mac's actual state: identity present, public key and path, interface table, and the Re-check button firing 200s with no console errors. Confirmed both the unhealthy and healthy core states by pointing LOCALTUNNELS_BIN at a freshly compiled CLI.

Two bugs found by looking at it

  1. Stale derived state. The Core card read Not installed after a response said the core was healthy, while core.version bound two lines below it updated correctly. STX evaluates an x-data getter once while building the scope and never re-runs it. Converted to methods, and tests/xdata-parses.test.ts now rejects getters outright — same silent class of failure as the comment and double-quote rules it already guards.
  2. The probe inherited the host's bunfig. The CLI is a compiled Bun binary and Bun reads bunfig.toml from the cwd, so probing it from inside system-cleaner handed it that project's preload and both --version and vpn:selftest failed with preload not found. Probes now run from the temp directory.

Honest about what it cannot say

The kernel does not record which process asked for a utun, and macOS hands them out to iCloud Private Relay and every other VPN. So an interface is marked localtunnels when its address falls in a range we assign — 100.64.0.0/10, 10.8.0.0/24 — and the screen says in as many words that this is a hint and not proof. Identity is read and never created: vpn:keygen mints a keypair on first use, which is right for a typed command and wrong for a screen someone opened, and a test pins that a read does not write the private key.

Note for 4.2.2

Worth knowing before the helper work: a compiled CLI cannot find libltvpn on its own. Resolution in ffi.ts is relative to the source file's location, which inside a single-file binary points into the embedded bundle — so a standalone build needs LTVPN_LIB set. An npm install resolves normally. Whatever ships the helper has to carry the library's path.

Also unchanged from before: nothing has exercised a real TUN interface, since sudo needs a password here.

9 days ago

Fixed: compiled binaries could never load libltvpn

eccfafd + cdf2d15. 141 TS tests pass / 0 fail, typecheck + lint clean.

Every candidate path came from import.meta.url, which inside a bun build --compile binary points into the embedded filesystem rather than at a directory on disk. All six collapsed to nonsense:

native libltvpn not found. Looked in:
  /dist/native/libltvpn.dylib
  /native/zig-out/lib/libltvpn.dylib
  /vpn-core/zig-out/lib/libltvpn.dylib
  ...the same three again

That is every binary compile:all produces, on all five platforms — no handshake, no transport, no tunnel, and a failure listing six places that could never have held anything. Only a source checkout or an npm install with Zig ever worked.

The library is now looked for beside the executable and in ../lib of it first. Source-relative candidates are kept but taken only from a directory holding a package.json — testing that the directory exists was not enough, because the embedded filesystem answers existsSync happily and both roots collapse to /.

Verified against a real compiled binary in three layouts (beside the library, bin/ + lib/, neither) plus dev mode. The seven new tests fail on the old resolver and pass on this one.

Also corrected: a docs claim that was false

docs/features/vpn.md said the library "ships prebuilt in the npm package". It does not — build.ts copies the Zig source into native/, files publishes native/build.zig and native/src, and nothing ever writes the dist/native/ path the resolver checks. Anyone reading that line expected a working VPN from bun add localtunnels and got an error telling them to build.

Open decision: should releases ship the library?

The resolver fix makes a co-located library work, but nothing currently produces that layout. zip:* packs the binary alone, and the release workflow has no Zig step. So a downloaded release still needs LTVPN_LIB or a hand-built library placed next to the binary.

Closing that means cross-compiling libltvpn for all five targets and adding it to each zip — a release-pipeline change, not a code fix, so it is left for an explicit decision. Worth noting the Windows naming mismatch if it goes ahead: the resolver wants libltvpn.dll while zig build emits ltvpn.dll.

9 days ago

Releases now ship the library

d429c47 + ee7e695. 141 TS tests / 0 fail, 50 Zig tests, typecheck + lint clean.

Found while wiring it: the library never built for Windows

zig build -Dtarget=x86_64-windows
  lld-link: undefined symbol: __error
    referenced by src/lib.zig:318 (ltvpn_forward_decrypt)
    referenced by src/lib.zig:310 (ltvpn_forward_encrypt)

The packet pump normalises errno through tun.macErrno, which is libc's __error — a BSD accessor absent on Windows. Two functions referencing it stopped the entire library existing for that target, crypto core included, which the docs have claimed Windows support for all along. The pump returns EINVAL there now; Windows has no descriptor to pump anyway, since wintun is a ring-buffer API and TunDevice.open() already refuses there.

Nothing caught it because nothing built for Windows. CI now cross-compiles all five release targets and fails if any produces no shared library.

Packaging

bun run package replaces compile:all + zip:all: per target it compiles the CLI, cross-compiles libltvpn, and zips the two flat so unzipping leaves them side by side. The release workflow gained a zig step.

Two target disagreements, both handled rather than left to fail in CI:

  • Windows emits ltvpn.dll — no lib prefix, that is a Unix convention — while the resolver wants libltvpn.dll, since Bun's suffix only gives the extension. Renamed into the zip so one name is right everywhere.
  • Windows installs that dll into bin/, leaving only the import library in lib/. Checking only lib/ found nothing for the one target whose layout differs, so both are searched.

Verified

Unzipped a packaged artifact into an empty directory and ran vpn:selftest from an unrelated cwd with no LTVPN_LIB:

Key generation:      ok
Handshake (IKpsk2):  ok
Transport roundtrip: ok
Replay protection:   ok
VPN core is healthy.

Partially verified: only the darwin-arm64 target was packaged end to end. This machine's Bun cannot download cross-targets (Target platform 'bun-linux-x64-v1.4.3' is not available) — pre-existing, compile:linux-x64 fails here identically, and CI's released Bun does not have this limitation. The zig half of all five targets was verified locally, as was the library lookup against each real prefix layout.

9 days ago

4.2.2 done: connect and disconnect from the VPN screen

Two commits in stacksjs/system-cleaner: 18ddc56 (control module) and 7ff2480 (routes + UI).

No privileged helper after all

I went in expecting to build a launchd root daemon, and reading the codebase first changed the design. The app already has a posture here: packages/uninstall elevates for bounded operations through osascript ... with administrator privileges, and bulkDelete refuses to touch /Library and points at Terminal instead. Elevate for one legible command, never for a broad one.

Bringing up a tunnel fits the first category, so it takes the same path. No helper is installed and nothing runs as root between connections — macOS puts its own authorization dialog in front of the person for each connect. That is a smaller permanent attack surface than a daemon, and it matches code that already exists.

Injection is designed out, not escaped away

The command ends up inside a shell string inside an AppleScript literal, where escaping has to be perfect twice. So values are rejected before they are quoted: a coordinator URL is a scheme, host and optional port — no path, query, fragment or userinfo — and keys are base64. Then shell-quoted, then AppleScript-escaped. The CLI path never comes from the caller.

Verified end to end through the running agent: ws://host;touch /tmp/pwned and an auth key of a"b;id are both refused with a readable message, no file is created, and no authorization prompt is raised — rejection happens before elevation.

A running tunnel is confirmed, not assumed

The recorded pid is not trusted alone: pids get reused and a stale one would be killed. The process is confirmed to still be that CLI running vpn:up, matched against the resolved path rather than the binary name — the CLI also ships as lt, which is too common a word to trust. That also makes a crashed tunnel report as disconnected rather than as running.

managedPid is deliberately separate from connected: the interface list shows any tunnel on the machine, including one you started in Terminal, and only one this app started can be stopped here. The screen says so rather than offering a button that would fail.

Verified

  • 21 control tests, almost all about what gets rejected
  • 38 of my tests pass; the full suite was briefly red from an unrelated in-flight refactor in packages/clean, which has since resolved
  • UI round trip in the browser: Connect disabled while the URL is empty, auth key is a password field, an invalid URL surfaces the server's message in red, nothing executes

Not verified

The authorized path itself. Completing the macOS prompt needs the user's password, so no tunnel has actually been brought up from the GUI. Everything up to the osascript call is covered, including the exact command string.

Still open: a real server to connect to (5.c, held), and kernel-WG interop (#30), which needs wireguard-go or a Linux container — neither is installed here.

Sign in to comment on this issue.