一个类似派洞(Pi-hole)的 DNS 广告拦截器运行在售价 2 美元的 ESP32-C3 上,无需 PSRAM。大家常忽略的一个技巧是,不需要将拦截列表保存在内存中,而是把域名作为排序后的 40 位 FNV-1a 哈希存储在闪存里并进行二分查找。超过 14 万个域名只需占用约 0.7 MB 闪存空间,使用约 50 KB 内存就能在约 10 毫秒内完成匹配。
正文
一个 Pi-hole 风格的 DNS 广告拦截器,运行在 2 美元的 ESP32-C3 上——不需要 PSRAM。它被 Tom's Hardware、XDA Developers 和 Korben 报道。关键技巧:不需要把屏蔽列表留在 RAM 里。把域名存成 flash 中已排序的 40 位哈希,然后二分查找。14 万+ 域名只占约 0.7 MB flash,匹配约 10 ms,用约 50 KB RAM。
查询进来 → 提取域名 → FNV-1a 哈希(含父域名后缀)→ 在 flash 哈希表中二分查找;命中返回 0.0.0.0(被 sinkhole),未命中转发给上游解析器并回传响应。
为什么有意思
大多数 ESP32 DNS sinkhole 把屏蔽列表(域名字符串)加载进 RAM,所以需要 PSRAM。这个项目改为在 flash 中存固定的 5 字节(40 位)哈希:
| | 字符串放 RAM 方案 | 这个方案(哈希放 flash) | | --- | --- | --- | | 硬件 | ESP32 + PSRAM(约 $8) | ESP32-C3,无 PSRAM(约 $2) | | 141k 域名 | 约 2.5 MB RAM | 0.67 MB flash | | 占用 RAM | 大部分 | 约 50 KB | | 查找 | 字符串比较 | 约 18 次 flash 读取(含 WiFi RTT 约 10 ms) | | 冲突 | 不适用 | 141k 时 0 个(537k 时 1 个) |
为什么是 40 位?这是这个 flash 预算下的甜点。冲突遵循生日界限——14.1 万域名约 0 个,53.7 万约 1 个(即一个倒霉域名被过度屏蔽)。降到 32 位可省 20% flash,但在 25 万时约 7 个冲突;升到 64 位每个域名浪费 3 字节,去解决一个不存在的问题。
同样的技巧在更大的芯片上也成立——这不是 C3 的变通。在 16 MB ESP32-S3 上,这些哈希能存约 270 万域名,而 8 MB PSRAM 里放字符串只能存约 46.6 万。哈希放 flash 几乎在所有地方都胜过字符串放 PSRAM;C3 只是让这点无可辩驳。
硬件
- 任意 ESP32-C3 板(在 C3 SuperMini 上测试),4 MB flash,不需要 PSRAM
- 经典 ESP32(DevKit / WROOM,4 MB)也能构建:
pio run -e esp32dev -t upload(社区贡献,已编译测试;C3 是测试目标) - 用稳定的 USB 电源供电(手机充电器或路由器 USB 口)。便宜/松动的 USB-C→A 转接头可能在 WiFi 发射时让射频欠压。
- USB-A → USB-C 转接头可让它直接插到大多数路由器背后的空闲 USB 口——不用电源,不用额外盒子。
外壳
C3 SuperMini 的可打印外壳:hardware/esp32-c3-supermini-enclosure.stl
打印注意:
- 不需要支撑;0.2 mm 层高,约 15% 填充就够。
- 保持天线端通畅。C3 的 PCB 天线是 USB-C 口对面短边上的锯齿走线——不要把它埋进实心塑料或靠近金属,否则 RSSI 会变差。
- 通风口保持打开:板子空闲约 45–55 °C。
构建与刷写(PlatformIO)
一次 USB 刷写就能跑起来——之后固件和屏蔽列表都通过 WiFi 更新(见下)。
使用最新的 PlatformIO——VSCode PlatformIO 扩展自带核心,或在 venv 里 pip install -U platformio。发行版/apt 的 platformio 包(如 4.3.4)太旧,会报 AttributeError: ... 'resultcallback'(issue #4)。一键浏览器安装器即将推出。
- 复制 secrets 模板(已 gitignore,留在本地)并编辑:
cp src/secrets.example.h src/secrets.h,然后编辑src/secrets.h。WIFISSID / WIFIPASS 可选——保留占位符,改用设备上的设置门户(见下)。WEBUSER / WEBPASS / OTA_PASS 不是可选:它们保护仪表盘的状态变更端点(/ban、/addblock、/upload、/update、/setupdate、/forgetwifi)和网络 OTA。选真实值——这些以前对 LAN 上任何人完全开放。 - 构建屏蔽列表哈希表(默认 = StevenBlack base + Hagezi Light,约 10 万条,WhatsApp/社交安全):
python3 tools/build_blocklist.py data/blocklist.bin - 刷写固件 + 屏蔽列表文件系统(唯一一次 USB 刷写):
pio run -t upload、pio run -t uploadfs - 看它启动,记下 IP / 打开仪表盘:
pio device monitor-> http://c3adblock.local
自定义屏蔽列表
build_blocklist.py OUT.bin [SOURCE ...] 接受任意混合的 URL 和本地文件,支持这些格式:
- hosts 文件——
0.0.0.0 ads.example.com tracker.example.com(行内所有域名都包含) - 纯域名列表——每行一个域名
- AdGuard / Adblock 基本规则——
||ads.example.com^屏蔽,@@||ok.example.com^移除域名(例如镜像 AdGuard Home 允许列表)
被屏蔽的域名也会屏蔽其子域名。DNS 哈希列表无法表达的规则(regex、通配符、$ 修饰符、外观 ## 规则)会跳过并计数。@@ 规则只解除该确切条目的屏蔽——不能从被屏蔽的父域名中切出一个子域名。如果某个源无法下载,构建会停止,而不是悄悄生成更小的列表(用 --allow-missing 覆盖)。
WiFi 设置(无需重刷)
如果连不上(或你从未设置 secrets.h),它会启动一个开放接入点 C3-AdBlock-XXXX,带强制门户——用手机加入,选网络,输密码,完成。之后要移到新网络:在仪表盘点 Forget WiFi,或上电时按住 BOOT 按钮,设置门户会回来。(/forgetwifi 现在需要认证,所以不再是可以直接访问的裸 URL——见下面的安全。)
OTA 更新(不再需要 USB)
仪表盘 http://c3adblock.local 全都能做:
- 屏蔽列表——把新构建的
blocklist.bin拖到 Blocklist → Upload,或在 Remote auto-update 下设置 URL,设备会按计划拉取预构建的blocklist.bin。默认列表由 GitHub Actions 每周一重建,发布在稳定 URL,所以粘贴一次就能让设备自行保持最新:https://github.com/M-Abozaid/esp32-c3-adblock/releases/download/blocklist/blocklist.bin - 固件——在 Firmware → OTA update 上传
.pio/build/c3/firmware.bin;设备验证后重启进新镜像。或从 CLI 通过 WiFi 推送:pio run -t upload --upload-port c3adblock.local --upload-protocol espota
4 MB flash 的取舍:固件 OTA 需要两个 app 槽,留约 1.3 MB 给屏蔽列表(最多约 25 万域名)。激进的 53.7 万“终极”列表只能用于单 app 分区表(无固件 OTA)。在 partitions.csv 里选取舍。
安全
仪表盘只读视图(/、/stats.json)保持开放,但每个状态变更端点都要求 HTTP Basic Auth(secrets.h 里的 WEBUSER/WEBPASS):/ban、/addblock、/unblock、/forgetwifi、/upload、/update(屏蔽列表和固件 OTA)、/setupdate、/fetchnow。网络 OTA(ArduinoOTA,如 pio run -t upload --upload-port c3adblock.local --upload-protocol espota)要求同一文件里的 OTA_PASS。没有这些,LAN 上能访问设备的人就能零凭证重刷任意固件或重写屏蔽列表——考虑到设备位于你网络每个 DNS 查询的路径上,这点值得知道。自定义屏蔽域名在仪表盘渲染前也会做 HTML 转义,堵住一条存储型 XSS 路径:否则通过 /addblock 添加的含标记域名会在查看者浏览器里执行。
这里的 Basic Auth 是 LAN 信任边界控制,不是加密。所有东西都是 :80 上的明文 HTTP——这个芯片没有现实预算跑 TLS 服务器。Basic Auth 凭证是 base64(未加密),每次认证请求都会发送;任何已经能嗅探你 LAN 流量的人(开放/访客 WiFi、ARP 欺骗)都能从线上读到。它加固的是常见情况——你网络里另一台设备零凭证打 API,或浏览器标签页 CSRF——不是路径上的网络攻击者。
通过缓存 Basic Auth 的 CSRF:浏览器会把缓存的 Basic Auth 凭证自动附加到对已认证源的任何后续请求——包括同一浏览器之后访问的完全无关页面触发的请求(例如用 http://c3adblock.local/forgetwifi 这样的图片,不需要 JS)。一旦你登录过仪表盘,任何网页都能静默驱动这个 API,不管谁在 LAN 上。上面每个变更端点现在还需要自定义 X-Requested-With: c3-adblock 头,普通 /auto-submitted <form> CSRF 无法附加(只有同源 fetch() 可以,也就是仪表盘自己的 JS 做的)——这就是为什么 /forgetwifi 不再是你可以直接访问的裸 URL;用仪表盘按钮代替。
默认凭证:如果 secrets.h 里仍是 secrets.example.h 的占位值 CHANGE_ME_WEB_PASSWORD / CHANGE_ME_OTA_PASSWORD,设备会带着一个公开的“密码”启动(它就在本仓库的示例文件里)。这种情况下固件会通过串口记录警告,并在仪表盘显示横幅——但它仍会启动并运行,所以在不完全控制的网络上信任它之前,不要跳过在 secrets.h 里设真实值。
目前不在范围内:WiFi 设置门户的接入点(C3-AdBlock-XXXX)按设计仍是开放(未加密)——它需要能在不知道密码的情况下加入。你在门户里输入的真实 WiFi 密码,只在短暂设置窗口内和那段本地无线电链路一样安全。
使用
把设备的 DNS 指向 C3 的 IP,或把它加为主 DNS 后面的次级解析器。测试:
dig @<c3-ip> doubleclick.net -> 0.0.0.0(被屏蔽)
dig @<c3-ip> github.com -> 真实 IP(被转发)
踩过的坑
- ModemManager(Fedora/Ubuntu 默认)会抓住
/dev/ttyACM0并切换 DTR/RTS,这会重置 C3 并阻塞串口。修复:sudo systemctl stop ModemManager,然后加 udev 规则ATTRS{idVendor}=="303a", ENV{ID_MM_DEVICE_IGNORE}="1"到/etc/udev/rules.d/99-esp-no-modemmanager.rules,重新加载规则并触发。 - C3 的 USB-Serial-JTAG 控制台在主机连接前会吞掉早期启动输出(
while(!Serial)有帮助)。 - DNS 客户端会加 EDNS OPT 记录;被屏蔽的回复必须只包含 question + answer(ANCOUNT=1, NSCOUNT=ARCOUNT=0),否则格式错误。
已完成 / 可扩展
- ✅ Web 仪表盘——每客户端屏蔽/允许计数,封禁客户端,添加自定义域名
- ✅ mDNS(
c3adblock.local)用于发现 - ✅ OTA——通过 WiFi 更新固件 + 屏蔽列表,外加定时远程拉取屏蔽列表
- ✅ 强制门户 WiFi 设置(无硬编码凭证)+ 一键浏览器 web 安装器
- ⬜ 分桶前缀索引——每次查找约 18 次 flash 读取 → 约 1–2 次(issue #3),吞吐量提升
- ⬜ 充当 DHCP 服务器(把自己作为 DNS 分发出去)实现真正即插即用
致谢
灵感来自 s60sc/ESP32_AdBlocker——“对屏蔽列表域名返回 0.0.0.0”的想法。这是一个独立从零实现的版本,专注于为无 PSRAM 芯片做哈希放 flash 的优化。
许可证
MIT——见 LICENSE。
评论
多数人肯定将屏蔽列表植入微控制器的创意,但质疑其十毫秒延迟与黑名单模式实用性;分歧主轴在于白名单是否更合适及文档是否过于人工智能化。
认为延迟过高影响实用
- 用户指出十毫秒查询延迟过慢,整体方案可能因延迟高而难用(较多人) > 十毫秒?天哪太慢了(@jolux)
认为白名单比黑名单更合适
- 白名单条目少、大多域名映射静态可内存缓存,此微控制器方案反而更适合允许列表模式(较多人) > 白名单更小,多数人并非每天访问新站,所需域名远少于十四万(@1vuio0pswjnm7)
反感文档像人工智能生成
- 有人批评项目说明文档充斥人工智能套话,呼吁作者亲自撰写关键句子(较多人) > 请务必自己写下 README 的第一句话(@timvdalen)
指出哈希碰撞讨论偏离重点
- 项目对哈希碰撞的说明令用户意外,实际风险是不同域名同哈希导致误屏蔽,但已有后台解锁接口可缓解(少数人) > 哈希碰撞真正问题在于误判如谷歌与广告服务器同哈希,而非两个坏域名同哈希(@muti)
担忧单设备失败导致断网
- 类似 PiHole 方案若硬件故障会使整个网络瘫痪,建议支持多设备冗余(少数人) > 设备一坏网络就挂,换成 NextDNS 了,多设备支持或许能解决(@jwillmer)
质疑为何不直接用普通 DNS 方案
- 有人不解为何要用此类硬件屏蔽,认为本地跑 unbound 等正规 DNS 更直接(少数人) > 搞不懂 PiHole 的意义,本地用 unbound 不香吗(@nicman23)
调侃命名糟糕但认可创意
- 有用户吐槽项目名字难听,但同时承认想法很酷,反映社区对创意有兴趣但对包装无感(少数人) > 这项目名真烂哈哈,不过确实酷(@manlymuppet)
有价值的补充
- 硬件上,ESP32-C3 普通版内置天线极弱,选购应挑可外接天线型号。
- 开发框架上,ESP32 用原生 ESP-IDF 扩展优于 PlatformIO,后者仅适合替代 Arduino IDE。
- 存储上,采用布隆过滤器等概率数据结构可塞入更多域名,虽引入误判但符合项目已有权衡。