Commit Graph

10 Commits

Author SHA1 Message Date
dca8c3b643 fix(net): protocol fixes for Tailscale-compatible peer reachability
A pile of correctness bugs that all stopped real Tailscale peers from
being able to send WireGuard packets back to us. Found while building
out the e2e test against the docker-compose stack.

1. WireGuard static key was wrong (lifecycle.rs)
   We were initializing the WgTunnel with `keys.wg_private`, a separate
   x25519 key from the one Tailscale advertises in netmaps. Peers know
   us by `node_public` and compute mac1 against it; signing handshakes
   with a different private key meant every init we sent was silently
   dropped. Use `keys.node_private` instead — node_key IS the WG static
   key in Tailscale.

2. DERP relay couldn't route packets to us (derp/client.rs)
   Our DerpClient was sealing the ClientInfo frame with a fresh
   ephemeral NaCl keypair and putting the ephemeral public in the frame
   prefix. Tailscale's protocol expects the *long-term* node public key
   in the prefix — that's how the relay knows where to forward packets
   addressed to our node_key. With the ephemeral key, the relay
   accepted the connection but never delivered our peers' responses.
   Now seal with the long-term node key.

3. Headscale never persisted our DiscoKey (proto/types.rs, control/*)
   The streaming /machine/map handler in Headscale ≥ capVer 68 doesn't
   update DiscoKey on the node record — only the "Lite endpoint update"
   path does, gated on Stream:false + OmitPeers:true + ReadOnly:false.
   Without DiscoKey our nodes appeared in `headscale nodes list` with
   `discokey:000…` and never propagated into peer netmaps. Add the
   DiscoKey field to RegisterRequest, add OmitPeers/ReadOnly fields to
   MapRequest, and call a new `lite_update` between register and the
   streaming map. Also add `post_json_no_response` for endpoints that
   reply with an empty body.

4. EncapAction is now a struct instead of an enum (wg/tunnel.rs)
   Routing was forced to either UDP or DERP. With a peer whose
   advertised UDP endpoint is on an unreachable RFC1918 network (e.g.
   docker bridge IPs), we'd send via UDP, get nothing, and never fall
   back. Send over every available transport — receivers dedupe via
   the WireGuard replay window — and let dispatch_encap forward each
   populated arm to its respective channel.

5. Drop the dead PacketRouter (wg/router.rs)
   Skeleton from an earlier design that never got wired up; it's been
   accumulating dead-code warnings.
2026-04-07 14:33:43 +01:00
85d34bb035 feat(net): add UDP transport for direct peer connections
DERP works for everything but adds relay latency. Add a parallel UDP
transport so peers with reachable endpoints can talk directly:

- wg/tunnel: track each peer's local boringtun index in PeerTunnel and
  expose find_peer_by_local_index / find_peer_by_endpoint lookups
- daemon/lifecycle: bind a UdpSocket on 0.0.0.0:0 alongside DERP, run
  the recv loop on a clone of an Arc<UdpSocket> so send and recv can
  proceed concurrently
- run_wg_loop: new udp_in_rx select arm. For inbound UDP we identify
  the source peer by parsing the WireGuard receiver_index out of the
  packet header (msg types 2/3/4) and falling back to source-address
  matching for type-1 handshake initiations
- dispatch_encap: SendUdp now actually forwards via the UDP channel

UDP failure is non-fatal — DERP can carry traffic alone if the bind
fails or packets are dropped.
2026-04-07 13:48:59 +01:00
bea8a308da test(net): add integration test harness against Headscale
Spins up Headscale 0.23 (with embedded DERP) plus two Tailscale peers
in docker compose, generates pre-auth keys, and runs three integration
tests behind the `integration` feature:

- test_register_and_receive_netmap: full TS2021 → register → first
  netmap fetch
- test_proxy_listener_accepts: starts the daemon and waits for it to
  reach the Running state
- test_daemon_lifecycle: full lifecycle including DERP connect, then
  clean shutdown via the DaemonHandle

Run with `sunbeam-net/tests/run.sh` (handles compose up/down + auth
key provisioning) or manually via cargo nextest with the env vars
SUNBEAM_NET_TEST_AUTH_KEY and SUNBEAM_NET_TEST_COORD_URL set.
2026-04-07 13:42:46 +01:00
9750d4e0b3 feat(net): add VPN daemon lifecycle, state, and IPC
The daemon orchestrates everything: it owns reconnection backoff, the
WireGuard tunnel, the smoltcp engine, the DERP relay loop, the local
TCP proxy, and a Unix-socket IPC server for status queries.

- daemon/state: DaemonStatus state machine + DaemonHandle for shutdown
  signaling and live status access
- daemon/ipc: newline-delimited JSON Unix socket server (Status,
  Disconnect, Peers requests)
- daemon/lifecycle: VpnDaemon::start spawns run_daemon_loop, which pins
  a session future and selects against shutdown_rx so shutdown breaks
  out cleanly. run_session brings up the full pipeline:
  control client → register → map stream → wg tunnel → engine →
  proxy listener → wg encap/decap loop → DERP relay → IPC server.

DERP transport: when the netmap doesn't surface a usable DERP endpoint
(Headscale's embedded relay returns host_name="headscale", port=0),
fall back to deriving host:port from coordination_url. WG packets to
SendDerp peers go via a dedicated derp_out channel; inbound DERP frames
flow back through derp_in into the decap arm, which forwards Packet
results to the engine and Response results back to derp_out for the
handshake exchange.
2026-04-07 13:42:36 +01:00
f903c1a073 feat(net): add network engine and TCP proxy
- proxy/engine: NetworkEngine that owns the smoltcp VirtualNetwork and
  bridges async TCP streams to virtual sockets via a 5ms poll loop.
  Each ProxyConnection holds the local TcpStream + smoltcp socket
  handle and shuttles data between them with try_read/try_write so the
  engine never blocks.
- proxy/tcp: skeleton TcpProxy listener (currently unused; the daemon
  inlines its own listener that hands off to the engine via mpsc)
2026-04-07 13:42:15 +01:00
d9d0d64236 feat(net): add control protocol (register + map stream)
- control/client: TS2021 connection setup — TCP, HTTP CONNECT-style
  upgrade to /ts2021, full Noise IK handshake via NoiseStream, then
  HTTP/2 client handshake on top via the h2 crate
- control/register: POST /machine/register with pre-auth key, PascalCase
  JSON serde matching Tailscale's wire format
- control/netmap: streaming MapStream that reads length-prefixed JSON
  messages from POST /machine/map, classifies them into Full/Delta/
  PeersChanged/PeersRemoved/KeepAlive, and transparently zstd-decodes
  by detecting the 0x28 0xB5 0x2F 0xFD magic (Headscale only compresses
  if the client opts in)
2026-04-07 13:41:58 +01:00
0fe55d2bf6 feat(net): add WireGuard tunnel and smoltcp virtual network
- wg/tunnel: per-peer boringtun Tunn management with peer table sync
  from netmap (add/remove/update endpoints, allowed_ips, DERP region)
  and encapsulate/decapsulate/tick that route to UDP or DERP
- wg/socket: smoltcp Interface backed by an mpsc-channel Device that
  bridges sync poll-based smoltcp with async tokio mpsc channels
- wg/router: skeleton PacketRouter (currently unused; reserved for the
  unified UDP/DERP ingress path)
2026-04-07 13:41:43 +01:00
76ab2c1a8e feat(net): add DERP relay client
DERP is Tailscale's TCP relay protocol for peers that can't establish a
direct UDP path. Add the standalone client:

- derp/framing: 5-byte frame codec (1-byte type + 4-byte BE length)
- derp/client: HTTP /derp upgrade, Tailscale's NaCl SealedBox handshake
  (ServerKey → ClientInfo → ServerInfo → NotePreferred), and
  send_packet/recv_packet for forwarding WireGuard datagrams

Includes the 8-byte DERP\xf0\x9f\x94\x91 magic prefix in the ServerKey
payload and reads the HTTP upgrade response one byte at a time so the
inline first frame isn't swallowed by a buffered reader.
2026-04-07 13:41:17 +01:00
91cef0a730 feat(net): add Noise IK + HTTP/2 stream layer
Tailscale's TS2021 protocol layers HTTP/2 over an encrypted Noise IK
channel reached via HTTP CONNECT-style upgrade. Add the lower half:

- noise/handshake: hand-rolled Noise_IK_25519_ChaChaPoly_BLAKE2s
  initiator with HKDF + ChaCha20-Poly1305 (no snow dependency)
- noise/framing: 3-byte frame codec (1-byte type + 2-byte BE length)
- noise/stream: NoiseStream implementing AsyncRead + AsyncWrite over
  the framed channel so the h2 crate can sit on top
2026-04-07 13:41:01 +01:00
13539e6e85 feat(net): scaffold sunbeam-net crate with foundations
Add the workspace crate that will host a pure Rust Headscale/Tailscale-
compatible VPN client. This first commit lands the crate skeleton plus
the leaf modules that the rest of the stack builds on:

- error: thiserror Error enum + Result alias
- config: VpnConfig
- keys: Curve25519 node/disco/wg key types with on-disk persistence
- proto/types: PascalCase serde wire types matching Tailscale's JSON
2026-04-07 13:40:27 +01:00