事务ID错乱惊魂:esp32-c3-adblock如何根治上游转发应答串包问题
【免费下载链接】esp32-c3-adblockPi-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项目地址: https://gitcode.com/GitHub_Trending/es/esp32-c3-adblock
esp32-c3-adblock 是一个运行在2 美元 ESP32-C3(无需 PSRAM)上的Pi-hole 风格 DNS 广告拦截器:它把 53.7 万个广告域名以 40 位 FNV-1a 哈希的形式存进 flash,约 10 毫秒完成一次拦截判定,只占约 50 KB 内存;命中黑名单的域名直接"黑洞"掉,未命中的则转发给上游 DNS 并回传应答。而在这个"转发-回传"环节,早期版本曾出现过一个极难排查的DNS 应答串包顽疾——本文就来复盘这场"事务ID错乱惊魂"的成因,以及它被彻底根治的完整思路。
一、惊魂现场:一次转发超时引发的"全员串包"
先看清一次域名解析的路径:
手机/电脑 → esp32-c3-adblock → 上游 DNS(默认 Quad9)
DNS 走 UDP,每条查询都携带一个事务ID(txid),应答必须带着相同的 txid 返回——这是客户端确认"这封应答是回给我的"的唯一凭证。惊魂的根源在于两个事实:
- C3 上所有客户端共享同一个上游 UDP 套接字;
- UDP 应答超时后,迟到的数据包不会自动消失,而是滞留在套接字缓冲区里。
于是链条就断了:A 的查询转发给上游后超时,C3 认为失败;下一位用户 B 的查询进来,C3 顺手把缓冲区里 A 那条迟到应答转发给了 B,B 的应答又被给了 C……正如源码注释里那句"every answer shifted by one"(每条应答都错位了一位)。
用户视角的表现是"某个网站随机解析到错误 IP、时好时坏、无法复现"——典型的间歇性故障,靠猜 IP、猜时间都查不出来。
二、根治方案:forwardUpstream 的四道关卡
完整修复集中在 src/main.cpp 的 forwardUpstream 函数(即 Issue #10 的修复),可以拆成四步:
第 1 步:转发前先清空套接字"残留行李"
每次转发前先释放半读缓冲区,再循环排空、丢弃所有上一轮遗留的旧应答(main.cpp L248-L249),保证发新查询之前套接字是"干净"的。
第 2 步:每次转发都换一张"全新随机身份证"
转发出去的查询不再沿用旧事务ID,而是每次用esp_random()生成一个全新随机 txid(main.cpp L251)。旧应答的 txid 永远对不上新 ID,即使没被清空,也天然失效。
第 3 步:三重校验,只收"确实属于本次查询"的应答
等待应答期间(main.cpp L258-L266)不是"来者不拒",而是同时满足三个条件才接受:
| 校验项 | 防住的风险 |
|---|---|
| 源地址、源端口 == 上游 DNS | 防止局域网里任意设备伪造应答 |
| 应答 txid == 本次新生成的随机 ID | 证明应答确实针对"我的"查询 |
| 问题区(question section)与原始查询逐字节一致 | 最后一道保险,域名问的是同一个 |
等待策略用的是1 秒截止时间而不是重试次数(main.cpp L258):截止时间一到就放弃,避免在慢上游上空耗,也避免过早放弃。
第 4 步:回传前"换回客户的身份证"
通过校验后,C3 把报文头恢复成客户端自己的原始事务ID(main.cpp L267)再发回去。对客户端而言,这就像自己的查询得到了直接应答——中间的"换 ID 转发"完全透明。
🔍 一句话总结这套机制:清残留 → 换新 ID → 严格对账 → 换回原 ID。四步缺一不可:清残留防脏读,新 ID 断旧账,对账防冒牌,换回保证客户端无感知。
三、为什么这个修复对广告拦截器是"生死线"
- C3 挡在全网络每台设备的解析路径上,答错一次就是错误的网站或连接失败,用户会直接怪罪这个"小白盒";
- 设备只有约 50 KB 可用内存,养不起连接池、复杂状态机——"清残留 + 随机 ID + 三重对账"是超低资源环境下最稳的工程解;
- 这也是所有 UDP 服务的通用教训:永远不要假设套接字里只有自己的包,收到任何数据都要先验证"它是发给谁的"。
四、三分钟上手:验证你的 C3 不再串包
- 烧录固件与 blocklist 文件系统后上电,打开仪表盘
http://c3adblock.local; - 把任意设备的 DNS 指向 C3 的 IP(或作为二级解析器挂在主 DNS 之后);
- 用两条命令验证(README.md 中 "Use it" 一节):
dig @<C3的IP> doubleclick.net # -> 0.0.0.0(命中黑名单,已拦截) dig @<C3的IP> github.com # -> 真实 IP(转发上游,应答一一对应)只要被拦的域名稳定返回 0.0.0.0、放行的域名拿到正确 IP,"ID 错乱"就已经被根治。
五、相关文件速查
| 文件 | 说明 |
|---|---|
| src/main.cpp | 固件主程序:DNS 黑洞、上游转发、Web 仪表盘、OTA 与 WiFi 配网 |
| src/page.h | 仪表盘页面(PROGMEM 内嵌 HTML) |
| tools/build_blocklist.py | 拦截名单构建器:hosts/域名列表/Adblock 规则 → 排序哈希表 |
| tools/test_build_blocklist.py | 构建器单元测试 |
| partitions.csv | 分区表:黑名单容量与固件 OTA 的取舍 |
| platformio.ini | PlatformIO 构建配置(C3 SuperMini / 经典 ESP32) |
| README.md | 完整文档:硬件、烧录、安全模型与踩坑记录 |
✅ 打印一个 3D 外壳(hardware/esp32-c3-supermini-enclosure.stl),插进路由器背后空着的 USB 口,就是一台免电源、免维护的家用 DNS 广告拦截器。
【免费下载链接】esp32-c3-adblockPi-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项目地址: https://gitcode.com/GitHub_Trending/es/esp32-c3-adblock
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考