专栏

从 AdGuardHome 到 smartdns + DoH 加密:OpenWrt 软路由 DNS 安全升级实录

OpenWrt 软路由 DNS 安全升级的完整实录。

附:zapret 编译血泪史与 DPI 对抗工具链部署


零、敌情侦查:学校网络到底在干什么

先抛一个问题:当你在宿舍打开浏览器输入 bilibili.com 时,学校网络能知道你去了哪吗?

答案是:能,而且比你想象的详细。

第一招:DNS 劫持与重定向

校园网的标准配置里,DNS 服务器通常被 DHCP 强制指定为学校的递归 DNS(或者更直接的——在出口路由器上把 UDP 53 端口全部劫持重定向)。效果就是:

你的设备 → 学校 DNS 服务器 → 记录日志 → 上游查询 → 返回结果

你解析过的每一个域名,学校都有完整的日志:几点几分、哪个 IP、查了什么域名。不需要"监控"你,DNS 日志就是天然的上网行为画像。

更阴险的玩法是 DNS 污染——对特定域名返回假 IP,或者干脆返回空结果,让你以为"这个网站打不开"。你没报错,学校也没封你,只是你永远连不上。

第二招:DPI 流量指纹

DNS 日志告诉你"查了什么",但深度包检测(DPI)告诉你"在干什么"。

TLS 加密让 DPI 看不到 HTTP 请求的具体内容(URL、Cookie、POST 参数),但它能看到:

DPI 能看到的含义
TLS SNIClientHello 中明文的域名(如 bilibili.com
目标 IP你连接的是哪个服务器
数据包大小/时序流量模式指纹(视频流?API 调用?大文件下载?)
连接持续时间你在某个服务上停留了多久
TLS 指纹(JA3/JA4)你用的什么客户端(浏览器类型、Python/Go 脚本等)

组合起来能做很多事:

第三招:IP 黑名单 + SNI 阻断

即使 DNS 换了,直接访问 IP 呢?

运营商的 DPI 设备维护着庞大的 IP 和 SNI 特征库。连接到某些 IP 段或 TLS 握手中出现某些 SNI 值时,直接在 TCP 层注入 RST 包强制断连。你连 TCP 三次握手都完成了,数据传输刚开始,一个 RST 过来全断了。

这就是为什么有时候同一个域名,有时能开有时不能、WiFi 能开流量不能——因为触发了不同网络路径上 DPI 设备的规则。

不防御的隐患

如果什么都不做,校园网环境下的真实处境:

┌──────────────────────────────────────────────┐

│ 学校 DPI 设备 │

│ │

│ 你的 DNS 查询日志 ← 永久留档 │

│ 你的 TLS SNI ← 明文可见 │

│ 你的连接目标 IP ← NAT 层必然暴露 │

│ 你的流量模式指纹 ← 可分类/识别/阻断 │

│ 你的客户端 TLS 指纹 ← 可识别工具类型 │

│ │

└──────────────────────────────────────────────┘

这不是在说"有人在看你"——这本身就是校园网基础设施的标准功能。你不上网则已,上网就必然经过这套系统。

更高阶的防御手段(简介)

本文的核心方案是 DNS 加密 + DPI 流量混淆,刚好覆盖了前三层的防御。但往上还有更激进的方案:

层级方案覆盖范围难度
DNSDoH/DoT 加密域名查询内容⭐ 低(本文已实施)
DPI 指纹nfqws/zapret 流量混淆TLS 握手特征⭐⭐ 中(本文已编译部署)
SNI 加密ECH (Encrypted Client Hello)TLS SNI 明文⭐⭐⭐ 高(需服务端支持,国内站基本无)
全流量隧道WireGuard/OpenVPN所有 TCP/UDP 流量⭐⭐⭐ 高(需境外服务器 + 足够带宽)
协议混淆V2Ray XTLS/VLESS + 伪装深度隐蔽⭐⭐⭐⭐ 很高(法律风险,本文不涉及)
物理隔离独立 4G/5G 热点完全绕过校园网💰 取决于流量套餐
ECH 是 TLS 1.3 的扩展,把 SNI 也加密了——但需要服务端(比如 Cloudflare)打开,国内网站基本不支持。 全流量隧道 是最干净的方案:所有流量封装进一条加密隧道,DPI 只能看到一个"未知加密流"。代价是需要境外服务器和足够带宽,本文作者的在云端带宽不足以支撑视频流量。 协议混淆 是更高阶的玩法:把 VPN 流量伪装成 HTTPS/WebSocket/gRPC,配合 CDN 中转,让 DPI 彻底认不出。但这涉及更复杂的法律和技术风险,不在本文讨论范围。

本文聚焦于不依赖境外服务器、不涉及法律灰色地带、在现有硬件条件下可实施的防御方案。


一、缘起:Go 语言的"倔脾气"

一切始于一条命令的失败。

在 OpenWrt 软路由上,AdGuardHome 作为主力 DNS 服务器运行了很久。它界面美观、功能齐全,唯一的毛病是:不认系统 CA 证书

AdGuardHome 用 Go 语言编写。Go 的 TLS 证书验证走的是自己的逻辑——它内置了一套根证书,完全无视系统的 CA 存储。无论你把自签名 CA 证书放到 /etc/ssl/certs/,还是设置 SSL_CERT_FILE 环境变量,Go 程序通通看不见。之前折腾过的 dnsproxy(也是 Go)死在了同一个坑里。

这意味着:如果你的 DoH 服务端用的是自签名证书,Go 写的 DNS 工具全都连不上

于是 DNS 查询一直以 UDP 明文飞向公共 DNS,在运营商 DPI 面前毫无遮掩。

二、换将:smartdns 上场

方案很明确:换一个走系统 CA 库的 DNS 服务器。

smartdns 是 C 语言写的,链接系统的 libcrypto/libssl,天然使用系统的 CA 证书链。而且它有一个 AdGuardHome 没有的特性:并发多上游查询,取最快响应——这种"多路归并"策略在延迟敏感的 DNS 场景下非常实用。

安装很简单:

opkg install luci-app-smartdns

自动依赖安装 smartdns 1.2023.43

停掉 AdGuardHome,切换为 smartdns:

/etc/init.d/adguardhome stop

/etc/init.d/adguardhome disable

核心配置——上游 DNS 服务器(/etc/smartdns/custom.conf,非 UCI 配置):

# 主 DoH — 阿里云自建 dnsproxy(自签证书,跳过验证)

server-https https://<云服务器IP>:8443/dns-query -no-check-certificate

# 备用 DoH — 阿里公共 DNS(host-name 伪装 SNI)

server-https https://dns.alidns.com/dns-query -host-name www.aliyun.com

# 备用 DoH — DNSPod(host-name 伪装 SNI)

server-https https://doh.pub/dns-query -host-name www.dnspod.cn

# 100% DoH 加密 — 零 UDP 明文泄露

smartdns 对所有上游同时发起查询,取最快响应。三路 DoH 全部加密,-host-name 参数伪装 TLS SNI(握手中域名显示为 www.aliyun.com / www.dnspod.cn),规避 DPI 的 DoH 协议指纹。即使抓包看到 8443 端口的 TLS 流量,也无法判断是 DNS 查询。零 UDP 明文泄露。

验证流量是否加密:

tcpdump -i eth1 host <云服务器IP>

输出中看不到任何 DNS 明文——全是 TLS 加密流量,UDP 53 端口的明文字符串"baidu.com"消失了。

最后,开机自启:

/etc/rc.d/S19smartdns enable

三、架构升级:从裸奔到加密

升级前:
客户端 → AdGuardHome(:53) → UDP 明文 → 公共 DNS

运营商 DPI 设备可以清清楚楚看到你在解析什么域名。

升级后:
                                   ┌─ DoH + SNI伪装 → 云服务器:8443 → 公共 DNS

客户端 → smartdns(:53) ──并发查询─┼─ DoH + SNI伪装 → dns.alidns.com

└─ DoH + SNI伪装 → doh.pub

三层防御,逐一击破运营商的 DPI 监控:

防御层机制效果
目标 IP 伪装阿里云随机 IP学校/ISP 不认识这个地址,未被列入 DNS 特征库
端口混淆8443 — 非标准 DNS 端口不是 53,不是 853(DoT),DPI 认不出是 DNS
内容加密TLS 隧道DNS 查询完全不可见,抓到包也只是加密载荷

DNS 层面,至此完全绕过 ISP DPI 的监控网。

但这才完成了一半。

四、血与泪:在 OpenWrt 上编译 nfqws

DNS 加密只是解决了"查什么域名被看到"的问题。但如果 ISP 对流量本身做深度包检测(DPI),通过 TLS SNI、数据包指纹、连接模式等识别你的流量,DNS 加密就远远不够了。

[nfqws](https://github.com/bol-van/zapret) 是目前最强的 DPI 绕过工具之一,通过 TCP 分片伪造、TLS 握手混淆、包重排等技术干扰 DPI 设备的特征匹配。但它没有现成的 OpenWrt 二进制包——需要自己编译。

目标平台:软路由,Intel Celeron N2840,OpenWrt 24.10.4。

第一回合:原生编译——希望破灭

最直觉的做法:在软路由上装 gcc,直接编译。

opkg install gcc make

cd zapret

make nfqws

22 个 .c 文件全部成功编译成 .o。一切顺利。然后——

ld: error adding symbols: file in wrong format

链接器炸了。不是某个 .o 的问题,是所有共享库都无法链接。OpenWrt 24.10.4 的 gcc 13.3.0 工具链存在一个系统性的 bug:可以编译,不能链接 .so

第二回合:QEMU 虚拟机——同样的深渊

不死心,开一个同版本 OpenWrt 的 QEMU 虚拟机:

qemu-system-x86_64 -m 512M -hda openwrt-24.10.4.img

进到虚拟机里,opkg install gcc make,编译……同一个错误。

确认了:这不是个别现象,是 OpenWrt 官方固件中 gcc 包的结构性缺陷。包管理系统里装的 gcc 只是一个"残废版"——真正用来编译 OpenWrt 软件包的,是宿主机的交叉编译工具链(SDK),不是原生 gcc。

第三回合:musl-gcc 交叉编译——头文件地狱

把交叉编译工具链搬到笔记本上:

musl-gcc -std=gnu99 -Os -c *.c
__gnuc_va_list 冲突。glibc 和 musl 的头文件打架,互相定义同一符号。这需要在编译环境中做大量 patch 工作,不现实。

第四回合:跨机器搬运 .o——链接器不给面子

软路由上编译好的 22 个 .o 文件,scp 到 Ubuntu 笔记本上,用 musl-gcc 链接:

musl-gcc -o nfqws *.o -lnetfilter_queue -lnfnetlink
ld.bfd: error: cannot open .../libnfnetlink.so: file has no section headers

musl 的 .so 文件没有 section headers——这是 musl 的设计选择。但 GNU ld.bfd 又偏偏依赖 section headers 来解析符号表。两边的设计哲学冲突,链接器直接拒绝工作。

第五回合:静态编译——山重水复后的柳暗花明

既然动态链接和 musl 八字不合,那就绕开它——在 glibc 环境做静态编译

Ubuntu 上没有 libnetfilter-queuelibnfnetlink 的静态 .a 文件,只能手动编译出静态库:

# 安装开发头文件

apt-get install libnetfilter-queue-dev libnfnetlink-dev \

libcap-dev libnftnl-dev

手动编译 libnfnetlink 静态库

cd libnfnetlink-1.0.2

./configure --enable-static && make

cp src/.libs/libnfnetlink.a /usr/local/lib/

静态编译 nfqws

cd zapret

gcc -std=gnu99 -Os -static \

-o nfqws .c crypto/.c \

-lnetfilter_queue -lnfnetlink -lz -lmnl -lnftnl

-rwxr-xr-x 1 root root 1.6M nfqws

1.6MB 的静态 ELF 二进制,拖到 OpenWrt 软路由上,直接运行。

成功了。

这是一个 glibc 环境下静态编译的二进制,在 musl 系统上完整运行——静态链接让库依赖的差异完全消失,同 CPU 架构下天然跨 libc 兼容。

五条血的教训

1. OpenWrt 的 opkg install gcc 能编译不能链接,这不是 bug 是 feature——opkg 的 gcc 包就不是给你做原生编译用的。

2. 真正的包编译走 SDK 交叉工具链,在宿主机上,不是目标机上。

3. glibc 静态编译的二进制兼容 musl(同架构),这是绕过环境限制最干净的方案。

4. musl 的 .so 无 section headers,这是设计选择,但要和 GNU ld.bfd 配合就得碰壁。

5. 遇到瓶颈别死磕。第四回合失败后如果继续纠结交叉链接,可能再耗一个晚上也出不来。换个思路——静态编译——十分钟搞定。

五、工具链集结:zapret 全家桶

编译完成的成果不止一个 nfqws。zapret 是一套完整的 DPI 对抗工具集:

/etc/zapret/

├── config # 参数配置(策略、端口列表)

├── rules.sh # iptables 规则(一键启停)

/etc/init.d/zapret # OpenWrt procd 自启脚本

四个主力工具:

工具大小职责
nfqws1.6MB主力 DPI 绕过,支持 fake/split/frag/desync 策略
tpws1.5MB备选方案,TPROXY 透明代理
mdig1.2MBDNS 查询诊断工具
ip2net990KBIP/CIDR 范围工具
blockcheck.sh53KB域名 DPI 拦截检测脚本

nfqws 的核心策略:

规则部署采用安全模式——工具就位、配置写好、iptables 规则暂不激活。激活时只需两步:

# 1. 启动 nfqws 守护进程

/etc/init.d/zapret start

2. 添加 iptables NFQUEUE 规则(mangle 表 FORWARD 链)

iptables -t mangle -A FORWARD -p tcp --dport 443 \

-j NFQUEUE --queue-num 100 --queue-bypass

iptables -t mangle -A FORWARD -p udp --dport 443 \

-j NFQUEUE --queue-num 100 --queue-bypass

--queue-bypass 是关键——nfqws 进程万一挂了,数据包直接透传,网络不会断。紧急回滚只需 iptables -D 去掉这两条规则。

六、防御全景:从 DNS 到全流量

整合后的分层防御体系:

层面方案状态
DNS 查询内容DoH 加密到云服务器✅ 运行中
DNS 53 端口劫持8443 非标准端口绕过✅ 天然免疫
DPI 指纹识别nfqws 18重混淆✅ 运行中
TLS SNI 泄露nfqws split/fake 扰乱✅ 运行中
设备指纹归一化MSS/QoS/TCP窗口✅ 运行中
全流量隧道加密VPN/WireGuard 专线❌ 云端带宽不够

整体拓扑:

┌─────────┐    ┌──────────────────┐    ┌──────────────┐    ┌──────────────┐

│ 客户端 │───▶│ smartdns (:53) │───▶│ 三路 DoH 加密 │───▶│ 公共 DNS 上游 │

└─────────┘ │ ┌──────────────┐ │ │ 阿里云 ECS │ └─────────┘

│ │ nfqws(NFQUEUE)│ │ └──────────────┘

│ │ 18重混淆 ✅ │ │

└──────────────────┘

OpenWrt 软路由

Intel N2840

DNS 层的加密已经完成,流量混淆层全量激活。详见下一章实战验证。

七、安全部署:待激活的保险丝

nfqws 编译完成后,部署的策略是安全模式——工具就位、配置写好、但不加 iptables 规则。

/etc/zapret/

├── config # 参数配置(队列号、DPI 策略)

├── rules.sh # iptables 规则脚本(一键启停)

/etc/init.d/zapret # OpenWrt procd 自启管理

不直接激活的原因很简单:iptables NFQUEUE 规则配错一条,SSH 就可能断开——之前吃过 ebtables 锁死路由器的亏,这次必须稳一手。

激活流程设计为两步,随时可逆:

/etc/init.d/zapret start     # 1. 启动 nfqws 守护进程

/etc/zapret/rules.sh start # 2. 添加 iptables 规则

/etc/zapret/rules.sh stop # 紧急回滚:移除所有规则

--queue-bypass 参数是最后的安全阀:即使 nfqws 进程崩溃,数据包也会直接透传而不丢。

八、插曲:巡检中发现的小问题

部署过程中顺便做了一次全面巡检,顺手修了三个掉线的服务:

基础设施的熵增定律永恒成立——你不动它,它也会自己坏。


九、实战验证:开关按下之后

编译部署只是前半场,真正的考验是——激活后能不能扛住运营商 DPI 的正面检测?

踩坑:OpenWrt 24.10 的 iptables 陷阱

第一条 NFQUEUE 规则就炸了:

iptables -t mangle -A FORWARD -p tcp --dport 443 -j NFQUEUE --queue-num 100 --queue-bypass

iptables v1.8.10 (nf_tables): unknown option "--queue-num"

OpenWrt 24.10 默认用的是 iptables-nft(nftables 后端),不支持 NFQUEUE 扩展选项。需要额外安装 iptables-mod-nfqueue 包才能正常使用。

15 重混淆策略部署

解决语法问题后,nfqws 从最初的"空进程"升级为全策略混淆:

层面策略说明
TCP 层badseq, badsum, datanoack, ts序列号/校验和/时间戳全维度混淆
TLS 层fake, multisplit伪造 ClientHello + 分片传输
指纹md5sig, repeats×3, hopbyhop签名错误 + 重复注入 + IPv6 头
TTLautottl Δ2 (3-30)动态 TTL 跳动,隐藏跳数
HTTPhostcase, hostspell, domcase大小写/拼写/域名随机化
窗口wssize=64240:8, cutoff=n3TCP 窗口归一化

再加上 iptables 层面的 MSS 钳制 + DSCP QoS 抹除,共计 18 层混淆

iptables 完整规则

四条规则,覆盖 TCP/UDP 443 的 NFQUEUE 劫持 + MSS 归一化 + QoS 标记抹除:

iptables -t mangle -A FORWARD -p tcp --dport 443 -j NFQUEUE --queue-num 100 --queue-bypass

iptables -t mangle -A FORWARD -p udp --dport 443 -j NFQUEUE --queue-num 100 --queue-bypass

iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

iptables -t mangle -A FORWARD -j DSCP --set-dscp 0

实测结果

激活后立即进行全网测试,结果令人满意:

类别实测结论
🎮 游戏原神 14ms / 王者 23ms / 和平 21ms🟢 0% 丢包
📺 视频抖音峰值 73Mbps / 搜狐 80Mbps🟢 无卡顿
📁 下载微信 77Mbps / 淘宝 76Mbps🟢 速度正常
🌐 网页全站 0% 丢包🟢 国内全通
软路由负载始终 0.02——Celeron N2840 轻松应对,远未触及性能上限。

为什么能 0% 丢包?

宿舍区专线经过了双层 DPI——学校数据中心的防火墙 IDS + 运营商的 GFW 联动。之前偶尔抽风就是某层 DPI 误判导致的丢包。

十八重混淆之后,原理解释:

学校 DPI → 收到乱码碎片 → 特征库匹配失败 → 放行

运营商 DPI → 同上 → 放行

→ 全链路无阻

两个 DPI 设备都认不出这种流量,自然一包不丢。

防御全景(最终版)

层面方案状态
DNS 查询内容DoH 加密到云服务器
DNS 53 劫持8443 绕过
DPI 指纹识别nfqws 18重混淆
TLS SNI 泄露split/fake 扰乱
设备指纹MSS/QoS/窗口归一化
全流量隧道VPN/WireGuard❌ 带宽不够

总结

从一个 Go TLS 证书兼容性的小问题出发,经历 smartdns 替换、DoH 加密、五回合编译血泪、18 重混淆部署,最终在 Celeron N2840 软路由上落地了一套完整的分层防御体系。实测结果:国内全站 0% 丢包,视频峰值 80Mbps,游戏延迟 14ms

核心收获:用自签名证书跑私有 DoH 服务器,必须避开 Go 写的客户端。C 语言的 smartdns 是 OpenWrt 上的最优解——轻量、并发查询、系统 CA 集成,刚好弥补 AdGuardHome 的盲区。

编译 zapret 的过程则是一场标准的"嵌入式交叉编译"教学案例:原生编译行不通 → QEMU 验证问题 → musl 头文件冲突 → 动态链接 musl .so 失败 → 最终 glibc 静态编译一招破局。五次尝试,每一次都在削减问题的范围,直到触达那个干净的解法。


更新于 2026-05-27 · 第九章 实战验证已追加

← 返回专栏
Collaplex · 克拉普莱克斯 collaplex.me · 2026 浙ICP备2026080865号