esp32-c3-adblock
日本語 README
A Pi-hole-style DNS ad-blocker that runs on a $2 ESP32-C3 — no PSRAM required.
Also builds for the ESP8266 (NodeMCU / D1 mini, 4 MB flash) — see ESP8266 notes.
📰 Featured on Tom’s Hardware, XDA Developers, and Korben.
The trick everyone misses: you don’t need to keep the blocklist in RAM. Store the
domains as sorted 40-bit hashes in flash and binary-search them. 140,000+ domains
fit in ~0.7 MB of flash and are matched in ~10 ms, using ~50 KB of RAM.
query in ──▶ extract domain ──▶ FNV-1a hash (+ parent suffixes)
──▶ binary-search the flash hash table
├─ hit ──▶ answer 0.0.0.0 (sinkholed)
└─ miss ──▶ forward to upstream resolver, relay the reply
Why this is interesting
Most ESP32 DNS sinkholes load the blocklist (domain strings) into RAM, so they
demand PSRAM. This project stores fixed 5-byte (40-bit) hashes in flash instead:
|
string-in-RAM approach |
this (hash-in-flash) |
| Hardware |
ESP32 + PSRAM (~$8) |
ESP32-C3, no PSRAM (~$2) |
| 141k domains |
~2.5 MB of RAM |
0.67 MB of flash |
| RAM used |
most of it |
~50 KB |
| Lookup |
string compare |
~18 flash reads (~10 ms incl. WiFi RTT) |
| Collisions |
n/a |
0 at 141k (1 at 537k) |
Why 40 bits? It’s the sweet spot for this flash budget. Collisions follow the
birthday bound — at 141k domains you get ~0, at 537k about 1 (i.e. one unlucky
domain gets over-blocked). Dropping to 32 bits would save 20% of the flash but
cost ~7 collisions at 250k; going to 64 bits wastes 3 bytes per domain to solve
a problem you don’t have.
The same trick works on bigger chips — it isn’t a C3 workaround. On a 16 MB
ESP32-S3 these hashes hold ~2.7M domains vs ~466k for strings in 8 MB of
PSRAM. Hashes in flash beat strings in PSRAM basically everywhere; the C3 just
makes it undeniable.
Hardware
- Any ESP32-C3 board (tested on a C3 SuperMini), 4 MB flash, no PSRAM needed
- Classic ESP32 (DevKit / WROOM, 4 MB) also builds:
pio run -e esp32dev -t upload and pio run -e esp32dev -t uploadfs (confirmed on real hardware in #40; the C3 is the main tested target). Its temperature reading is uncalibrated, so treat it as approximate.
- ESP8266 (NodeMCU / D1 mini / any ESP8266EX with 4 MB flash) also builds:
pio run -e esp8266 -t upload — see ESP8266 notes for what changes
- Power it from a stable USB source (a phone charger or your router’s USB port).
Cheap/loose USB-C→A adapters can brown out the radio during WiFi transmit.
- A USB-A → USB-C dongle lets it plug straight into the spare USB port on the
back of most routers — no power supply, no extra box.
ESP8266 notes
The ESP8266 runs the blocker too — it never had PSRAM to begin with, and the whole
blocklist lives in flash as 40-bit hashes either way. It is the slowest and most
cramped target, but it is the cheapest board you can plug into a router.
pio run -e esp8266 -t upload
pio run -e esp8266 -t uploadfs
What changes on the ESP8266 (the C3 and classic-ESP32 builds are untouched):
|
ESP32-C3 |
ESP8266 |
| Flash used |
~1.24 MB |
~0.50 MB of a 1,044,464-byte app slot |
| Static RAM |
78 KB of 320 KB |
44 KB of 80 KB |
Flash index (INDEX_ENTRIES) |
4096 |
1024 |
Hash cache (CACHE_SIZE) |
256 |
64 |
| Tracked clients / custom domains |
96 / 200 |
32 / 50 |
| Client identification |
IP + MAC (lwIP ARP table) |
IP only (no ARP table exposed) |
| Temperature reading |
internal sensor |
0 °C (no sensor) |
| Filesystem |
1.3 MB LittleFS |
1 MB LittleFS |
The smaller index still resolves the 112,556-domain default list with no misses: the
widest bucket is 112 hashes, well inside the 256-hash scan window, so every stored
hash stays reachable. If you load a much larger list, raise INDEX_ENTRIES and keep
MAX_RANGE >= ceil(domains / INDEX_ENTRIES) — inFlash() clamps the bucket it scans
to MAX_RANGE, so a too-small value silently misses hashes.
Two shims make it work: src/prefs_shim.h backs the ESP32’s NVS Preferences API
with the ESP8266’s EEPROM, and getMac() is stubbed out (the ESP8266 exposes no ARP
table). Firmware OTA stages the image in the free sketch space between the app and
the filesystem, and the HTTPS blocklist fetch uses BearSSL (setInsecure(),
setBufferSizes(8192, 1024)) instead of mbedTLS.
Enclosure
A printable case for the C3 SuperMini: hardware/esp32-c3-supermini-enclosure.stl
Printing notes:
- No supports needed; 0.2 mm layers, ~15% infill is plenty.
- Keep the antenna end clear. The C3’s PCB antenna is the zig-zag trace on the
short edge opposite the USB-C port — don’t bury it in solid plastic or put metal
near it, or your RSSI will suffer.
- Leave the vents open: the board idles around 45–55 °C.
The C3 case does not fit an ESP8266 dev board (it is 44 × 25 mm, sized for
the SuperMini footprint). For NodeMCU / D1 mini class boards use the parametric
case: hardware/esp8266-enclosure.py writes the
STL itself (no CAD tool needed), and
hardware/esp8266-enclosure.scad is the same
geometry for OpenSCAD. Pre-generated for the 30 × 58 mm NodeMCU:
hardware/esp8266-nodemcu-enclosure.stl
Measure the board first — width across the two header rows, and length from the
USB end to the antenna end — then regenerate:
python3 hardware/esp8266-enclosure.py --board-w 30 --board-l 58 --out case.stl
It is a tray: the board drops in from the top, the side ribs catch the header
strips, the end wall leaves a slot for the USB plug, and the antenna end is left
open. Same printing notes as above.
One USB flash to get going — after that, firmware and blocklist both update over WiFi (see below).
⚠️ Use a current PlatformIO — the VSCode PlatformIO extension’s bundled core, or
pip install -U platformio in a venv. The distro/apt platformio package (e.g. 4.3.4) is
too old and fails with AttributeError: ... 'resultcallback' (issue #4). The platform version is
pinned in platformio.ini (newer ones build the C3 image too big for the OTA slot).
# 1. copy the secrets template (gitignored, stays local) and edit it:
# - WIFI_SSID / WIFI_PASS are optional — leave the placeholders and use the
# on-device setup portal instead (below).
# - WEB_USER / WEB_PASS / OTA_PASS are NOT optional: they gate the dashboard's
# state-changing endpoints (/ban, /addblock, /upload, /update, /setupdate,
# /forgetwifi) and network OTA. Pick real values — these used to be wide
# open to anyone on the LAN.
cp src/secrets.example.h src/secrets.h
# then edit src/secrets.h
# 2. build the blocklist hash table (default = StevenBlack base + Hagezi Light,
# ~100k entries, WhatsApp/social safe)
python3 tools/build_blocklist.py data/blocklist.bin
# 3. flash firmware + the blocklist filesystem (the one and only USB flash)
pio run -t upload
pio run -t uploadfs # classic ESP32: add -e esp32dev to BOTH commands
# 4. watch it boot, note the IP / open the dashboard
pio device monitor # -> http://c3adblock.local
Your own blocklists
build_blocklist.py OUT.bin [SOURCE ...] takes any mix of URLs and local files, in any of
these formats:
- hosts files —
0.0.0.0 ads.example.com tracker.example.com (all domains on the line are included)
- plain domain lists — one domain per line
- AdGuard / Adblock basic rules —
||ads.example.com^ blocks, @@||ok.example.com^
removes a domain (e.g. to mirror an AdGuard Home allowlist)
A blocked domain also blocks its subdomains. Rules a DNS hash list can’t express (regex,
wildcards, $ modifiers, cosmetic ## rules) are skipped and counted. An @@ rule only
un-blocks that exact entry — it can’t carve a subdomain out of a blocked parent. If a source
can’t be downloaded the build stops instead of silently producing a smaller list
(--allow-missing to override).
WiFi setup (no re-flash needed)
If it can’t connect (or you never set secrets.h), it starts an open access point
C3-AdBlock-XXXX with a captive portal — join it from a phone, pick your network,
type the password, done. To move it to a new network later: click Forget WiFi on
the dashboard, or hold the BOOT button while powering on, and the setup portal
comes back. (/forgetwifi requires auth now, so it’s no longer a bare URL you can
just visit — see Security below.)
Some C3 SuperMini boards have a poorly matched antenna and misbehave at full TX power
(#50). The setup AP always runs at 8.5 dBm, and if the C3 can’t join your WiFi at full
power it retries once at 8.5 dBm. To pin a power level, build with e.g.
-DWIFI_TX_POWER=WIFI_POWER_8_5dBm in build_flags.
Over-the-air updates (no more USB)
The dashboard at http://c3adblock.local does it all:
4 MB flash tradeoff: firmware OTA needs two app slots, which leaves ~1.3 MB for the
blocklist (~250k domains max). The aggressive 537k “ultimate” list only fits the
single-app partition table (no firmware OTA). Pick your tradeoff in partitions.csv.
Security
The dashboard’s read-only view (/, /stats.json) stays open, but every
state-changing endpoint requires HTTP Basic Auth (WEB_USER/WEB_PASS from
secrets.h):
/ban, /addblock, /unblock, /forgetwifi
/upload, /update (blocklist and firmware OTA)
/setupdate, /fetchnow
Network OTA (ArduinoOTA, e.g. pio run -t upload --upload-port c3adblock.local --upload-protocol espota) requires OTA_PASS from the same file.
Without this, anyone who could reach the device on the LAN could reflash it
with arbitrary firmware or rewrite the blocklist with zero credentials — worth
knowing given the device sits in the path of every DNS query on your network.
Custom blocked-domain names are also HTML-escaped before being rendered on the
dashboard, closing a stored-XSS path where a domain string containing markup
(added via /addblock) would otherwise execute in the viewing browser.
Basic Auth here is a LAN-trust-boundary control, not encryption. Everything
is plain HTTP on :80 — this chip has no realistic budget to run a TLS server.
Basic Auth credentials are base64 (not encrypted) and sent on every authenticated
request; anyone who can already sniff your LAN traffic (open/guest WiFi, ARP
spoofing) can read them off the wire. This hardens against the common case —
another device on your network hitting the API with no credentials at all, or a
browser tab CSRF’ing it — not against an on-path network attacker.
CSRF via cached Basic Auth: browsers auto-attach cached Basic Auth
credentials to any subsequent request to an already-authenticated origin —
including one triggered by a totally unrelated page the same browser visits
later (e.g. <img src="http://c3adblock.local/forgetwifi">, no JS required).
That would let any webpage silently drive this API once you’ve logged into the
dashboard once, regardless of who’s on your LAN. Every mutating endpoint above
now also requires a custom X-Requested-With: c3-adblock header, which a plain
<img>/auto-submitted <form> CSRF can’t attach (only same-origin fetch()
can, which is what the dashboard’s own JS does) — this is why /forgetwifi is
no longer a bare URL you can visit directly; use the dashboard button instead.
Default credentials: if secrets.h still has the placeholder
CHANGE_ME_WEB_PASSWORD / CHANGE_ME_OTA_PASSWORD values from
secrets.example.h, the device boots with a “password” that’s public (it’s
sitting in this repo’s example file). The firmware logs a warning over serial
and shows a banner on the dashboard when this is the case — but it will still
boot and run, so don’t skip setting real values in secrets.h before trusting
this on a network you don’t fully control.
Out of scope for now: the WiFi setup portal’s access point (C3-AdBlock-XXXX)
is still open (unencrypted) by design — it needs to be joinable without knowing
a password first. The real WiFi password you type into the portal is only as
safe as that local radio link during the brief setup window.
Use it
Point a device’s DNS at the C3’s IP, or add it as a secondary resolver behind
your main DNS.
Upstream resolver: allowed lookups go to Quad9 by default. You can change it under
UPSTREAM DNS on the dashboard (presets, any IPv4, or “use router’s DNS”). Keep a public
resolver if this box is the backup for a Pi-hole/AdGuard your router hands out, because
forwarding to it would fail exactly when the backup is needed. (From China, AliDNS
223.5.5.5 is much faster than Quad9; see #47.)
IPv6: this resolver answers over IPv4. If your router also advertises an IPv6 DNS
server (RDNSS/DHCPv6), devices may use that and bypass the blocker, so turn it off or point
it elsewhere on the router.
Test:
dig @<c3-ip> doubleclick.net # -> 0.0.0.0 (blocked)
dig @<c3-ip> github.com # -> real IP (forwarded)
Gotchas (learned the hard way)
Done / how it could grow
- ✅ Web dashboard — per-client block/allow counts, ban a client, add custom domains
- ✅ mDNS (
c3adblock.local) for discovery
- ✅ OTA — firmware + blocklist update over WiFi, plus scheduled remote blocklist pulls
- ✅ Captive-portal WiFi setup (no hardcoded creds)
- ✅ Weekly blocklist release + validated updates (a bad download never replaces a working list)
- ✅ Configurable upstream resolver
- ⬜ One-click browser web-installer (built in
docs/, needs hosting again)
- ⬜ DNS answer cache + non-blocking forwarding (#40, #47)
- ⬜ Smaller on-flash format (#3/#21): 3-byte suffixes, ~40% more domains per MB
- ⬜ Act as the DHCP server (hand itself out as DNS) for true plug-and-play
Credits
Inspired by s60sc/ESP32_AdBlocker — the
“answer 0.0.0.0 for blocklisted domains” idea. This is an independent from-scratch
implementation focused on the hash-in-flash optimization for PSRAM-less chips.
License
MIT — see LICENSE.
评论