A pocket-sized WiFi NAT router and Tailscale subnet router on a single ESP32-S3 — built to put low-bandwidth IoT devices on your tailnet, no extra hardware, configured entirely from a built-in web UI.
Status — early access (
v0.1.9). Runs daily on the reference ESP32-S3 hardware and the core paths (WiFi NAT, Tailscale subnet routing, DERP fallback, exit nodes, firewall) are exercised continuously. Treat it as a capable hobby build, not a hardened appliance — see Known limitations.
This firmware turns one ESP32-S3 board into two things at once:
- A WiFi NAT router. It joins your existing 2.4 GHz network as a client (STA) and re-broadcasts its own access point (AP). The IoT devices on that AP reach the internet through the upstream WiFi, with NAPT, DHCP, and an on-board DNS forwarder.
- A Tailscale subnet router. It runs a userspace WireGuard +
Tailscale (
ts2021) stack, so any peer on your tailnet can reach the devices behind its AP — and the AP-side devices can use the ESP as a Tailscale exit node gateway. No client software on the IoT devices, no cloud account on the LAN.
Everything — WiFi credentials, the tailnet auth key, advertised routes, firewall rules, diagnostics — is configured from a phone or laptop browser. There is no app and no serial console required after the first flash.
Most "put a sensor on Tailscale" setups need either a Raspberry Pi acting as a subnet router or per-device Tailscale clients. This project collapses that into a ~$10 board you can leave plugged into a USB charger: it bridges a whole IoT subnet onto your tailnet and gives those devices internet through an exit node, while staying small enough to ignore.
Typical setups people use it for:
- Weekend house / cabin. A Tasmota/Shelly AC, water heater or freeze-guard on the cabin's existing WiFi — your home Home Assistant sees and controls it, with no public IP, port-forward or VPN server on the cabin's router.
- Parents' / grandparents' place. A couple of sensors (temperature, door/window, water-leak) pulled into your own HA so you can watch them and get alerted, without ever touching their router.
- CGNAT / mobile-broadband locations. Sites with no public IP (4G routers, shared building internet) that you could never reach before — the tailnet bridges the CGNAT, so HA sees everything anyway.
- Garage / workshop / shed. There's WiFi, but you don't want to set up a VLAN or VPN; the garage door, irrigation or a frost guard just show up in HA.
- Rentals / networks you don't own. Not your router, no right to port-forward — the ESP puts your gadgets on the tailnet as a node, done.
The common thread: there's WiFi on site, but you can't (or don't want to) touch the router. That's exactly where this fits.
This is a micro-controller doing userspace encryption and NAT on a single shared 2.4 GHz radio. Its job is reach, not throughput — size your expectations accordingly.
✅ What it's for
- IoT / home-automation gear: sensors, smart switches, plugs, thermostats, energy/environmental monitors, ESPHome / Zigbee / MQTT bridges — anything small and low-bandwidth.
- Low-rate control & telemetry: bursty, tiny payloads that are perfectly happy with around a megabit.
- Reaching a device stuck behind NAT/CGNAT so you (or Home Assistant) can poll it, flip a relay, or SSH in from anywhere on your tailnet.
🚫 What it's not for
- Being the everyday internet uplink for your phone or laptop.
- Streaming, video calls, or watching a camera feed in high resolution.
- Large downloads, backups, OTA images for other devices — anything bandwidth-heavy.
- A general-purpose VPN gateway for fast clients.
Real-world throughput through the tunnel runs roughly 0.3–1.4 Mbit/s, depending on the path — plain STA routing is at the top of that range, a direct exit node in the middle, and a DERP-relayed exit node at the bottom. Plenty for switches and sensors, not for media. If you need real bandwidth, put a Raspberry Pi (or similar) on that job instead. (Configuring the device from a phone or laptop browser is of course fine — that's just the admin UI, not traffic you route through it.)
- Dual role — simultaneous WiFi STA (uplink) + AP (NAT router) + Tailscale subnet router.
- Expose the upstream LAN too — beyond its own AP subnet, an optional
Source-NAT (à la Tailscale
--snat-subnet-routes) lets tailnet peers reach the network the device is connected to, with no static route needed on the upstream router. - Web UI for everything — first-run password setup, WiFi join, tailnet enrolment, routes, firewall, diagnostics. Dark, responsive, single-page; served straight off the device.
- Tailscale, the real protocol — DISCO peer discovery, direct paths and DERP relay fallback, NAT traversal, MagicDNS-aware, exit-node client and gateway. Powered by microlink.
- Exit-node aware routing — AP clients' internet traffic can be forced through a chosen Tailscale exit node; when the exit node is unreachable the firmware fails closed (traffic stops) rather than silently leaking to the local uplink.
- Stateful-ish ACL firewall — four hook points (Internet↔ESP, Clients↔ESP) with first-match-wins rules by protocol / CIDR / port / action, plus per-rule hit counters.
- DNS forwarder with cache — on-board resolver for AP clients with a PSRAM-backed response cache and configurable upstream.
- Operations toolbox — on-device ping / traceroute / route-explain, a 1 MB download/upload speed test, live WiFi scan, and a microSD "flight recorder" for catching control-plane stalls.
- DHCP niceties — reservations, live lease table, per-client signal, and a MAC denylist.
- Robust by design — encrypted config backup/restore, OTA updates (with an opt-in beta channel for pre-releases), per-sink (console + SD) log levels, auto AP-channel realign on STA roam, and pre-crash log capture.
- Anonymous telemetry (on by default, one toggle to opt out) — a tiny
daily payload: a salted one-way device hash + boot/flash counters +
firmware/chip/uptime + reboot/crash cause. Never SSIDs, IPs, MACs,
tailnet, or peers — and fully inspectable in
main/telemetry.c.
| Target | ESP32-S3 with PSRAM (8 MB octal, 80 MHz) |
| Reference board | ESP32-S3-DevKitC-1 N16R8 (16 MB flash / 8 MB PSRAM) |
| Radio | Single 2.4 GHz — STA and AP share one radio (see limitations) |
| Storage (optional) | microSD for the log flight-recorder |
| Power | USB-C; ~real-world draw of a small dev board |
Only the ESP32-S3 is supported. It's the board this firmware is
written for and tested on, and it's the only one I have. The PlatformIO
config still lists a few other targets (esp32, esp32-c3,
wt32-eth01) left over from earlier scaffolding, but I don't build or
test against them and have no idea whether they work — so I can't support
them. This is a free hobby project and I'm not planning to buy extra
boards just to validate other hardware. If you get it running elsewhere,
great — but you're on your own there, and PRs are welcome.
Real-world results from the field (see #9 and #10 — thanks @bobcroft and @markvovo):
| Board | Result |
|---|---|
| ESP32-S3-DevKitC-1 N16R8 (genuine) | ✅ Reference — developed and tested on this |
| Freenove ESP32-S3-WROOM N8R8 | ✅ Community-confirmed: AP, web UI, full setup |
| Seeed XIAO ESP32-S3 N8R8 | ✅ Community-confirmed: AP join, plus the WireGuard/DERP data plane (both a direct peer-to-peer session and a relayed one). Web UI wasn't separately re-verified. See the first-flash note below |
| YD-ESP32-S3 ("YD32") clones | ❌ SoftAP never visible on air — fails even with a minimal ESP-IDF AP example, i.e. a board-level RF problem, not this firmware |
First flash on a XIAO: one tester's board wouldn't accept the default AP password until they ran a full
esptool erase_flashfollowed by afactory_reset --confirm, allowing a little extra time before retrying the join. No log survives from the failed first boot, so this is a known rough edge rather than a diagnosed bug. If you hit it, erase the flash and retry. Thefactory-full.binasset on the latest release is a full-flash image and rewrites the NVS region too, so it sidesteps this as well.
git clone --recurse-submodules https://github.com/Csontikka/esp32-tailscale-subnet-router
cd esp32-tailscale-subnet-router
# PlatformIO (recommended)
pio run -e esp32-s3 -t upload
# …or ESP-IDF (>= 5.5.3)
idf.py set-target esp32s3
idf.py build flash monitorThe web assets are embedded into the firmware at build time from
main/index.html, so a single flash carries the whole UI.
On first boot the device brings up its own access point:
- SSID:
ESP32-TSR-Setup(builds before v0.1.18:myssid), password:mypassword - Connect to it and open http://192.168.4.1 — set an admin password there, then rename the AP to your liking.
On Network → Access Point / Uplink networks, point the device at your existing 2.4 GHz network and (optionally) rename the AP it broadcasts.
This is the one step with a couple of non-obvious Tailscale details — do them once and the device stays on your tailnet for good.
Log in to the Tailscale admin console → Settings → Keys and click Generate auth key….
Fill in the dialog:
| Option | Value | Why |
|---|---|---|
| Description | esp32-router |
so you can find it later |
| Reusable | ✅ On | re-flash without regenerating a key |
| Ephemeral | ❌ Off | ephemeral nodes get garbage-collected when offline — bad for a device that reboots |
| Pre-approved | ✅ On (if your tailnet uses device approval) | lets the device join without a manual click |
| Tags | tag:esp32 (optional) |
handy for ACL targeting |
| Expiration | 90 days (max) | Tailscale caps this — you make the node permanent in 4c below |
Copy the key (it starts with tskey-auth-…).
On the device's Tailscale tab, paste the auth key, set a hostname, and list the subnet(s) to advertise (your AP subnet is offered automatically). Pick an exit node here too if you want AP clients to egress through it. Save — the device registers with your tailnet on its next connect.
Approve the route. A newly advertised subnet shows up in the Tailscale admin (Machines → your device → Edit route settings) and must be approved before peers can use it. And if you later change the AP subnet, re-approve the new route there — the old approval stays but no longer matches, so the subnet silently becomes unreachable until you do.
Reaching the uplink LAN (not just the AP subnet). To expose the network the device is connected to (its STA/uplink side), advertise that subnet too and turn on Source-NAT advertised routes. That masquerades tunnel→LAN traffic to the device's own uplink IP (Tailscale's
--snat-subnet-routesdefault), so upstream hosts can reply without a route back to the tailnet. Without it, the upstream router would need a static route (100.64.0.0/10 → this device).
🔑 Auth key vs. node key — read this once
- The auth key (
tskey-auth-…) is a one-time ticket: the device uses it only on first registration. After that it has its own private node key (stored in NVS) and no longer needs the auth key — so it's fine if the auth key later expires.- The node key is the device's long-term identity, and Tailscale expires it after ~180 days by default. When it expires the device drops off the tailnet — exactly what you don't want on an unattended sensor.
So once the device shows up in your tailnet, disable its node-key expiry (next step). Skip it and everything looks fine for months, then the device silently falls off and you won't know why. Do it for every device you flash.
- Open the Tailscale Machines page.
- Find the new
esp32-routerentry. - Click the
⋯menu → Disable key expiry.
- Reboot the device (Reboot on the System tab, or power-cycle). The expiry status is only re-fetched on a fresh control-plane login, so a plain reconnect isn't enough.
That's it — remote tailnet peers can now reach the IoT devices on the AP subnet, and those devices can use the tailnet (and any exit node you picked).
The single-page UI has six sections:
| Section | What's there |
|---|---|
| Status | Uplink, AP, Tailscale node + peer list, memory, uptime |
| Network | Uplink networks, AP (SSID/IP/DNS), DHCP reservations & leases, MAC denylist, port forwarding |
| Tailscale | Auth key, hostname, advertised routes, exit node, MTU, peer table |
| Firewall | The four ACL chains, rule editor, hit counters |
| Diagnostics | Route-explain, ping, traceroute, speed test, WiFi scan, live + SD logs |
| System | Device name, firmware/OTA, SD-card logging, backup, danger zone, About (telemetry toggle) |
Four chains, evaluated first-match-wins; an empty chain allows by default. Rules match on protocol, source/destination CIDR, ports, and action, with live hit counters.
| Chain | Direction |
|---|---|
TO_ESP |
Internet → ESP |
FROM_ESP |
ESP → Internet |
TO_AP |
Clients → ESP |
FROM_AP |
ESP → Clients |
Route-explain answers "where would a packet to X actually go — uplink, WireGuard, or DERP?", which is the fastest way to reason about exit-node and subnet routing.
Internet
│ (upstream 2.4 GHz WiFi, STA)
┌─────┴─────┐
│ ESP32-S3 │ NAPT + DHCP + DNS forwarder
│ ┌──────┐ │ userspace WireGuard + Tailscale (microlink)
│ │ ACL │ │ exit-node aware route hook
└─────┬─────┘
AP │ (2.4 GHz, 192.168.x.0/24 advertised to the tailnet)
┌───────┼────────┐
sensor switch thermostat ←→ reachable from any tailnet peer
The AP is for IoT gear — sensors, switches, low-bandwidth control — not for routing your phone's or laptop's everyday internet. See What it's for.
- Data plane vs control plane. WireGuard moves packets; Tailscale's
DISCO/
ts2021control plane decides how (direct UDP vs DERP relay). The firmware watches the WireGuard data plane and re-handshakes over DERP when a direct path dies, so an exit-node session survives a direct↔DERP transition without dropping. - Exit-node routing. A route hook forces AP-client public traffic
into the WireGuard tunnel when an exit node is set; CGNAT (
100.64/10) always goes to the tunnel. If the exit node is down, traffic stops — it is not silently rerouted to the local uplink. - microlink. The Tailscale-compatible stack lives in its own repo,
Csontikka/microlink — itself
based on the original CamM2325/microlink —
attached here as a git submodule and pinned to the integration commit.
Its
docs/ARCHITECTURE.mdanddocs/TAILSCALE_REFERENCE.mdgo deeper.
See docs/CONFIGURATION.md for a field-by-field
configuration reference.
The device authenticates with a standard Tailscale auth key and has been validated against the hosted Tailscale control plane.
Headscale is supported (validated against Headscale v0.28.0; requires Headscale ≥ 0.26): set Login server to your server and authenticate with a Headscale pre-auth key. Accepted forms:
hostorhost:port— plain TCP, port defaults to 80http://host[:port]— e.g.http://192.168.1.42:8080https://host[:port]— TLS, port defaults to 443. The certificate is validated against the ESP-IDF public-CA bundle (Let's Encrypt works); self-signed / private-CA certificates are not supported.
The device fetches the server's Noise public key from /key?v=88 and
reads the initial netmap from the streaming long-poll, matching what
current Headscale versions require.
tailnet lock is not supported (the device cannot sign its own node
key); disable it for the tailnet or pre-authorize the node.
- Single radio. STA and AP share one 2.4 GHz radio and channel. If the upstream AP is on a different channel after a roam, throughput collapses until the device realigns (it auto-reboots to do so).
- Throughput. This is an MCU doing userspace crypto + NAT; expect roughly 0.3–1.4 Mbit/s through the tunnel (plain STA highest, direct exit node mid, DERP-relayed exit node lowest), not gigabit. Plenty for IoT and remote-admin traffic.
- Exit node fails closed. By design — when a selected exit node is unreachable, AP-client internet traffic stops rather than leaking to the local uplink. Clear the exit node to restore direct internet.
- Tailnet lock unsupported. Headscale over HTTPS needs a public-CA certificate (self-signed is rejected).
- 2.4 GHz only, single AP subnet.
The device reports a tiny, fully anonymous status payload to a Cloudflare
Worker — a small JSON on boot, then a heartbeat roughly once a day. It's
on by default, and one toggle in the About section (System tab)
turns it off for good (the choice is saved in NVS). Anyone can verify exactly what it
does — the whole thing is one function in
main/telemetry.c.
This is the entire payload — nothing else leaves the device:
{
"dh": "a1b2c3d4e5f6071839",
"v": "0.1.9",
"bd": "2026-05-31",
"et": "heartbeat",
"bc": 276,
"fc": 158,
"up": 90074,
"rr": 1,
"rw": "",
"ch": "S3r0",
"fh": 53707,
"ac": 42,
"ts": "up"
}| Field | Meaning | Example |
|---|---|---|
dh |
anonymous device ID — 16-hex SHA-256(WiFi MAC + fixed salt) plus a 2-hex integrity check (18 hex total). One-way; it can't be turned back into your MAC |
a1b2c3d4e5f6071839 |
v |
firmware version | 0.1.9 |
bd |
firmware build date | 2026-05-31 |
et |
event type — boot, heartbeat, or a crash report |
heartbeat |
bc |
total boot count | 276 |
fc |
total firmware-flash count | 158 |
up |
uptime, seconds | 90074 |
rr |
reset-reason code (ESP-IDF reason, or 100 = new firmware / 101 = rollback) |
1 |
rw |
short reboot-reason tag, or empty | ch-realign 11->1 |
ch |
chip model + silicon revision | S3r0 |
fh |
free heap at send time, bytes | 53707 |
ac |
Tailscale (re)connect count this session | 42 |
ts |
Tailscale toggle — up or off (just the switch; no peers, no tailnet name) |
up |
cr |
crash signature — only added to a crash report | StoreProhibited @ ml_derp_tx |
It never sends SSIDs, IP or MAC addresses, tailnet names, peer
information, or anything you typed into the UI. The device ID is a salted
one-way hash (compute_device_hash() in
main/telemetry.c), so reports can be grouped per
device without ever identifying one.
No hidden agenda — the JSON above is literally all of it, and the code is right there to check. I'd genuinely appreciate you leaving it on unless you have a specific reason not to:
- It's how I'd catch a mass PANIC rolling across devices after a bad
release — the same crash signature arriving from many
dhs at once is a five-alarm fire I'd otherwise never see. - Fully anonymized, it's the only signal I get for how many people actually run this. This is a free hobby project; if essentially nobody uses it long-term, that's fair feedback that I shouldn't keep pouring effort in.
Either way it's your call — flip it off in About → Anonymous telemetry (System tab) and the device never phones home again.
Please report vulnerabilities privately — see SECURITY.md.
The repo runs CodeQL, Dependabot, secret scanning, and a custom
Sensitive Data Check on every
push.
main/ firmware entry, web server + embedded SPA (index.html)
components/
acl/ the ACL firewall engine
sdlog/ microSD flight-recorder
… DNS relay, telemetry, etc.
external/microlink/ Tailscale/WireGuard stack (git submodule, MIT)
docs/ configuration reference, images
tools/ helper scripts
The entire web UI is the single file main/index.html, embedded into
the firmware by main/CMakeLists.txt at build time.
See CONTRIBUTING.md for build/flash setup and pull-request guidelines, and docs/TESTING.md for the test harness.
Found a bug or have an idea? Open an issue. If this firmware saved you a router purchase or an afternoon of debugging, you can chip in: buy me a coffee ☕ or sponsor me on GitHub
This firmware is MIT licensed. It builds on:
- microlink — Tailscale
ts2021client (MIT), based on the original CamM2325/microlink - wireguard_lwip — userspace WireGuard for lwIP (BSD-3-Clause), vendored inside microlink
- ESP-IDF — Espressif RTOS & networking (Apache-2.0)
See NOTICE.md for the full third-party attribution list.
Tailscale and Headscale are trademarks of their respective owners. This is an independent, unaffiliated community project.









