ReviewOS

stacks/localtunnels

feat(deploy): self-hosted WireGuard VPN on Hetzner Cloud via ts-cloud

#31
Closed chris opened this 9 days ago ·
9 days ago

Done — closing. Deployed and verified live: server 188.34.167.121, all 7 e2e checks pass repeatably.

feat(deploy): self-hosted WireGuard VPN on Hetzner Cloud via ts-cloud

Provision a Hetzner Cloud server, provisioned as a WireGuard VPN / exit node, using ts-cloud's Hetzner driver — with a fully-automated, e2e-verified deploy.

This complements the in-repo WireGuard implementation (#28): that's the client/library stack; this issue is the real-world server deployment story, using stock kernel WireGuard on the server so the setup is standard and interoperable (and doubles as a live interop target for our own client).

What gets built

  • deploy/hetzner-vpn/ — scripts that drive ts-cloud's HetznerClient:
    • deploy.ts — ensure SSH key, create firewall, create the server with a WireGuard cloud-init, wait for boot, add a client peer, emit a ready-to-use client config.
    • verify.ts — e2e verification over SSH (thorough, repeatable, asserted).
    • destroy.ts — tear the server + firewall down.
    • README.md — usage + how the e2e test works.

Server setup (cloud-init)

  • Ubuntu 24.04, cpx11 (2 vCPU / 2 GB), Falkenstein (fsn1).
  • wireguard + wireguard-tools; wg0 at 10.8.0.1/24, listening UDP 51820.
  • IPv4 + IPv6 forwarding (sysctl), iptables MASQUERADE → the server is an exit node (client traffic egresses via the Hetzner IP).
  • wg-quick@wg0 enabled + started; peers persisted to wg0.conf.

Firewall (Hetzner Cloud firewall)

  • tcp/22 (SSH), udp/51820 (WireGuard), icmp (ping tests). Attached to the server.

E2E verification (the important part)

Run entirely on the server over SSH, fully controlled, no dependency on local WireGuard tooling — a network namespace acts as a real WireGuard client:

  1. Create a netns + veth pair; run a kernel WireGuard client inside it, endpoint = the host veth, allowed-ips 0.0.0.0/0.
  2. Register the test client as a peer on wg0.
  3. Handshake — assert wg show reports a recent handshake and non-zero rx/tx.
  4. Tunnel ping — ping 10.8.0.1 from the netns succeeds.
  5. Exit routing — curl https://api.ipify.org from inside the netns returns the server's public IP (proves the full chain: handshake → encrypt → tunnel → decrypt → forward → NAT → internet → back).
  6. Tear the test peer/netns down; assert cleanup.

Plus: server-side assertions (interface up, forwarding on, firewall attached, ports listening) and, when a WireGuard client is available locally, a real Mac↔server handshake.

Checklist

  • Validate Hetzner credentials; confirm image/type/location
  • deploy.ts (firewall + server + WireGuard cloud-init + client peer/config)
  • Deploy the server for real; confirm wg0 up and cloud-init clean
  • verify.ts — netns handshake + ping + exit-IP, all asserted (7/7)
  • Repeatable automated test harness (verify.ts cleans up + re-runs)
  • destroy.ts teardown
  • README / docs
  • Optional: live Mac↔server WireGuard client test (client-lt.conf generated; import into the WireGuard app to use)

Cost / safety

cpx11 ≈ €0.007/hr. Credentials are read from ~/Code/Libraries/ts-cloud/.env at runtime and never committed. destroy.ts removes all created resources.

9 days ago

Deployed and verified live — closing

A WireGuard VPN is running on Hetzner Cloud, provisioned via ts-cloud's HetznerClient, and verified end-to-end.

Server: localtunnels-vpn (Hetzner #148191064), Ubuntu 24.04, cx23, Falkenstein (fsn1), public IP 188.34.167.121, WireGuard on udp/51820.

E2E verification (bun run deploy/hetzner-vpn/verify.ts) — 7/7, run repeatably:

  ✓ WireGuard wg0 interface up — interface: wg0
  ✓ Listening on udp/51820
  ✓ IPv4 forwarding enabled
  ✓ NAT masquerade rule present
  ✓ Client completed a handshake — ts=1783277922
  ✓ Ping server over the tunnel (10.8.0.1) — 3 received
  ✓ Traffic egresses via the VPN (exit node) — client-sees=188.34.167.121 server=188.34.167.121 expected=188.34.167.121

  7/7 checks passed
  ✅ VPN verified end-to-end: handshake, tunnel ping, and exit routing all work.

The exit-routing check is the decisive one: a real kernel-WireGuard client (running in a network namespace on the server) sees its public IP as the server's IP, proving the full path — handshake → encrypt → tunnel → server decrypt → forward → NAT → internet → back through the tunnel.

Notes

  • The client keypair is generated with localtunnels' own WireGuard-compatible keygen and accepted by stock kernel WireGuard — a nice live interop confirmation for #28.
  • Two subnet/DNS bugs in the netns test were found and fixed while wiring it up (test client must live in the server's wg0 subnet for the return route; the netns needs its own resolver since Ubuntu's 127.0.0.53 isn't reachable there). The fixes are in verify.ts.
  • The server is left running as a usable VPN; client-lt.conf (git-ignored, holds the private key) can be imported into any WireGuard app. Tear down anytime with bun run deploy/hetzner-vpn/destroy.ts (cx23 ≈ €0.008/hr while it runs).

Code on main (deploy/hetzner-vpn/). Optional Mac↔server client test left unchecked — the config is generated; importing it into the WireGuard app is a manual step.

9 days ago

WireGuard VPN deployed on Hetzner via ts-cloud and verified end-to-end (7/7: handshake, tunnel ping, exit routing). Server live at 188.34.167.121. 🎉

9 days ago

Updated: the VPN now runs the localtunnels stack, not stock WireGuard

The first pass set the box up with wg-quick (kernel WireGuard). It now runs our own localtunnels implementation end to end — the whole point of #28. Re-verified live.

What changed

  • Native portability: the Linux build dropped libc — randomness (getrandom), TUN device I/O, and the packet pump now use raw syscalls (std.os.linux). libltvpn cross-compiles to a self-contained x86_64-linux-musl .so that dlopens on any glibc host (Ubuntu 24.04 here). build.zig links libc only on macOS/Windows.
  • CLI server mode: lt vpn up gained --peer/--allowed-ips (authorize clients without dialing), destination-based cryptokey routing for return traffic, and --exit-node (IP forwarding + NAT).
  • Deployment: deploy.ts provisions the server + firewall via ts-cloud, compiles the localtunnels CLI (bun-linux-x64) + native lib, ships them, and runs lt vpn up as the systemd unit localtunnels-vpn (exit node). verify.ts runs a localtunnels client in a netns against the live service — our stack on both ends.

Live e2e (bun run deploy/hetzner-vpn/verify.ts) — 7/7, repeatable:

  ✓ localtunnels-vpn service active — active
  ✓ Server TUN interface present — tun0
  ✓ Listening on udp/51820
  ✓ IPv4 forwarding enabled
  ✓ NAT masquerade rule present
  ✓ Client link + ping over the tunnel (10.8.0.1) — 3 received
  ✓ Traffic egresses via the VPN (exit node) — client-sees=188.34.167.121 server=188.34.167.121 expected=188.34.167.121

  7/7 checks passed
  ✅ localtunnels VPN verified end-to-end: our stack handshakes, tunnels, and exits.

Every hop on the datapath is localtunnels: our Zig WireGuard handshake + ChaCha20-Poly1305 transport, our Linux TUN device (exercised for real for the first time — it opened tun0 on the box), our raw IP-packet datapath, and our cryptokey routing. The server forwards + NATs decrypted client traffic to the internet, so curl ipify from the client returns the server's IP.

Server 188.34.167.121 is running the localtunnels VPN as a persistent service. Code on main (deploy/hetzner-vpn/ + the native/CLI changes).

9 days ago

Re-verified: the Hetzner VPN now runs the localtunnels stack (our Zig core, Linux TUN, raw datapath) end to end, not stock WireGuard. 7/7 e2e live. 🎉

Sign in to comment on this issue.