装完 AdGuard Home 那阵子我挺得意:53 端口全接管,路由器上看什么都干净。但过了一两个月,我开始有点疑心——手机上那些开屏广告,好像一条都没少。

原因不难想。过滤只对"从 AdGuard Home 走的查询"生效,而现实里一大半设备和 App 压根不理你下发的 DNS:有的自己写死一个 8.8.8.8,有的系统层开了 DoT/DoH,加密着就出去了。路由器在中间干看着,一点办法没有。

麻烦的地方在于你根本不知道是谁在绕,所以我没急着拦,先干了个笨办法:挂条规则只记账、什么都不拦,让它自己跑,大半天之后数据出来——家里有 4 台设备在绕,其中一台还是"绕了也白绕",它的 DNS 指着一个早就删掉的旧网段。

这篇记录完整过程:怎么把绕的人揪出来、为什么不能用 OpenWrt 自带那个「DNS 重定向」开关、规则怎么写、以及怎么证明它真的生效了(这一步最反直觉,我差点自己骗自己)。后来我把这套东西做成了 LuCI 应用,开关都搬进界面了。文末有 4 个坑,前三个都属于"配置看着生效了其实没生效"那种。如果你也在用 iStoreOS / OpenWrt 当家做主路由,可以直接抄。

先说结论

  • 先观察,别急着拦。直接拦会把"本来就解析失败"的设备一起掩盖掉。我这一步救回了一台手机。
  • OpenWrt 自带的「DNS 重定向」开关,在我这儿是禁区。它重定向的目标是 dnsmasq 自己的端口。我这台机器 53 已经归 AdGuard Home、dnsmasq 退到 5353 了,打开那个开关等于让全屋绕开 AdGuard Home,广告过滤集体失效。
  • 正确做法是在 nftables 层做透明重定向。把发往外部 :53 的 TCP/UDP 查询改道到本机。客户端毫无感觉,硬编码 DNS 的设备一个设置都不用改。
  • 真正难的不是写规则,是"证明它生效了"。透明重定向会让客户端回显依旧显示"服务器:8.8.8.8"——照着客户端看,你永远测不出它有没有在工作。
  • 改 DNS 得给自己留后路。改错了就是全屋上不了网,而且你连路由器都进不去(管理界面也得靠域名解析)。所以我加了三道保险,后面单独说。

第一步:先挂个只记账的规则,观察了大半天

观察模式特别简单:挂一条只计数、只记日志、绝不拦截的规则,相当于给防火墙装了个"谁在看外面 DNS"的计数器,别的什么都不干。

## /etc/nftables.d/20-dns-guard.nft  —— 观察模式(policy accept,只记录)
chain dns_guard_pre_forward {
    type filter hook forward priority -1; policy accept;

    iifname "br-lan" oifname "pppoe-wan" meta l4proto { tcp, udp } \
        th dport { 53, 853 } counter log prefix "DNS-BYPASS-53853: "

    iifname "br-lan" oifname "pppoe-wan" tcp dport 443 \
        ip daddr @doh_servers counter log prefix "DNS-BYPASS-DOH: "
}

有三个细节我特意确认过,都属于"不确认就会看错数"的类型:

  • 只管"LAN → 外网"这一个方向(iifname br-lan + oifname pppoe-wan),不然路由器自己向上游发的正常查询也会被一起抓进来,数字直接翻倍。
  • 53 和 853 一起看。853 是 DoT,看不见内容,但能看见"有人在用"。
  • DoH 只能按 IP 猜。443 是加密的,判断不了内容,只能自己维护一份"已知 DoH 服务商地址"的集合(doh_servers)。所以这一块天生比前两个糙。

另外我还顺手抓了一份包(tcpdump 过滤 br-lan 上非内网目标的 53/853,以及发往 DoH 地址的 443)。这一步后来证明是"认人"的关键——日志只能告诉你"192.168.100.198 查了 120.76.196.169",抓包才能告诉你"它查的是哪个域名"。

观察结果:4 台设备在绕

6 小时 34 分钟(11:32 → 18:19),两个计数器一共记下 4932 条,按端口拆开是:明文 53 有 1537 条、DoT 853 有 2162 条、DoH 有 1233 条。按设备归完类,全集中在 4 台:

设备量级绕过方式主要目标推断用途
192.168.100.201
我的电脑
3743
(76%)
853 + DoH + 53
三管齐下
223.5.5.5:443、1.1.1.1:853、1.12.12.12:853、120.53.53.53:853、2400:3200::1:853代理客户端(mihomo 内核)加 DNS 测速优选;dot.pub、dns.cloudflare.com 的 SNI 都对得上
192.168.100.32
nas-vbr-lan1
568
(11%)
53 写死 + DoH8.8.8.8:53、8.8.4.4:53、223.5.5.5:443NAS 上跑着一个完整 Firefox;另外有「节点小宝」的域名
192.168.100.198
DBR-W10
403
(8%)
明文 53120.76.196.169:53、8.8.8.8:53大量 ANY? 查询加字节跳动系域名
192.168.100.123
REDMI-K80
86明文 53192.168.5.1、192.168.5.12指向一个已经不存在的旧网段 —— 查了也白查

剩下几台只有个位数到几十次,属于偶发或探活:Xiaomi-17(68 次,114.114.114.114:853 只有 SYN、对端不应答)、打印机(7 次)、三台身份没认出来的设备(各 1~4 次)。没有别的系统性绕过。

但光看 IP 和次数是认不出"是谁"的。抓包把域名带出来之后,故事才算完整:

iStoreOS 软路由 DNS 绕过观察期统计:4 台设备的绕过量级、目标 DNS 服务器与域名证据
观察期的三个证据层:nft 计数器 → 按设备归因的日志聚合 → 抓包里的具体域名
  • 我的电脑查的是 appav1.llguangli25n.com(74 次)、dot.pub(66 次)、appav1.apiv2.a047.com(63 次)。前一个是机场域名,后一个是 DNSPod 的 DoT 域名——代理客户端加 DNS 优选,实锤。
  • NAS 查的是 firefox.settings.services.mozilla.com(182 次)、push.services.mozilla.com(154 次)、c.iepose.com(49 次)。一个完整的 Firefox 实例,外加「节点小宝」。
  • DBR-W10 这个最怪:133 次 ANY? 查询,连域名都没有,只有类型 ANY。典型的字节系 SDK 行为。
  • 红米 K80 查的是微信域名(res.wx.qq.com、mmbiz.qpic.cn、wximg.wxs.qq.com),但全都发往 192.168.5.1。

这一步救了一台手机

红米 K80 的 DNS 被静态设成了 192.168.5.1 和 192.168.5.12,大概是以前接别的网络时填的,而这个网段在我现在的网络里根本不存在。结果就是它那 86 次查询全都有去无回,一次都没成功过。

如果我第一步就上 REJECT,这台手机的表现会是"DNS 被拦了",看着就像我的规则干的。而它其实本来就在解析失败——我的规则只是让它失败得更彻底一点,然后永远查不出来。

反过来,透明重定向正好能治它:把发往 192.168.5.1 的查询也一并改道到 AdGuard Home 就行,手机一个设置都不用动。不过这也带出了下面那条很坑的规则。

OpenWrt 自带的「DNS 重定向」开关,为什么在我这儿是禁区

LuCI 的 DHCP/DNS 页面上有个「DNS 重定向」开关,看名字正是我要的功能。但我先去瞄了一眼它的实现(/etc/init.d/dnsmasq):

config_get dns_port "$cfg" port 53
nft add table inet dnsmasq
nft add chain inet dnsmasq prerouting "{ type nat hook prerouting priority -95; policy accept; }"
nft add rule inet dnsmasq prerouting "meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :$dns_port"

问题出在 $dns_port 上——它取的是 dnsmasq 自己的监听端口。标准 OpenWrt 里的 53 归 dnsmasq,所以这个开关没毛病;但我这台机器上 53 已经交给 AdGuard Home,dnsmasq 的端口已经改成 5353,只管 DHCP 和 LAN 侧本地域名。于是打开这个开关的后果是:

缺陷后果
重定向目标是 5353(dnsmasq)全屋绕开 AdGuard Home,广告过滤、恶意域名拦截一起失效
规则只匹配 udp dport 53TCP 53 完全没覆盖(现在不少客户端会走 TCP)
没有任何 daddr 条件连"发给内网某台 DNS 服务器"的查询也会被劫持

结论很清楚:在我这套架构下这个开关碰不得。它做的事情方向是对的(透明重定向),只是目标端口和覆盖面都不对,只能自己写。

自己写:让 53 查询无声无息地改道

规则本身很短,就两条(IPv4 加 IPv6):

chain dns_guard_redirect {
    type nat hook prerouting priority -105; policy accept;

    iifname "br-lan" meta l4proto { tcp, udp } th dport 53 \
        ip  daddr != @dns_never_hijack4 counter redirect to :53

    iifname "br-lan" meta l4proto { tcp, udp } th dport 53 \
        ip6 daddr != @dns_never_hijack6 counter redirect to :53
}

redirect to :53(只给端口不给地址)就是改道到本机 :53。目标端口写死 53,也就是 AdGuard Home——不是 dnsmasq 的 5353。这是它和自带开关最本质的区别。

为什么优先级是 -105

fw4 自己的 DNAT 链叫 dstnat,挂在 priority dstnat 上(数值等于 -100)。我要抢在它前面,所以写 -105。装完从设备上看是这样:

chain dns_guard_redirect {
    type nat hook prerouting priority dstnat - 5; policy accept;   # dstnat - 5 就是 -105
    ...
}

顺带记一个容易踩的细节:/etc/nftables.d/ 里的文件是被 include 进 table inet fw4 内部的(设备上那份 README 写得很清楚)。所以文件里只写 set 和 chain,千万别再包一层 table { },包了直接语法错误。

两个容易反着来的地方

  • 排除段里不要放 192.168.0.0/16。看着挺合理("内网地址不该劫持"),但红米 K80 指的 192.168.5.1 正好落在这一段里,一排除它就会继续逃过重定向、继续打空。我的排除列表是故意只放自己真实的网段:192.168.100.0/24、172.16.0.0/12,加回环、链路本地和组播。
  • 排除的目标要精确到"我这边的网段",而不是"所有私有网段"。后者看着更安全,实际是把真正需要救的设备留在了外面。

怎么证明它真的生效了(这里最容易骗自己)

规则写完、防火墙重载,一切看起来都正常,但有个很反直觉的坑我必须单独讲:

透明重定向是在 nat 层改道的,客户端看到的应答者永远是它自己填的那个地址。从电脑上执行 nslookup baidu.com 8.8.8.8,回显还是:

服务器:  dns.google
Address:  8.8.8.8

哪怕它已经被改道到 AdGuard Home 了。照客户端去验证,你永远测不出它有没有工作——这跟上篇文章里那个"配置看着生效了其实没生效"是同一类事。能看的是路由器侧的两样东西:

  • nat 链的 counter 涨没涨。改道发生在转发之前,计数会实打实往上走。
  • AdGuard Home 的查询日志里有没有出现这条查询、有没有这台客户端。这条最硬——说明查询真的被 AdGuard Home 拿去解析了。

我的做法是先建基线再做对照:上线前从电脑查一次 8.8.8.8,AdGuard Home 日志命中 0;上线后同样再查一次,计数从 0 涨到 3,AdGuard Home 日志里出现了这条查询,从 upstream 字段还能看出实际是 dns.alidns.com 的 DoH 在解析它。

nftables 透明重定向链内容与端到端验证:客户端回显不变但 AdGuard Home 收到并解析了查询
客户端看到的应答者始终是 dns.google,但 nat 链计数从 0 涨到 3、AdGuard Home 日志里出现了这条查询——这才是收编成功的证据

一个提前知道能省事的口径变化

重定向上线之后,前面那个观察计数会立刻接近归零。原因很简单:53 的流量在 prerouting 就被截走了,根本走不到 forward 链。我这边观察链的两条规则里,53/853 那条变成 0,只剩 DoH 那条在涨。

这不是坏消息,是功能正常的标志。但得记住:以后想知道"谁还在绕",要看的是重定向链的计数(被收编了多少)和 AdGuard Home 的客户端列表,原来那个 bypass 计数不管用了。

顺手把它做成了 LuCI 应用

命令行能跑之后老问题又来了:改个参数还得 SSH 上去 uci set 加 fw4 reload。上篇文章我也是这么想的,然后就写了个界面——LuCI 应用不用 SDK、不用编译,本质就是几个文本文件丢进对应目录。

这次的应用叫 luci-app-dnsguard,入口在服务 → DNS 防绕过。上半部分是状态表,一眼看出重定向到底生效没有;下半部分是开关和参数。

iStoreOS LuCI 后台「服务 → DNS 防绕过」页面:左侧菜单入口与「当前状态」表格
真机截图:入口在 LuCI 的「服务 → DNS 防绕过」,状态表能一眼看出重定向到底有没有真的生效
DNS 防绕过的五个开关:总开关、53 透明重定向、观察计数、阻断 853、阻断 DoH 专用 IP
五个开关都带说明;853 与 DoH 两个我刻意留着关闭,等观察一天再决定

状态表里有一行是我特意加的:「53 透明重定向」。它会直接告诉你「已生效」,还是「配置为开启,但链不存在」。后者是我特意加上的——配置文件写对了不等于防火墙里真有这条链,这两种状态必须能分开。

四个按钮的分工:

按钮干什么什么时候用
保存并应用写 UCI → 渲染规则 → 校验 → 重载防火墙改完任何字段
按当前配置重新应用不读表单,直接用现有配置重跑一遍规则文件被别的东西改动了
健康自检10 项只读检查,出表格感觉 DNS 不对时的第一件事
预览将要生成的规则只渲染到临时文件,不碰防火墙想确认改动会写成什么样

另外「853 阻断」和「DoH 阻断」两个开关我也做进去了,但目前都关着(原因在最后一节)。

改 DNS 之前,我先给自己上了三道保险

这一节我觉得比规则本身更值得写。改 DNS 有个很讨厌的性质:改错了是全屋上不了网,而且你连路由器都进不去——因为连管理界面都得靠域名解析,只能靠记 IP 或者去插键盘。

第一道:三层校验,任一层不过就中止并还原

渲染出规则片段
  → 包进临时表名做 nft -c 语法预检     ← 语法错在这里就中止,/etc 一字未动
  → 落盘后跑 fw4 check(全量校验)      ← 失败立即还原旧文件
  → fw4 reload
  → 核对目标链是否真的存在             ← 不存在则还原 + 重载,并报错

顺便说一个我原来不知道的事:设备上其实有个 fw4 check 命令,能把完整规则集校验一遍而不加载。这比只校验自己那段片段权威得多。另外 nft -f 是原子事务,所以重载失败不会留下半套规则。

第二道:依赖的服务没跑,就拒绝启用

重定向规则和 AdGuard Home 是"同生共死"的关系:AdGuard Home 挂了而重定向还在,等于把所有设备的 DNS 都指到一个死端口上。所以应用里加了个闸门——发现 AdGuard Home 进程不在,直接拒绝应用重定向,一个字都不写:

# AdGuard Home 未运行时的返回
{"status":"error","message":"AdGuard Home 未运行(pidof 为空)—— 已拒绝应用 53 透明重定向:
 此时重定向会让全屋解析失败。请先启动 AdGuard Home。"}

我拿一个假的 pidof 顶掉真命令验证过:返回 error、退出码 3,而且规则文件的 md5 和改前逐字节相同。

第三道:延迟自动回滚——改完人走了,它也能自己退回去

这是我私心最喜欢的一道。当「53 重定向」从关改到开的那一刻,应用会自动做三件事:

  • 把当前的 UCI 配置和规则文件一起拍成回滚点(必须在写配置之前拍——顺序反了的话快照里存的就是新配置,回滚等于没回滚);
  • 起一个倒计时,默认 10 分钟;
  • 到点如果没人点「确认保留」,就把 UCI 和规则文件一起还原,并重载防火墙。

我拿 1 分钟实测跑了一遍完整的生命周期:

延迟自动回滚实测:倒计时到点后 UCI 配置与 nftables 规则一起被还原并重载防火墙
挂 1 分钟倒计时后故意不确认:UCI 被自己还原、链被摘干净、日志三段留痕

回滚的判定逻辑也挺有意思:倒计时进程不靠"把自己杀干净"来保证正确性,而是以状态文件为唯一真相。点「确认保留」时把那个文件删掉,倒计时进程醒来一看记录没了就什么都不做。这样哪怕进程没杀干净,也不会出现"我明明确认了它却回滚了"。

我踩的 4 个坑

四个坑,前三个都属于"东西坏了但看起来好好的":

#现象原因与修法
1配置里 5 个地址,生成的规则里只有 4 个。少的永远是每组的最后一个——224.0.0.0/4、ff00::/8、140.207.198.6 全没了printf '%s' 没补结尾换行,while read 循环把最后一行吞了。改成 printf '%s\n'。这个 bug 极具迷惑性:前面的全对,只少最后一项
2每点一次「保存并应用」,防火墙就重载一次生成的文件头有「生成时间」行,每次都不同,幂等比较永远判成"有变化"。修法是比较前先用 sed 剔除该行
3CIDR 被写坏:192.168.100.0/24 变成 192.168.100.024控制器里做字符白名单清洗时漏了 /。这个是靠"打桩直调控制器、比对改前改后的 uci show"抓出来的;而且只因为渲染层还有一道 nft -c 校验,坏值才没写进防火墙
4测试脚本报 ash: syntax error: unexpected "("在(被封装过的)shell 里写 PATH=/x:$PATH cmd,会被改写成带 $( ) 的形式。改用 /usr/bin/env PATH=... cmd 就稳了

第 3 个坑我印象最深。它最后没造成任何损失,纯粹是因为还有第二道防线兜住了。所以我的体会是:校验层至少得有两层(界面一道、落地一道),单层迟早会被绕过——而它被绕过的那一次,往往就是你完全没注意到的那次。

现在的效果,和两件我故意没做的事

上线之后有个挺反直觉的地方:你什么都感觉不到。没有设备报错、没有 App 重连、没有任何提示——这正是透明重定向该有的样子。能看到的只有两处:路由器上重定向链的计数慢慢往上涨,以及 AdGuard Home 的查询日志里开始冒出原本不会出现的客户端。

有两件事我是故意没做的:

  • 853(DoT)阻断先放着。DoT 没法透明重定向,只能拦。拦掉之后客户端通常会回落到明文 53、进而被重定向收编——但"通常"不等于"一定"。全网唯一的真实用户就是我这台电脑,而它还同时配着明文 53 上游,大概率能回落;可既然是自己的开发机,我打算单独确认一遍再开。
  • DoH 的 IP 层阻断不做。按 IP 命中太糙了:223.5.5.5:443 只是"恰好也在 DoH 服务商地址列表里",拦掉就容易误伤跑在同一个 IP 上的其他服务。更精准的做法是在 AdGuard Home 里加域名规则(像 ||doh.pub^ 这种),可审计、可回滚。

所以现在的状态是:53 已经收编,853 和 DoH 的开关留在界面上等观察,各自可以单独开关。

常见问题

重定向会不会让网络变慢?

不会。改道发生在 nat 层,客户端到本机 AdGuard Home 比到 8.8.8.8 还近一点。而且这条规则只匹配"发往外部 :53 的查询",正常上网流量压根不经过它。观察期本身也是零影响——只计数不拦截,跑了 6 个多小时没任何设备出现重试或超时。

如果 AdGuard Home 挂了会怎样?

这才是真正要防的。手动保存时应用会直接拒绝启用;但如果 AdGuard Home 是在启用之后挂的,规则还在防火墙里躺着。这时全屋解析会一起失败——注意,没有重定向它们也一样会失败,因为 53 本来就归 AdGuard Home。所以正确的做法是 /etc/init.d/dnsguard stop 把重定向链临时摘掉(配置不动),然后去救 ADGuard Home。

能不能只对部分设备生效?

可以,界面里有「例外设备」一栏,填进去的 IP 不做重定向(IPv4/IPv6 混填),「永不劫持」集合也能改。但我的建议是尽量别加白名单——透明重定向本来就是为了消灭"某些设备需要特殊照顾"这件事。反正这次我一个例外都没加。

小结

回头看,这套东西真正的价值不在那两条 nftables 规则上——它们加起来不到十行。

一个是"先观察再动手"。如果六个多小时前我直接上拦,红米 K80 那台手机的问题会被永久掩盖;现在反倒是重定向顺手把它治好了。

另一个是"把改网络当成危险作业来做"。多层校验、依赖闸门、延迟自动回滚,这三样加起来大概是规则本身的十倍代码量。但改 DNS 失败的代价是全屋断网外加进不去管理界面,这个保险付得值。

最后一点体会:透明重定向这类东西,"看起来生效了"是最没有信息量的一句话。客户端回显不会变、设备不会报错、App 不会重连。你必须去路由器上看计数、去 DNS 服务日志里找那条查询——不然就只是在猜。

如果你也在折腾家用软路由的 DNS,建议从"先挂条只记账的规则"开始。那种"原来家里有这么多设备在自己找 DNS"的发现,比直接拦下来有意思多了。