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.
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.
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.
- 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)
- 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)
- 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)
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.
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
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