Userspace WireGuard gateway that joins a Tailscale-compatible tailnet via Headscale + DERP. No TUN, no kernel drivers, no admin beyond raw sockets for ICMP.
burrow is a WireGuard peer you drop inside a private network. It
registers as a node on a Headscale tailnet, gets a 100.x.y.z IPv4,
and exposes:
- transparent MASQUERADE for other tailnet peers reaching internal LAN hosts,
- SSH
-R-style reverse tunnels bound on real OS listeners, - a DNS resolver, and
- a remote shell,
all over a single CBOR control channel on <tailnet_ip>:57821.
Built on boringtun, smoltcp, and the vendored tailscale-rs (DERP + Noise IK + netmap).
Topology:
flowchart LR
peer["tailnet peer<br/>(laptop, VPS,<br/>burrow-client, …)"]
derp[DERP relay]
hs[Headscale<br/>coordination]
burrow["burrow<br/>userspace gateway"]
h1[internal host]
h2[internal host]
peer <-->|DERP| derp
derp <-->|DERP| burrow
burrow --- h1
burrow --- h2
peer <-->|HTTPS + Noise IK| hs
burrow <-->|HTTPS + Noise IK| hs
Coordination (who's who, what IP, which DERP region) is Headscale. Transport (encrypted WG datagrams between peers) is DERP. There's no direct P2P — Tailscale's disco / NAT-traversal magic is out of scope here; everything relays.
Node identity is regenerated at every process start; nothing persists
to disk. Headscale's ephemeral=true registration means stale nodes
age out automatically.
You need a reachable Headscale server and a preauth key. If you don't have one:
headscale users create test
headscale preauthkeys create --user test --reusable
# -> hskey-auth-<opaque-string>Three deployment modes for burrow:
cargo build --release
./target/release/burrow \
--server-url https://headscale.example.com \
--authkey hskey-auth-...export BURROW_HEADSCALE_URL=https://headscale.example.com
export BURROW_HEADSCALE_AUTHKEY=hskey-auth-...
export BURROW_HEADSCALE_HOSTNAME=gateway-a # optional
./target/release/burrowFor single-binary deploys. Credentials land in the binary's read-only data segment — treat the output with the same care as the preauth key.
# Write a 2- or 3-line embed file
burrow-client headscale-embed \
--server-url https://headscale.example.com \
--authkey hskey-auth-... \
--hostname gateway-a \
--out ./burrow-headscale.txt
# Build min-profile (opt-level=z, LTO, panic=abort) silent binary
just embed ./burrow-headscale.txt
# -> target/min/burrow(.exe), target/min/burrow-client(.exe)
# Or do both in one step
just gen-embed --server-url https://headscale.example.com \
--authkey hskey-auth-... --hostname gateway-aOnce burrow is up, check that it registered:
headscale nodes list
# should show the gateway hostname with a 100.x.y.z addressburrow-client connects to burrow by tailnet IP (from headscale nodes list). Two ways to get the client onto the tailnet:
Direct-TCP mode (when the client's OS already has a route — e.g. another tailscale daemon is running on the same box):
burrow-client tunnel 100.64.0.5 start -R 443:127.0.0.1:8080Embedded DERP mode (no existing tailnet — burrow-client registers its own ephemeral node and rides DERP in-process):
export BURROW_HEADSCALE_URL=https://headscale.example.com
export BURROW_HEADSCALE_AUTHKEY=hskey-auth-...
burrow-client tunnel 100.64.0.5 start -R 443:127.0.0.1:8080All subcommands take the same top-level --server-url/--authkey/
--hostname flags; when set, they route through DERP instead of direct
TCP. BURROW_HEADSCALE_* env vars work identically.
SSH -R, but over the tailnet. The burrow host binds a real OS
listener; connections tunnel back to the client and originate on
forward_to locally.
# Anything that connects to <burrow_lan_ip>:443 lands on 127.0.0.1:8080
burrow-client tunnel 100.64.0.5 start -R 443:127.0.0.1:8080
# Ctrl-C to stop — burrow-client holds the control flow open for the
# tunnel's lifetime.HOST can be a hostname (resolved on the client when a connection
arrives). -R [BIND:]LISTEN:HOST:PORT; BIND defaults to 0.0.0.0. -U
for UDP. Stop by id:
burrow-client tunnel 100.64.0.5 list
burrow-client tunnel 100.64.0.5 stop 42PTY session on the burrow host (default mode):
burrow-client shell 100.64.0.5
# drops into cmd.exe on Windows, $SHELL / /bin/sh on UnixRun a command, capture stdout + stderr + exit code, return:
# --output - pipes captured output to the local terminal
burrow-client shell 100.64.0.5 --output - --program whoami
# --output <path> writes it to a file (stderr still on terminal)
burrow-client shell 100.64.0.5 --output build.log --program makeSpawn detached; the server returns the pid and the process outlives the
burrow-client invocation. Nothing is captured.
burrow-client shell 100.64.0.5 --detach --program ./long-running-task
# 47412 <- pid printed to local stdout--program picks the executable; anything after -- is argv:
burrow-client shell 100.64.0.5 --program /usr/bin/python3 -- -i
burrow-client shell 100.64.0.5 --program cmd.exe -- /c "dir C:\"burrow-client login \
--server-url https://headscale.example.com \
--authkey hskey-auth-...
# 100.64.0.7Registers as an ephemeral node, waits for Headscale to assign an IPv4, prints it, and exits. Useful for scripts that need to announce a tailnet IP before spinning up a persistent session elsewhere.
burrow [--server-url URL] [--authkey KEY] [--hostname NAME]
# the gateway; same flags available via BURROW_HEADSCALE_{URL,AUTHKEY,HOSTNAME}
# or baked in at build time with --features embedded-headscale-config.
burrow-client [--server-url URL] [--authkey KEY] [--hostname NAME] \
tunnel <burrow_ip> start -R ... # reverse tunnel (TCP; -U for UDP)
burrow-client ... shell <burrow_ip> # interactive PTY on burrow
burrow-client login --server-url ... --authkey ... # register, print IP
burrow-client headscale-embed --server-url ... --authkey ... --out FILE
--help on any subcommand for the full option surface. just --list
for build / embed recipes.
[DERP WebSocket] ←→ peer_table (boringtun Tunn per NodePublicKey)
↕ plaintext IPv4
[destination rewrite shim]
↕
smoltcp Interface
↕ TCP/UDP sockets
[NAT table → real OS sockets]
↕
LAN hosts
- The Headscale client (vendored
ts_control) handles registration + netmap long-polling. Each delta feeds aPeerTablereconciler that keeps oneboringtun::Tunnper tailnet peer. - Inbound: DERP WebSocket frames → look up sender's
Tunn→ decapsulate → plaintext IPv4 → smoltcp. - smoltcp only processes packets whose
dstis its interface address, so we rewritedstto a synthetic198.18.0.0/15range on ingress and restore it on egress. The NAT table holds the original 5-tuple so both directions resolve. - For TCP, burrow dials the original destination as a real OS
TcpStreamfirst — only on success does smoltcp answer the peer's SYN. Closed ports get an RST; unreachable destinations get an ICMP. - UDP bypasses smoltcp: per-flow
UdpSocket, idle-swept after 30s. - Reverse tunnels bind real OS listeners on burrow's host. Incoming
connections yamux-multiplex back to the owning client, which
originates the
forward_toconnection locally.
Your Headscale is serving HTTPS with a certificate that isn't in the
system trust store (common for self-hosted Headscale with a private CA,
or any caddy/traefik install with a non-public ACME issuer). Point
burrow at the CA bundle and it'll validate normally:
export BURROW_EXTRA_CA_BUNDLE=/path/to/ca.pem
burrow --server-url https://headscale.example.com --authkey hskey-auth-...One env var is inspected by both burrow and burrow-client. The
file can contain multiple concatenated PEM certs — all of them are
added as additional trust anchors on top of the system store.
It's there, just listed as offline. Headscale marks ephemeral nodes
offline the moment the netmap stream disconnects — if you ran burrow
briefly and killed it, the entry either expired or shows offline
until the expiry policy sweeps it. Check --ephemeral on your
preauth key and headscale nodes expire-policy if stale entries
pile up.
Expected. On the first packet to a new peer, boringtun initiates a
WireGuard handshake (one DERP round-trip), then smoltcp retries the
dropped SYN after its retransmit timer (~3 s). Once the WG session
is live, subsequent flows pay only the DERP + smoltcp RTT. If this
bothers you, run burrow-client login on script startup to keep a
session warm.
Usually a PTY quirk. If you're driving burrow-client shell
programmatically (piping through a headless automation harness on
Windows), ConPTY's cursor-position query (\x1b[6n) needs a response
or cmd.exe blocks forever. Either run in a real terminal, or write
your driver to answer the DSR with something like \x1b[24;80R.
Interactive shells run from a real terminal don't hit this — your terminal driver handles the query automatically.
ICMP raw sockets require CAP_NET_RAW on Linux and Administrator on
Windows. Without them, burrow can't send real ICMP; ping to a peer
behind burrow gets an admin-prohibited response instead of the echo
reply. Run burrow as Administrator on Windows (or with
CAP_NET_RAW on Linux) to enable ICMP forwarding.
TCP and UDP are unaffected — they use plain sockets.
burrow-client tunnel start asks the burrow gateway host to bind
the listener. If burrow's host already has something on port 443,
the bind fails and you get a StartError response. Pick an
unused port (-R 8443:…) or stop the conflicting service on the
gateway first.
Burrow is pure layer-3/4 NAT with no application-layer gateway.
Protocols that embed their own IP/port inside the payload (active
FTP, SIP, H.323, some game protocols) can't be rewritten by burrow
and will misbehave. Workarounds: use passive-mode FTP, tunnel the
protocol over SSH via a reverse tunnel, or pick a different
protocol. Linux's nf_conntrack_* kernel helpers are the
equivalent; burrow has no analog.
It doesn't, by design — every burrow process generates a fresh
node key. Use Headscale's ephemeral=true preauth keys (so stale
registrations expire on disconnect) and run burrow under a process
supervisor (systemd, nssm on Windows, etc.) that auto-restarts
on crash. The headscale-assigned IP may change on each restart —
reference burrow by its headscale nodes list name if you're
scripting.
- DERP-only transport. No direct peer-to-peer; everything relays
through a DERP server. Tailscale's disco protocol (endpoint discovery
- NAT traversal) is explicitly out of scope.
- IPv4 data plane only. Headscale assigns tailnet IPv6, but we don't route it yet.
- No persistence. Every process start regenerates the node key.
Use
ephemeral=truepreauth keys or aheadscale nodes expirepolicy so stale entries don't accumulate. - ICMP without raw sockets returns admin-prohibited rather than
forwarding; raw sockets need
CAP_NET_RAW/ Administrator. - Pure layer-3/4 NAT — no ALG. Protocols that embed addresses in their payload (FTP active/PASV, SIP, H.323, …) break without a helper that parses + rewrites those embedded addresses.
cargo test # hermetic lib + integration tests
cargo test --features insecure-tests # plus real-DERP / real-Headscale tests
cargo clippy --all-targets -- -D warningsThe real-infra tests are gated on env vars
(BURROW_TEST_HEADSCALE_{URL,AUTHKEY}, BURROW_TEST_DERP_URL) and
short-circuit cleanly without them — the hermetic suite stays green
without any external setup. See tests/burrow_client_headscale.rs
for the test infrastructure pattern, and CLAUDE.md for design
notes on the data plane.
Vendored tailscale-rs sits under vendor/tailscale-rs/; pinned commit
in vendor/tailscale-rs/REVISION. Don't let cargo fmt --all walk
into it — use cargo fmt -p burrow or rustfmt --edition 2021 <files>
scoped to files you touched.
BSD-3-Clause (matches boringtun).