A pure-Rust Tailscale client for the ESP32-S3 — control plane, WireGuard, disco, STUN & DERP, all from scratch.
github.com/punnerud/tailscale-mpe-rust
tailscale-rust crate — this
is a from-scratch firmware plus a portable no_std protocol core. A ~$10
USB dongle becomes a real Tailscale node you can ping, browse to, and
route through, running hand-rolled WireGuard crypto on a dual-core Xtensa LX7.Subtract the WiFi + esp-idf runtime any networked ESP32 project already has (a 998 kB baseline), and the Tailscale functionality alone adds:
| Adds on top of a WiFi baseline | Extra flash |
|---|---|
| Control plane (ts2021) + WireGuard crypto | +391 kB |
| + working data plane (disco + STUN + direct UDP) | +470 kB |
| + DERP relay fallback (full remote reachability) | +488 kB |
A complete Tailscale node — control plane, WireGuard, NAT traversal
and relay fallback — in under half a megabyte. The full demo (in-tunnel
webserver, mDNS reflector, outbound, dual-core, …) adds +511 kB (~1.5 MB total).
No tailscaled, no Go runtime.
Comparing total static SRAM is misleading — most of it (~116 kB here) is
the shared esp-idf + WiFi framework that every networked ESP32 firmware
already carries. On top of that baseline, the Tailscale layer adds only ~2 kB of
static SRAM (its .data+.bss): the protocol runs from
flash and the heap, not static RAM. (Runtime tunnel buffers + worker-thread stacks
use heap on top, like any networked app.) So "how much RAM does adding Tailscale
cost?" → about 2 kB.
Other ESP32 Tailscale efforts, by static SRAM (the number this niche markets on). n/p = not published.
| Project | Lang | Native Tailscale node | Static SRAM | Portable no_std core |
|---|---|---|---|---|
| tailscale-mpe-rust | Rust | ✅ full — ts2021 + WireGuard + disco + STUN + DERP | ~118 kB total · +~2 kB over a WiFi baseline | ✅ bare-metal riscv32 |
| microlink | C | ✅ full | ~85–116 kB total | ✗ |
| tailscale-iot | C | ⚠ PoC / partial | n/p | ✗ |
| esp32-tailbridge | C/C++ | via a WireGuard bridge | n/p | ✗ |
| stock Tailscale | Go | ✅ full (+ MagicDNS, …) | needs an OS — won't fit an MCU | ✗ |
Total static SRAM is on par with the C client (~118 vs ~116 kB for a WiFi build — most of it is the shared esp-idf/WiFi framework both carry). The difference: Rust (memory-safe), a portable no_std core reusable beyond the ESP32, and a ~2 kB incremental RAM cost.
CALL_ME_MAYBE
hole-punching (with a birthday-paradox port spray for symmetric NAT);
encrypted DERP relay fallback over real TLS.ping (IPv4) and
ping6 (its fd7a:… IPv6 address), serves a small HTTP
page, and can initiate ICMP/UDP/TCP out the tunnel.Protocol logic lives in a platform-independent tailscale-core crate
(#![no_std] + alloc); the ESP32 firmware is a thin adapter for the OS
bits (WiFi/UDP/TLS, NVS, the ST7735 display). Migrated so far:
icmp, stun, disco, wg,
outbound, tcp, peers, and the pure parts of
node. Proven genuinely std-less: the core + all crypto deps
compile for a bare-metal target (riscv32imc-unknown-none-elf). The same
wg.rs is reused unchanged by the host benchmark tool.
Measured with a host WireGuard load generator that handshakes directly with the dongle over the LAN (no path drift onto DERP), flooding inner UDP and reading the device's reflected RX rate.
| Metric | Single-core | Dual-core (default) |
|---|---|---|
| WireGuard decrypt throughput | ~3.9–4.0 Mbit/s | ~6.0–6.6 Mbit/s (~1.6×) |
| In-tunnel latency (ICMP RTT, median) | ~21 ms | ~20 ms |
| Latency min / packet loss | ~13 ms / 0% | ~13 ms / 0% |
Bottleneck: pure-Rust ChaCha20-Poly1305 on the LX7 (no hardware ChaCha). Dual-core decrypts on both cores in parallel with unchanged latency — a strict win. Great for control / IoT / SSH / discovery; not a bulk-transfer gateway.
App image size (bytes written to flash), via
espflash save-image.
| Build | App image | Δ |
|---|---|---|
| Baseline — WiFi + ST7735 display only | 998 kB | — |
+ ts — control plane + WireGuard + crypto/TLS | 1389 kB | +391 |
+ direct — disco + STUN + UDP data plane | 1468 kB | +79 |
+ derp — encrypted relay client | 1486 kB | +18 |
| Default — full Tailscale demo (all features) | 1509 kB | — |
~1.5 MB total — a small fraction of the dongle's flash. WiFi SSID /
password (and an optional auth key) are build-time options you fill into
src/config.rs (git-ignored).
| Feature | Extra | Adds |
|---|---|---|
ts | +391 kB | control plane + WireGuard + crypto + TLS (foundational) |
direct | +79 kB | disco + STUN + UDP data plane (LAN + NAT-punch) |
derp | +18 kB | encrypted relay fallback |
mdns-forward | +6 kB | mDNS/Bonjour reflector across LANs |
outbound | +5 kB | device-initiated ICMP/UDP/TCP out the tunnel |
http-server | +4 kB | in-tunnel TCP + the HTML web demo |
icmp | +2 kB | answer ping |
birthday | +2 kB | birthday-paradox port spray (symmetric NAT) |
dualcore | +2 kB | 2-core parallel decrypt (+60% throughput) |
packet-filter | +1 kB | enforce netmap ACLs |
derp-upgrade | +1 kB | upgrade relayed peers to a direct path |
authkey | ~0 kB | headless pre-auth-key provisioning |
bench (opt-in) | +2 kB | UDP throughput sink + RX reflection |
subnet-router (opt-in) | +1 kB | NAPT data-path foundation |
tcp-proxy (opt-in) | +3 kB | in-tunnel TCP → a fixed LAN host:port |
# 1. Provide your WiFi creds (git-ignored, never committed)
cp src/config.rs.example src/config.rs
$EDITOR src/config.rs # set WIFI_SSID + WIFI_PASS
# 2. Build + flash (esp-rs Xtensa toolchain + espflash)
. ~/export-esp.sh
cargo build --release
espflash flash --monitor --port /dev/cu.usbmodemXXXX \
--bootloader target/xtensa-esp32s3-espidf/release/bootloader.bin \
--partition-table target/xtensa-esp32s3-espidf/release/partition-table.bin \
target/xtensa-esp32s3-espidf/release/tailscale-rust
First boot without an auth key prints a login URL on the serial
console — open it to add the node to your tailnet. Then ping 100.x.y.z
or browse http://100.x.y.z/.
Humanoid robots, vacuum cleaners, drones, self-driving cars and boats — the coming wave of autonomous machines has to coordinate, and coordination is bounded by latency. A cloud round-trip costs tens to hundreds of milliseconds; two machines in the same room, or across town over 5G, can reach each other directly in a few.
Tailscale already gives every device a flat, encrypted, NAT-traversing address space where peers connect directly, peer-to-peer (hole-punched WireGuard), relaying only when they truly must — exactly the substrate machines need: local-first, lowest-latency, no central server in the hot path.
The catch: the stock Tailscale stack (Go + tailscaled) is too heavy
for the cheapest, most numerous devices — the microcontrollers that will live
inside those robots and appliances. This project shows the whole client fits
in under half a megabyte of portable, no-std Rust on a ~$10
chip. So the smallest, cheapest device can be a first-class mesh node — not a
second-class thing tethered to a gateway or a cloud account.
If every machine can securely find and reach every other machine — directly, privately, at the lowest possible latency, on hardware anyone can afford — that is an enabler for an abundant, decentralized future for the benefit of all. Lifting all boats.