Pure-Rust Tailscale client for the ESP32-S3. Already have a WiFi stack? This adds a full Tailscale node — ts2021 control plane, WireGuard, NAT traversal, DERP — for ~2 kB static RAM. No tailscaled, no Go runtime. Portable no_std core.
8
stars
15
commits
Rust
primary language
Jul 10, 2026
updated
A pure-Rust Tailscale client for the ESP32-S3 (LilyGO
T-Dongle S3) — no C Tailscale, no tailscaled, no DERP-only shortcuts. It speaks
the real protocols against Tailscale's own coordination server: the ts2021
control plane (Noise IK over HTTP/2), WireGuard data plane, disco path
discovery, STUN NAT traversal, and an encrypted DERP relay fallback.
Not to be confused with the unrelated
tailscale-rustcrate. This is a from-scratch firmware + a portableno_stdprotocol core. Different project, different scope.
A ~$10 USB dongle becomes a 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 here), 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 below (with the in-tunnel
webserver, mDNS reflector, outbound client, dual-core, etc.) 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 from
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):
| 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 | ✅ builds for 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). What's different here:
Rust (memory-safe), a portable no_std core reusable beyond the ESP32
(iOS / desktop / WASI), and a ~2 kB incremental RAM cost. n/p = not published.
controlplane.tailscale.com,
interactive browser auth or headless pre-auth-key, persists across reboots,
shows green/online in the admin console. Frugal netmap parse that fits in
~287 KB of heap (no serde_json::Value tree).Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s, initiator
and responder, multiple concurrent tunnels.
CALL_ME_MAYBE NAT hole-punching, incl. a
birthday-paradox port spray for symmetric NATping (its 100.x IPv4) and
ping6 (its fd7a:… IPv6 address) over the same WireGuard session — serves a
small HTTP page, and can initiate traffic out (ICMP/UDP/TCP client).100.x:PROXY_LISTEN_PORT over the tunnel and the dongle forwards to a fixed
host:port on its LAN — reach one service on the other network without a full
subnet-router. (ping6 works too — see below.)no_std core + ESP32 adapterThe protocol logic lives in a platform-independent tailscale-core crate
(#![no_std] + alloc); the ESP32 firmware is a thin adapter that provides the OS
bits (WiFi/UDP/TLS, NVS storage, the ST7735 display). The same core is meant to be
reusable from an iOS app, desktop, or WASI.
Already migrated into the no_std core: icmp, stun, disco, wg (WireGuard),
outbound, tcp (in-tunnel HTTP), peers (netmap/ACL parser), and the pure
parts of node. RNG comes from getrandom (which has an esp-idf backend on the
device and OS backends elsewhere), so no RNG trait-threading is needed.
Proven genuinely std-less: the core + all its crypto dependencies compile for
a bare-metal, no-OS target (riscv32imc-unknown-none-elf).
The same WireGuard module (core/src/wg.rs) is reused unchanged by a host-side
load generator to benchmark the device over a guaranteed-direct path.
Measured with a host WireGuard load generator that handshakes directly with the dongle over the LAN (no Tailscale path negotiation, so the path can't 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 / loss | ~13 ms / 0% | ~13 ms / 0% |
The bottleneck is pure-Rust ChaCha20-Poly1305 on the LX7 (the S3 has no
hardware ChaCha — its AES accelerator only helps TLS). Dual-core runs the decrypt
on both cores in parallel (recv is serialized by a small mutex because lwIP UDP
sockets aren't safe for concurrent recvfrom); latency is unchanged, so it's a
strict win. Fine for control / IoT / SSH / device discovery; not a bulk-transfer
gateway.
App image size (the bytes written to flash), via espflash save-image:
| Build | App image | Δ |
|---|---|---|
Baseline (--no-default-features: WiFi + ST7735 display only) | 998 kB | — |
+ ts — control plane (ts2021) + WireGuard + keys + all crypto/TLS | 1389 kB | +391 |
+ direct — disco + STUN + magicsock + UDP data plane | 1468 kB | +79 |
+ derp — encrypted relay client | 1486 kB | +18 |
| Default — full Tailscale demo (all features) | 1509 kB | — |
The full demo is ~1.5 MB — a small fraction of the dongle's flash. WiFi SSID /
password (and an optional auth key) are build-time options you must fill in
(src/config.rs, git-ignored — see Build).
Each feature's marginal cost (leaf features measured leave-one-out from the
default build; bench / subnet-router measured added to the default):
| Feature | Extra | What it adds |
|---|---|---|
ts | +391 kB | control plane + WireGuard + crypto + TLS (foundational — everything needs it) |
direct | +79 kB | disco + STUN + UDP data plane (LAN + NAT-punch) |
derp | +18 kB | encrypted relay fallback (remote reachability) |
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 |
Mix features to fit a deployment, e.g. a tiny LAN-only feeder:
--no-default-features --features "ts,direct,icmp" (~1.47 MB).
Toolchain: the esp-rs Xtensa toolchain
(espup + . ~/export-esp.sh) and espflash.
# 1. Provide your WiFi creds (and optional Tailscale auth key). This file is
# git-ignored so secrets never get committed.
cp src/config.rs.example src/config.rs
$EDITOR src/config.rs # set WIFI_SSID + WIFI_PASS
# 2. Build + flash + watch the serial console
. ~/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
On first boot (no auth key) the serial console prints a login URL — open it to add
the node to your tailnet. Then ping 100.x.y.z, or browse to http://100.x.y.z/.
src/config.rs (WiFi SSID/password, any auth key) is git-ignored; only
src/config.rs.example with placeholders is committed. Never commit real
credentials. If you advertise subnet routes / exit-node, combine with
packet-filter so only authorized peers can route through the device.
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), falling back to a relay only when they truly must. That is exactly the substrate machines need to talk to each other: local-first, lowest-latency, no central server in the hot path.
The catch is that the stock Tailscale stack (Go + tailscaled) is too heavy for
the cheapest, most numerous devices — the microcontrollers that will actually live
inside those robots and appliances. This project shows the whole client fits in
under half a megabyte of portable, no-std Rust, running hand-rolled WireGuard
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.
Sibling project: mpee is the fleet-routing brain (optimize who goes where); tailscale-mpe-rust is the low-latency nervous system (let them all talk, directly).
src/ ESP32-S3 firmware (the platform adapter)
core/ tailscale-core — portable no_std + alloc protocol core
docs/ GitHub Pages site + design notes
Pure-Rust Tailscale on a USB dongle. MPE = Morten Punnerud-Engelstad.
15 commits
Hacker News (1)
Rust
100.0%
Pure-Rust Tailscale client for the ESP32-S3. Already have a WiFi stack? This adds a full Tailscale node — ts2021 control plane, WireGuard, NAT traversal, DERP — for ~2 kB static RAM. No tailscaled, no Go runtime. Portable no_std core.
8
stars
15
commits
Rust
primary language
Jul 10, 2026
updated
A pure-Rust Tailscale client for the ESP32-S3 (LilyGO
T-Dongle S3) — no C Tailscale, no tailscaled, no DERP-only shortcuts. It speaks
the real protocols against Tailscale's own coordination server: the ts2021
control plane (Noise IK over HTTP/2), WireGuard data plane, disco path
discovery, STUN NAT traversal, and an encrypted DERP relay fallback.
Not to be confused with the unrelated
tailscale-rustcrate. This is a from-scratch firmware + a portableno_stdprotocol core. Different project, different scope.
A ~$10 USB dongle becomes a 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 here), 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 below (with the in-tunnel
webserver, mDNS reflector, outbound client, dual-core, etc.) 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 from
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):
| 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 | ✅ builds for 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). What's different here:
Rust (memory-safe), a portable no_std core reusable beyond the ESP32
(iOS / desktop / WASI), and a ~2 kB incremental RAM cost. n/p = not published.
controlplane.tailscale.com,
interactive browser auth or headless pre-auth-key, persists across reboots,
shows green/online in the admin console. Frugal netmap parse that fits in
~287 KB of heap (no serde_json::Value tree).Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s, initiator
and responder, multiple concurrent tunnels.
CALL_ME_MAYBE NAT hole-punching, incl. a
birthday-paradox port spray for symmetric NATping (its 100.x IPv4) and
ping6 (its fd7a:… IPv6 address) over the same WireGuard session — serves a
small HTTP page, and can initiate traffic out (ICMP/UDP/TCP client).100.x:PROXY_LISTEN_PORT over the tunnel and the dongle forwards to a fixed
host:port on its LAN — reach one service on the other network without a full
subnet-router. (ping6 works too — see below.)no_std core + ESP32 adapterThe protocol logic lives in a platform-independent tailscale-core crate
(#![no_std] + alloc); the ESP32 firmware is a thin adapter that provides the OS
bits (WiFi/UDP/TLS, NVS storage, the ST7735 display). The same core is meant to be
reusable from an iOS app, desktop, or WASI.
Already migrated into the no_std core: icmp, stun, disco, wg (WireGuard),
outbound, tcp (in-tunnel HTTP), peers (netmap/ACL parser), and the pure
parts of node. RNG comes from getrandom (which has an esp-idf backend on the
device and OS backends elsewhere), so no RNG trait-threading is needed.
Proven genuinely std-less: the core + all its crypto dependencies compile for
a bare-metal, no-OS target (riscv32imc-unknown-none-elf).
The same WireGuard module (core/src/wg.rs) is reused unchanged by a host-side
load generator to benchmark the device over a guaranteed-direct path.
Measured with a host WireGuard load generator that handshakes directly with the dongle over the LAN (no Tailscale path negotiation, so the path can't 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 / loss | ~13 ms / 0% | ~13 ms / 0% |
The bottleneck is pure-Rust ChaCha20-Poly1305 on the LX7 (the S3 has no
hardware ChaCha — its AES accelerator only helps TLS). Dual-core runs the decrypt
on both cores in parallel (recv is serialized by a small mutex because lwIP UDP
sockets aren't safe for concurrent recvfrom); latency is unchanged, so it's a
strict win. Fine for control / IoT / SSH / device discovery; not a bulk-transfer
gateway.
App image size (the bytes written to flash), via espflash save-image:
| Build | App image | Δ |
|---|---|---|
Baseline (--no-default-features: WiFi + ST7735 display only) | 998 kB | — |
+ ts — control plane (ts2021) + WireGuard + keys + all crypto/TLS | 1389 kB | +391 |
+ direct — disco + STUN + magicsock + UDP data plane | 1468 kB | +79 |
+ derp — encrypted relay client | 1486 kB | +18 |
| Default — full Tailscale demo (all features) | 1509 kB | — |
The full demo is ~1.5 MB — a small fraction of the dongle's flash. WiFi SSID /
password (and an optional auth key) are build-time options you must fill in
(src/config.rs, git-ignored — see Build).
Each feature's marginal cost (leaf features measured leave-one-out from the
default build; bench / subnet-router measured added to the default):
| Feature | Extra | What it adds |
|---|---|---|
ts | +391 kB | control plane + WireGuard + crypto + TLS (foundational — everything needs it) |
direct | +79 kB | disco + STUN + UDP data plane (LAN + NAT-punch) |
derp | +18 kB | encrypted relay fallback (remote reachability) |
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 |
Mix features to fit a deployment, e.g. a tiny LAN-only feeder:
--no-default-features --features "ts,direct,icmp" (~1.47 MB).
Toolchain: the esp-rs Xtensa toolchain
(espup + . ~/export-esp.sh) and espflash.
# 1. Provide your WiFi creds (and optional Tailscale auth key). This file is
# git-ignored so secrets never get committed.
cp src/config.rs.example src/config.rs
$EDITOR src/config.rs # set WIFI_SSID + WIFI_PASS
# 2. Build + flash + watch the serial console
. ~/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
On first boot (no auth key) the serial console prints a login URL — open it to add
the node to your tailnet. Then ping 100.x.y.z, or browse to http://100.x.y.z/.
src/config.rs (WiFi SSID/password, any auth key) is git-ignored; only
src/config.rs.example with placeholders is committed. Never commit real
credentials. If you advertise subnet routes / exit-node, combine with
packet-filter so only authorized peers can route through the device.
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), falling back to a relay only when they truly must. That is exactly the substrate machines need to talk to each other: local-first, lowest-latency, no central server in the hot path.
The catch is that the stock Tailscale stack (Go + tailscaled) is too heavy for
the cheapest, most numerous devices — the microcontrollers that will actually live
inside those robots and appliances. This project shows the whole client fits in
under half a megabyte of portable, no-std Rust, running hand-rolled WireGuard
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.
Sibling project: mpee is the fleet-routing brain (optimize who goes where); tailscale-mpe-rust is the low-latency nervous system (let them all talk, directly).
src/ ESP32-S3 firmware (the platform adapter)
core/ tailscale-core — portable no_std + alloc protocol core
docs/ GitHub Pages site + design notes
Pure-Rust Tailscale on a USB dongle. MPE = Morten Punnerud-Engelstad.
Hacker News (1)
15 commits
Rust
100.0%