MIT License

项目介绍

M-Abozaid/esp32-c3-adblock 是一个托管在 GitHub 上的开源项目,主要使用 C++ 编写,采用 MIT License 许可证,目前获得 374 个 Star、32 次 Fork。

仓库全名
M-Abozaid/esp32-c3-adblock
开源许可证
MIT License
Star 数
374
Fork 数
32
未关闭 Issue
3

README 摘要

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…

完整内容见 GitHub 原始 README 或本页 。

常见问题

esp32-c3-adblock 用什么语言开发?

主要使用 C++ 编写,采用 MIT License 许可证,目前有 374 个 Star。

esp32-c3-adblock 可以免费商用吗?

仓库采用 MIT License 许可证,可免费使用与修改,商业使用请遵守许可证条款(例如保留版权声明与许可声明)。

如何获取 esp32-c3-adblock 的源码?

可以直接克隆仓库:git clone https://github.com/M-Abozaid/esp32-c3-adblock.git,默认分支为 main;安装与运行步骤见仓库 README。

esp32-c3-adblock 最近还在维护吗?

仓库最近一次更新为 7月16日,当前有 3 个未关闭 Issue;Star 数为 374,可作为社区活跃度的参考。

关于

Pi-hole-class DNS ad-blocker on a $2 ESP32-C3 (no PSRAM): 537k domains as 40-bit FNV-1a hashes in flash, binary-searched. UDP DNS sinkhole + web dashboard. https://youtube.com/shorts/RaxszOUMi8E?feature=share

描述取自 GitHub 官方 API,中文内容由 GitRead 中文社区 编辑团队整理。

简要分析

热度分
812
价值分
271
活跃状态
活跃

语言占比

C
C++
OpenSCAD
Python

Release

README

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.

Build & flash (PlatformIO)

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:

  • Blocklist — drop a freshly built blocklist.bin into Blocklist → Upload, or set a URL under Remote auto-update and the device pulls a prebuilt blocklist.bin on a schedule. A fresh default list is rebuilt every Monday by GitHub Actions and published at a stable URL, so pasting this once keeps a device current on its own: https://github.com/M-Abozaid/esp32-c3-adblock/releases/download/blocklist/blocklist.bin
  • Firmware — upload .pio/build/c3/firmware.bin under Firmware → OTA update; the device verifies it and reboots into the new image. Or push over WiFi from the CLI:
    pio run -t upload --upload-port c3adblock.local --upload-protocol espota
    

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)

  • ModemManager (default on Fedora/Ubuntu) grabs /dev/ttyACM0 and toggles DTR/RTS, which resets the C3 and blocks serial. Fix:
    sudo systemctl stop ModemManager
    echo 'ATTRS{idVendor}=="303a", ENV{ID_MM_DEVICE_IGNORE}="1"' | sudo tee /etc/udev/rules.d/99-esp-no-modemmanager.rules
    sudo udevadm control --reload-rules && sudo udevadm trigger
    
  • The C3’s USB-Serial-JTAG console can swallow early boot output until the host connects (while(!Serial) helps).
  • DNS clients add an EDNS OPT record; a blocked reply must contain only the question + answer (ANCOUNT=1, NSCOUNT=ARCOUNT=0) or it’s malformed.

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.

评论

暂无评论