我在今年四月推出自建的 DNS over HTTPS (DoH) 服务 ZNS,到现在已经有一些固定用户。为了防止泄漏用户标识,我在设计的时候把标识放到了 URL 路径中,比如 http://zns.lehu.in/zns/YOUR-TOKEN。但有用户反馈安卓系统无法直接使用 ZNS,因为它只能在私密 DNS 中输入域名,填入 HTTP 链接便无法保存🤦♂️为了能让 Android 用户使用 ZNS 解析域名,我重新设计了 ZNS 服务。本文记录中间踩的坑。
开始我以为安卓系统要求 DoH 链接必须为https://${DOMAIN-NAME}/dns-query这种形式,所以在私密DNS页面只能填域名一项,系统会自动拼接 DoH 链接。但付费 DNS 服务需要识别用户身份。我之前把用户 TOKEN 放到了 URL 路径里。要想支持 Android 就只能放到的子域名中了,原来的https://zns.lehu.in/zns/${TOKEN}需要变成https://${TOKEN}.zns.lehu.in/dns-query。
提取 TOKEN 本身没什么困难,只需要根据 URL 路径来判断是从路径 PathValue 还是 Host 中提取就可以了。
diff --git a/http.go b/http.go
index b4a2f70..e7faec3 100644
--- a/http.go
+++ b/http.go
@@ -59,7 +59,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
- token := r.PathValue("token")
+ var token string
+ if r.URL.Path == "/dns-query" {
+ // https://${token}.zns.lehu.in/dns-query
+ x := strings.SplitN(r.Host, ".", 2)
+ token = x[0]
+ } else {
+ token = r.PathValue("token")
+ }
+
if token == "" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return这里比较麻烦的是域名证书问题。ZNS 之前使用 zns.lehu.in 固定域名证书,可以直接用标准扩展库golang.org/x/crypto/acme自动申请 SSL 证书。如果要支持子域名认证,就得申请*.zns.lehu.in泛域名证书。虽然 Let’s Encrypt 也支持泛域名证书,但需要使用 DNS-01 challenge 来验证域名所有权。所以就没法继续用原来的 ACME 库了。
DNS-01 challenge 简单来说就是提供域名管理API相关信息,ACME 客户端在申请证书时会自动调用接口创建和删除特定 TXT 记录完成验证,权限非常大。我的 lehu.in 域名还有其他用途,将 API 信息关联到 ZNS 会有不小的风险。
于是我想了一个安全的办法。我在另一台安全的设备上自动申请*.zns.lehu.in泛域名证书,然后上传到 ZNS 服务器。接着改造 ZNS 服务使用其能自动加载新的 SSL 证书。这样就能最大程度上降低风险。
申请证书我用 acme.sh 脚本。没用过的朋友可以选参考这篇文章。
申请泛域名命令如下:
acme.sh --issue -d zns.lehu.in -d '*.zns.lehu.in' --dns dns_cf
我用 Cloudflare 解析 lehu.in 域名,所以需要指定--dns dns_cf来告诉 acme.sh 调用 Cloudflare API 来验证。同时还需要设置如下环境变量:
export CF_Token="P0YBpgos..."
export CF_Zone_ID="21ee64c..."申请之后通过 rsync 同步到 ZNS 服务器就好:
rsync --chown www-data:www-data \
-avP .acme.sh/zns.lehu.in_ecc/{fullchain.cer,zns.lehu.in.key} \
zns.lehu.in:/etc/ssl/zns.lehu.in/把这个 rsync 放到 crontab 中定时执行就好了。为什么不用 scp 呢?不是更简单吗?非也。rsync 支持增量同步,如果证书文件没变化就不会修改 ZNS 服务器的文件。如果使用 scp,每次执行不管证书是否变化都会修改远端文件,这样会触发 ZNS 服务重新加载证书。实在是没有必要。
接着我们开始实现自动加载证书功能。
为了调试方便,ZNS 一开始就支持指定 SSL 证书。改成自动加载需要自己实现检查和更新逻辑。Go 标准 tls 库支持通过GetCertificate配置传入加载函数,其签名如下:
func (*tls.ClientHelloInfo) (*tls.Certificate, error)程序可以在 tls 协商会话的时候根据 ClientHelloInfo 来动态选择 SSL 证书。看下面的的 certLoader,核心函数是Load()。每次调用都会检查证书文件的最近修改时间。如果有改动,就调用LoadX509KeyPair重新加载并保留最新更新时间。就这么简单。
type certLoader struct {
certFile, keyFile string
lastMod time.Time
t *time.Ticker
cert *tls.Certificate
}
func (l *certLoader) Load() {
info, err := os.Stat(l.certFile)
if err != nil { panic(err) }
lastMod := info.ModTime()
if !lastMod.After(l.lastMod) { return }
cert, err := tls.LoadX509KeyPair(l.certFile, l.keyFile)
if err != nil { panic(err) }
l.cert = &cert
l.lastMod = lastMod
}
func (l *certLoader) Stop() { l.t.Stop() }
func (l *certLoader) Start() { l.Load(); go l.Loop() }
func (l *certLoader) Loop() {
l.t = time.NewTicker(1 * time.Minute)
for range l.t.C {
l.Load()
}
}
func (l *certLoader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return l.cert, nil
}最后在服务初始化 tls 时指定 GetCertificate 函数来启动自动加载协程就可以。
diff --git a/cmd/zns/main.go b/cmd/zns/main.go
index ceb889a..b325426 100644
@@ -86,11 +134,10 @@ If not free, you should set the following environment variables:
tlsCfg = acm.TLSConfig()
} else {
tlsCfg = &tls.Config{}
- certs, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
- if err != nil {
- panic(err)
- }
- tlsCfg.Certificates = []tls.Certificate{certs}
+ l := certLoader{certFile: tlsCert, keyFile: tlsKey}
+ l.Start()
+ defer l.Stop()
+ tlsCfg.GetCertificate = l.GetCertificate
}
lnH12, lnH3, err := listen()So far so good. 发布上线,我使用命令行工具测试正常,就在群里广而告之了。
发布后没人反馈,可能是因为一开始不支持,安卓用户都被劝退了。但我自己没测过,心里总不踏实。于是找了一台安卓设备,填入 DoH 域名测试。结果一直接报无法链接🤦♂️
开始我还以为是自己的 DoH 代理实现有问题,不兼容安卓系统。我参考了其他 DoH 实现,也对 ZNS 的代码做了优化,依然不管用。我尝试在服务端抓包,发现安卓系统根本没请求服务器。我又填入阿里的 dns.alidns.com 发现正常工作。由此断定是 ZNS 服务的问题。但怎么排查呢?
我尝试用 adb logcat 查看 Android 的日志。但日志不全,没有提供有效信息。我又查了安卓系统的 DnsResolver 模块源码,它的 README1 说可以修改该模块的日志级别:
adb shell service call dnsresolver 10 i32 1
# VERBOSE 0 DEBUG 1 INFO 2 WARNING 3 ERROR 4 Verbose我执行时报如下错误:
adb shell service call dnsresolver 10 i32 0
service: Service dnsresolver does not exist但用 list 命令又能看到 dnsresolver
adb shell service list|grep dns
129 dnsresolver: []
175 mdns: []请教了一位做安卓的同事,他说可能是需要 root 权限才能修改。但现在的安卓设备都不太容易 root 了。于是想到了 Android 模拟器。
在网上查到一篇获取模拟器 root 权限的文章2。简单来说就是在启动模拟器时将系统分区设置为可写模式,然后通过 rootAVD3 项目给模拟器安装 su 组件。
root 之后确实可以改日志级别了。但依然没有看到有用的信息。又想到一个基于 eBPF 实现 TLS 抓包的项目 eCapture。因为有 root 权限,所以可以安装 eCapture。但运行之后依然没有抓到有用的信息。
至此陷入僵局。有点智穷虑竭的意思🤷无奈之下只能多看看网上的资料。最后通过这个帖子4 确定 Android 系统只能支持 DNS over TLS (DoT) 默认不支持 DoH。这是什么鬼呀🤦♂️
起初我还有点不相信。我印象中谷歌发过一篇文章5说是 Android 已经支持 DNS over HTTP/3 了,这样可以进一步优化 DNS 解析延迟。怎么会只支持 DoT 呢?
直到我看见这篇推文6安卓系统居然硬编码了一个白名单,如果用户输入的私密DNS在名单中就自动转换成 DoH 协议,否则使用 DoT 协议。这是什么天才设计,我真想问候设计者的祖宗十八代。简直是无力吐槽呀😂
无论如何我想不通为什么要搞成这样。做成一个文本框,允许用户自由输入不香吗?开始我想可能是谷歌不想开放 DoH 协议,因为很多人用它来屏蔽广告从而影响谷歌的收入。但后来想不对呀,DoQ 服务也可以屏蔽广告。所以这种设计应该没什么好洗的,就是垃圾!
但垃圾归垃圾,你不支持 DoT 协议安卓就无法使用 ZNS 服务。只能含泪继续写代码。
DoT 协议跟 DNS over TCP 协议很像,因为底层都是可靠的数据流通信,区别只是 DoT 使用了加密数据流。因为是流式通信,这就涉及如何确定报文边界的问题。所以协议规定先传输两个字节,内容为DNS报文的长度,再传输报文内容。所以实现 DNS over TLS 和 DNS over TCP 都非常简单,只需要处理好这个长度就可以了。
我的 ZNS 服务已经可以正常处理 DoH 请求了。对于 DoT 请求,我会通过代码将其转换成特殊的 HTTP 请求然后交由 DoH 服务来处理,最后再将 DoH 的返回内容转换成 DoT 的数据。这样实现最简单。
首先,我们监听 853 端口,这是 DoT 的标准端口:
lnDot, err = net.Listen("tcp", ":853")
if err != nil { panic(err) }
ln := tls.NewListener(lnDot, tlsCfg)
go func() {
for {
c, err := ln.Accept()
if err != nil {
log.Println("Failed to accept dot connection", err)
continue
}
go h.ServeDoT(c.(*tls.Conn))
}
}()这里先监听 TCP 853 端口,然后转换成tls.Listener,接着就开始正常Accept循环。每次收到客户端传入连接后就启动新的协程来处理该链接上的查询请求。现在我们看 h.ServeDoT 的具体实现。
func (p *Handler) ServeDoT(conn *tls.Conn) {
defer conn.Close()
// 通过 SNI 获取当前域名
conn.Handshake()
domain := conn.ConnectionState().ServerName
// 准备缓冲区
lenBuf := make([]byte, 2)
w := &tlsWriter{conn: conn}
// 循环处理客户端查询
for {
// 读取查询请求
_, err := io.ReadFull(conn, lenBuf)
if err != nil {
if err != io.EOF {
log.Println("reading query length error", err)
}
return
}
queryLen := binary.BigEndian.Uint16(lenBuf)
queryBuf := make([]byte, queryLen)
_, err = io.ReadFull(conn, queryBuf)
if err != nil {
log.Print("reading query body error", err)
return
}
// 构造 DoH 请求
url := "https://" + domain + ":853/dns-query"
req, err := http.NewRequest("POST", url, io.NopCloser(bytes.NewReader(queryBuf)))
if err != nil {
return
}
req.RemoteAddr = conn.RemoteAddr().String()
// 转交 DoH 处理
p.ServeHTTP(w, req)
if w.code != http.StatusOK && w.code != 0 {
log.Println("dot query error", string(w.body))
return
}
// 响应 DoT 报文
respBytes := w.body
buf := make([]byte, 2+len(respBytes))
binary.BigEndian.PutUint16(buf, uint16(len(respBytes)))
copy(buf[2:], respBytes)
if _, err := conn.Write(buf); err != nil {
log.Println("dot writing response body error", err)
return
}
}
}tls 库提供ConnectionState().ServerName来读取当前 tls 连接的 SNI 域名。但读取该字段之前需要先调用Handshake()函数完成 tls 会话,不然返回内容为空。
之后便是循环处理客户端发送的解析请求。先读长度,再读内容。尔后构造一个虚拟的 HTTP 请求对象,不要忘记设置客户端的地址和端口。接着发送给ServeHTTP()处理,最后将解析结果按 DoT 的要求发送给客户端。这样便把 DoT 和 DoH 的处理逻辑整合到一起了,方便统一维护。
最后还得说一下 tlsWriter 对象。这是一个假的http.ResponseWriter,它的作用是保存 ServeHTTP()输出的内容供 DoT 转换用。这也是 Go 语言接口比较经典的示例。
type tlsWriter struct {
code int
body []byte
}
func (w *tlsWriter) Header() http.Header { return http.Header{} }
func (w *tlsWriter) WriteHeader(statusCode int) { w.code = statusCode }
func (w *tlsWriter) Write(b []byte) (n int, err error) {
w.body = b
return len(b), nil
}好了,万事俱备,发版上线!填入 Android 设备后果然生效了🥂
最后说说 DDR,也就是 RFC9462 Discovery of Designated Resolvers。其主要作用是通过特殊的 DNS 查询对外发布当前递归解析服务器支持哪些通信协议。这样客户端可以根据自己的实际情况选择合适的协议解析域名。
DDR 使用 DNS 中的 SVCB 记录7。以 ZNS 为例,它的域名为 zns.lehu.in。支持 DDR 的客户端首次配置的时候会查询 _dns.zns.lehu.in 域名的 SVCB 记录,ZNS 会返回:
_dns.zns.lehu.in. 1s SVCB 1 zns.lehu.in. alpn="h3,h2" dohpath="/dns/qta...bua/{?dns}"
_dns.zns.lehu.in. 1s SVCB 2 qta...bua.zns.lehu.in. alpn="dot"
第一条表示 ZNS 支持 DNS over HTTP/3 和 DNS over HTTP/2,端口为默认的 443,URL 路径为/dsn/qta...bua/{?dns}。第二条表示 ZNS 支持 DoT 协议,默认使用 853 端口,域名为qta...bua.zns.lehu.in。
使用 DDR 的好处是如果你的 ISP 封锁了某种协议,客户端可以选择另一种协议通信。谷歌在博客中说 Android 会支持 DDR。我看最新的 AOSP 代码确实已经有相关内容了,但还不确定它的选择逻辑。目前 Android 还是只能填 DoT 域名。我希望它能先通过 DoT 域名查询 DDR 的 SVCB 记录,再自动选用 DoH3 协议,这样解析延迟会更小,而且被封的可能性几乎为零。
今天就先分享这些。欢迎使用 ZNS 服务。
https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/DnsResolver/README.md↩︎
https://advaitsaravade.me/rooting-an-android-emulator-in-2025/↩︎
https://community.cloudflare.com/t/dns-settings-for-dns-over-http-3-dns-over-quic-on-android/400931↩︎
https://security.googleblog.com/2022/07/dns-over-http3-in-android.html↩︎