出境链路上的明文 UDP 53 不可信,查 www.google.com,回来的是别的公司的 IP。

$ dig www.google.com @1.1.1.1
;; ANSWER SECTION:
www.google.com.         156     IN      A       157.240.7.20

$ curl -sSL "http://ip-api.com/json/157.240.7.20" | jq
{
  "country": "Australia",
  "regionName": "Queensland",
  "city": "Airlie Beach",
  "timezone": "Australia/Lindeman",
  "isp": "Facebook, Inc.",
  "org": "Facebook, Inc.",
  "as": "AS32934 Facebook, Inc.",
}

我的需求很简单——一个自己可控的 DoH 入口,能给家里的设备、能给手机、能通过 curl/doggo 直接验证。

这篇记录三件事:怎么用 dnsproxy 把它跑起来、它的缓存和 TTL 到底怎么算、以及顺手排查出来的一次 DoH 服务端污染。

先把几个名词说清楚,后面不再解释:

缩写 英文全称 一句话意思
DNS Domain Name System 域名系统,负责把域名换成 IP;替你跑腿的服务器叫递归解析器,它自带缓存
UDP User Datagram Protocol 无连接、不保证送达,胜在快;传统 DNS 就走它
TCP Transmission Control Protocol 有连接、可靠有序,多几次握手,代价是慢一点
DoH DNS over HTTPS 把 DNS 报文塞进 HTTPS 请求里,路上只看到加密流量
HTTP/3 HTTP version 3 新一代网页协议,跑在 QUIC 上(也就是 UDP)
QUIC Quick UDP Internet Connections 基于 UDP 的传输协议,TLS 内建、握手更省往返
DoH3 DoH over HTTP/3 就是 DoH 用 HTTP/3 跑,走 UDP;配置里常写成 h3=true
DoT / DoQ DNS over TLS / DNS over QUIC 另外两种加密 DNS,默认端口 853
TTL Time To Live 这条记录还能用多久(秒);到期就得重新问
RTT Round-Trip Time 一次往返的耗时,DNS 快不快基本看它
DNSSEC DNS Security Extensions 给 DNS 记录加签名,用来验证回答没被篡改

还有一个不缩写但很关键的概念:污染(DNS poisoning)—— 查询一个域名,拿到的是别人塞进去的假 IP。既可能发生在传输链路,也可能发生在递归服务器自己的缓存里。

一、服务端:dnsproxy 单二进制

选 dnsproxy 而不是 CoreDNS/AdGuard Home,理由很朴素:单二进制、配置就是命令行参数、同时支持明文 DNS 与 DoH(含 HTTP/3)、自带缓存和 DNSSEC 校验。

dnsproxy \
  --listen 0.0.0.0 --port 53 \
  --upstream 1.1.1.1:53 --dnssec \
  --cache --refuse-any \
  --https-port 8443 --http3 \
  --tls-crt /path/cert.pem --tls-key /path/key.pem
  • 入站 TCP 最近封得厉害,通过 53/UDP 和 8443/UDP 上的 DoH3 续命;8443 的 h2(TCP)留着但不指望。
  • --dnssec:DNS 本身没有签名,谁都能伪造一个应答;DNSSEC 是给记录加签名,用来验证"这条回答是不是真的"。开了之后客户端 dig +dnssec 会看到 ad 标志,表示已验证。
  • --refuse-any:拒绝 ANY 类型的查询(一条查询要回所有记录),这类请求常被拿来做放大攻击,普通用户用不到。
  • systemd-resolved 的 stub:Linux 自带的本地 DNS 服务也占着 53 端口,绑定 0.0.0.0:53 之前要先把它的 DNSStubListener 关掉,否则抢不到端口。
  • 证书续期:TLS 证书是进程启动时读进内存的,续期后不重启还用旧证书;所以监控证书文件变化、自动重启一次最省事(代价是内存缓存被清空)。

上游用 1.1.1.1:53 明文,看着别扭但其实没问题:污染发生在"大陆 → 境外"这段跨境链路,而不是在服务器本地。在境外机器上 dig @1.1.1.1 www.google.com 拿到的是真地址。

二、缓存和 TTL 到底怎么算

一句话原理:缓存多久,由这条回答里最短的那个 TTL 决定。比如响应里既有 300 秒的 A 记录、又有 60 秒的 RRSIG,那就按 60 秒算——数据必须"整体"是新鲜的。

  • 没有设置下限/上限时(--cache-min-ttl / --cache-max-ttl 默认 0),缓存就按记录自己的 TTL 走,不延长也不截断。
  • 过期就重新去问,默认不会"先给旧答案再后台刷新"。
  • 失败的应答最多记 30 秒,避免一次抖动被缓存很久。
  • 缓存在内存里,重启进程就清空——这也意味着 TTL 递减能直接看出有没有命中。

想看"缓存有没有命中",最直接的办法是连查两次看 TTL 递减:

dig +noall +answer @127.0.0.1 www.google.com A   # 300
sleep 2
dig +noall +answer @127.0.0.1 www.google.com A   # 298

怎么看 TTL

工具就两个:dig 看明文,doggo 看加密。doggo 自带 TTL 列,所有通道一个写法(@ 后面跟服务器,端口用冒号):

为什么选它:一个二进制就能装(brew install doggo),UDP / TCP / DoT / DoH / DoH3 / DoQ 全覆盖, 输出天生带 TTL 列,-J 还能吐 JSON 给脚本用;@ 后面统一写地址,不用为每个协议记一套参数。

dig 是系统自带的老牌工具(BIND 套件),查明文足够,但它不支持 DoH,端口也得用 -p 单独指定。

doggo google.com A                                                   # 系统默认解析器
doggo google.com A @1.1.1.1                                          # 指定服务器(UDP)
doggo google.com A @tcp://1.1.1.1                                    # TCP
doggo google.com A @https://doh.pub/dns-query                 # DoH(h2)
doggo google.com A @https://dns.example.net:8443/dns-query --http3   # DoH3
doggo -J google.com A @https://doh.pub/dns-query              # JSON 输出,方便脚本处理

安装与文档:mr-karan/doggo | dig 的说明:man dig(BIND 自带)

输出长这样,TTL 是独立一列:

NAME         TYPE  CLASS  TTL   ADDRESS
google.com.  A     IN     300s  142.251.45.14

为什么有些工具看不到 TTL

标准 DoH(RFC 8484)的响应体(wireformat)就是原始的二进制 DNS 报文,TTL 只是里面一个 4 字节字段——不做 DNS 解码的 HTTP 客户端自然显示不出来。

用会解码的客户端 (dig / doggo)就完事,没必要自己去拆字节。

三、三家公共 DoH 横评

环境:大陆家宽,出网直连(无代理),测试目标 www.google.com。

协议支持矩阵

服务 wireformat JSON DoH3
cloudflare-dns.com ✅(大陆只有 h3 通) ✅ ✅
dns.alidns.com ✅ /dns-query ✅ /resolve ✅
doh.pub ✅ ✅ ❌(ALPN 里没有 h3)

可达性与延迟

目标 结果
1.1.1.1:443 大陆 TCP/UDP 全不通,任何协议都别指望
cloudflare-dns.com h2 RST / 超时
cloudflare-dns.com h3 200,http_version=3,落到 104.16.248.249
自建 DoH(境外 VPS)h3 每查询 29-33 ms
dns.alidns.com h2 24-31 ms
dns.google 不通

两个细节值得记一下:

  1. cloudflare-dns.com 现在的 A 记录是 104.16.248.249 / 104.16.249.249,不是 1.1.1.1。别拿这两个 IP 直连:TLS 证书里只登记了域名(SAN,即"证书认可的名字清单"),不包含这两个 IP,会直接校验失败。客户端必须用域名访问,或者自己做一次"域名 → 这个 IP"的绑定。
  2. anycast 可能把你甩到很远的机房(anycast:同一个 IP 在很多地方都有入口,走哪个看运营商路由):从家宽打 104.16.x,ICMP RTT 就 260 ms,DoH3 每查询 190-280 ms,比自建节点慢 7-9 倍;同端点走 h2/TCP 也是 320 ms,所以不是 QUIC 被限速,纯粹是绕路。

结论也很直接:

# 墙外域名:自建 DoH3 优先,cloudflare 只做兜底
- "https://dns.example.net:8443/dns-query#h3=true"
- "https://cloudflare-dns.com/dns-query#h3=true"
# 境内域名:h2 就够,别加 h3
- "https://doh.pub/dns-query"

境内 DoH 加 #h3=true 是负优化:RTT 只有几毫秒,QUIC 每次新连接要多走一轮握手,实测新连接 65-87 ms,而 h2 复用连接后是 24-31 ms;doh.pub 更糟,它压根不支持 h3。

四、加密通道也会脏:一次 DoH 污染实测

原本以为"换成 DoH 就干净了"。实测打脸:

域名 dns.alidns.com(DoH) cloudflare-dns.com(h3)
www.google.com 185.45.5.35(AS35995 Twitter) 142.251.155.119
www.youtube.com 69.171.235.22(AS32934 Facebook) 142.251.155.4
google.com 108.177.98.101 ✅ 142.251.45.14
mail.google.com 173.194.202.83 ✅ 142.251.41.5
www.cloudflare.com 104.16.124.96 ✅ 一致
github.com 20.205.243.166 ✅ 172.182.252.133(不同 POP)

阿里 DNS 同一域名两个接口给同一份脏数据(/dns-query 的 wireformat 和 /resolve 的 JSON 都是 Twitter 段),说明不是链路注入,是递归侧缓存被投毒。 而且只有 google/youtube 这种"常被投毒"的域名中招,其他域名都正常。

这条经验比较反直觉:DoH 只保护传输段,保护不了解析结果。境内公共 DoH 适合解析境内域名,墙外域名还是得走自己的加密出口。

既然 DoH 都是脏的,建议不要用阿里 DNS,用腾讯 DNS 就好了。

五、写个脚本把三协议 + 污染一起测

手工敲命令太碎,收敛成一个 fish 脚本,输入域名,输出「协议 / 成功率 / 最优 RTT / TTL / 应答」,再给一份污染判定:

  • 三个传输:dig(UDP)、curl --http2(DoH)、curl --http3-only(DoH3,需要 brew 装的带 HTTP/3 的 /opt/homebrew/opt/curl/bin/curl)。手工验证用 doggo 更省事,脚本里用 curl 是为了直接拿到 JSON 里的 TTL 和 HTTP 状态码。
  • 传输层要用每个服务商都支持的接口:cloudflare/doh.pub 用 /dns-query JSON,阿里用 /resolve JSON。
  • 污染判定两条腿:
    1. 归属地用 ip-api.com/batch 查(as 就是"这段 IP 归哪家公司",Google 是 AS15169),结果落 ~/.cache,30 天有效,省免费配额;
    2. 与"同服务商加密通道的答案"交叉比对,完全不重叠就标可疑。
  • 有一个坑必须写进规则里:归属查不到时,不能拿"和自己的加密通道一致"当清白,否则服务端污染会被判成 OK(两边一样脏)。这种情况直接标"可疑:归属未知"。

实测输出大致是:

PROVIDER    PROTO OK     RTT      TTL    应答
cloudflare  udp   3/3    22       97s    174.132.167.252
cloudflare  doh   0/3    -        -      -
cloudflare  h3    2/3    569      300s   142.251.154.119,142.251.152.119,...
alidns      udp   3/3    25       97s    31.13.92.37
alidns      doh   3/3    85       46s    31.13.92.37
alidns      h3    3/3    60       46s    31.13.92.37
doh.pub     udp   3/3    49       57s    31.13.92.37
doh.pub     doh   3/3    213      139s   142.251.156.119,142.251.150.119,...
doh.pub     h3    0/3    -        -      -

== 污染判定(基准 = 同服务商加密通道答案)
PROVIDER    PROTO 结论
cloudflare  udp   污染: 174.132.167.252(AS8075 Microsoft Corporation / Microsoft Limited)
cloudflare  doh   无应答
cloudflare  h3    OK
alidns      udp   污染: 31.13.92.37(AS32934 Facebook, Inc)
alidns      doh   污染: 31.13.92.37(AS32934 Facebook, Inc)
alidns      h3    污染: 31.13.92.37(AS32934 Facebook, Inc)
doh.pub     udp   污染: 31.13.92.37(AS32934 Facebook, Inc)
doh.pub     doh   OK
doh.pub     h3    无应答

== 汇总:污染 5 条,可疑 0 条,干净可用 2 条

六、本机也跑一个接力客户端

服务端在境外,本机也想要一个统一的入口(避免每台设备各自配 DoH),那就本地再起一个 dnsproxy:上游指到自建 DoH3,下游监听 53。

本机到本机总不能被污染了吧。

安装让 AI 写个脚本也方便后续一键更新:读 GitHub 的 releases/latest 拿版本号,按 uname -m 映射成 darwin-arm64 / darwin-amd64 资产名,解压后把 dnsproxy 装到 ~/.bin/,已经是最新版本就跳过。

sudo -n ~/.bin/dnsproxy -l 127.0.0.1 -p 53 \
  -u h3://dns.example.net:8443/dns-query -b 119.29.29.29 \
  --dnssec --refuse-any

两个注意点:

  • 53 是特权端口,必须 root。macOS 上最省事的不是把密码塞进环境变量(env/ps 里同机可见),而是给单一二进制开免密:

    echo "$USER ALL=(root) NOPASSWD: $HOME/.bin/dnsproxy" | sudo tee /etc/sudoers.d/dnsproxy
    sudo chmod 440 /etc/sudoers.d/dnsproxy
    

    之后脚本里统一用 sudo -n(绝不提示密码,没配好会立刻失败而不是挂住)。

  • -b(bootstrap)别用 1.1.1.1,大陆不通;用 119.29.29.29 之类境内服务器把 h3:// 上游的主机名解析出来。

验证:

dig +short @127.0.0.1 www.google.com A
dig -p 5353 @127.0.0.1 www.google.com  # 如果使用特殊端口
dig @127.0.0.1 google.com A +dnssec | grep 'flags:'

看到 flags: qr rd ra ad 说明 DNSSEC 验签链路是通的。

七、结论

  1. 自建 dnsproxy 很划算:一个二进制 + 二十行参数,就能拿到 DNSSEC 校验、4 MiB 内存缓存、明文 + DoH(h2/h3) 三入口。
  2. 缓存 TTL 就是"响应里最小 TTL",想观察命中就看 TTL 递减。
  3. 大陆家宽能用 Cloudflare DoH 的唯一方式是 h3 + 域名(不是 IP),但 anycast 可能绕到 260 ms 外,只适合做兜底。
  4. 境内 DoH 用 h2,别开 h3,也别拿它解析 google/youtube——它的缓存是脏的,加密救不了。
  5. 尽量不用阿里 DNS,用腾讯 DNS。