使用 Go 语言实现 DoH 服务

2024-04-16 ⏳8.4分钟(3.4千字) 🕸️

传统 DNS 协议使用明文传输,中间节点可以监听用户的查询内容,甚至还能篡改查询结果1。为了解决这类问题,IETF 制定了 RFC84842,使用 HTTPS 加密链路传输 DNS 查询。这就是所谓的 DNS over HTTPS (DoH)。当前网上有很多 DoH 服务,但能用的却不多。海外的服务基本都被阻断,就算能正常访问,解析出来的往往也是海外 IP 地址,从国内访问很不稳定;国内的 DoH 可以正常访问,解析结果往往也是最优的,但奈何国内有 DNS 污染,好多域名解析出来的 IP 都是错的。本文就分享 DoH 的工作原理并使用 Go 语言开发一套简易的 DoH 服务,解决前面提到的两个问题。

工作原理

DNS 解析服务器最好是就近部署,这样解析出来的 IP 地址离用户也最近,访问速度也更快。所以理论上用运营商自带的 DNS 解析服务器效果最好。且不说中国的运营商没有类似的服务,就算有,它们也能根据用户的 IP 地址来追踪 DNS 查询。用运营商的服务起不到保护隐私的作用。所以说要用第三方的公共 DNS 解析服务。

国内公共的 DNS 解析服务也有很多。比较知名的是 114.114.114.114,IP 很容易记,仅次于谷歌的 8.8.8.8。但是它不支持 DoH。再有就是腾讯旗下的 DNSPod,支持 DoH 访问,链接是 https://doh.pub/dns-query,算是比较好记。阿里云也有类似的产品,DoH 链接为 https://dns.alidns.com/dns-query。腾讯和阿里两家都使用 Anycast 任播技术在国内多个省份部署服务节点,用户设备可以自动访问最近的节点,使用效果不输运营商自己的服务。

但国内的好归好,终究要服从政府的管控要求,不能提供纯净的解析服务。要想取得无污染的解析结果,就需要使用海外的解析服务,但海外的解析结果在国内访问又不稳定。纯净和稳定快速好像是冲突的。解决这个问题最简单的办法是分流。在自家设备上搭建 DNS 解析服务,根据国内 DNS 列表3做分流,国内的域名走国内服务,其他域名走海外服务。要想能正常访问海外的解析服务,你还得配置好 VPN 等加密通道。

本地分流方案有点麻烦,而且只能自用。本文要说的是服务器分流方案。

如果我们有一台海外的服务器,假设在国内的访问延迟很低,那么就可以在它上面搭建解析服务。因为地处海外,所以不会有 DNS 污染的问题。但怎么保证给中国大陆的用户解析出最近的服务节点呢?这就得用到我之前介绍过的 EDNS Client Subnet (ECS) 技术4

ECS 简单说就是将用户的 IP 地址做适当的脱敏发给解析服务器,服务器根据 ECS 返回最近的访问节点,从而达到既能保护隐私,又能就近访问的效果。前面说的 DNSPod 和阿里云公共 DNS 都支持 ECS。

这样一来,通过海外服务器+ECS就能解决前面说的两问题。方案确定了,实现 DoH 就很容易。 DoH 说白了就是一种特殊的「反向代理」,DNS 查询客户端将查询请求发给 HTTPS 代理,代理再次请求转发给 DNSPod 等服务。本文使用 Go 语言演示如何实现 DoH。

实现 DoH 代理

以 DNSPod 为例,它的查询链接是 https://doh.pub/dns-query。大家可能已经注意到,几乎所有的 DoH 链接路径部分都是 /dns-query,甚至阿里云的文档上就只说了自己的域名,默认大家都知道路径是 /dns-query。但事实上这个路径不必是 /dns-query,你想用什么就用什么。下面看 Go 语言处理逻辑。

首先我们需要提取客户端的查询请求。DoH 支持 GET 和 POST 两种查询方式。多数实现会用 POST 提交查询请求,比如 Firefox/Chrome 等。也有部分实现使用 GET 请求提交,比如 Macos 系统。所以两种方式都得支持。GET 方式会将查询数据先用 base64 编码,然后设置到 dns 查询参数中。POST 请求比较直接,将原始查询数据放到 body 里就好了。

收到请求后就直接转给上游的 DNSPod 或者阿里云的服务,再把对应的查询结果发给客户端就可以了。完整代码如下:

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  var question []byte
  if r.Method == http.MethodGet {
    q := r.URL.Query().Get("dns")
    question, err = base64.RawURLEncoding.DecodeString(q)
  } else {
    question, err = io.ReadAll(r.Body)
    r.Body.Close()
  }
  resp, err := http.Post(h.Upstream, "application/dns-message", bytes.NewReader(question))
  defer resp.Body.Close()

  w.Write(resp.Body)
}

是不是有点过于简单简陋😂但从原理上来说,这段代码可以正常工作。

虽然 DoH 也是 DNS 服务,可以用 curl 等工具来调试。但自己构造 DNS 查询请求还是麻烦了点。这里我推荐大家使用 natesales/q5 工具来调试。

安装 q 命令:

go install github.com/natesales/q@latest

通过 DoH 查询域名解析:

q -v A taoshu.in @https://doh.pub/dns-query

当然了,要想运行上面的代码,还需要 HTTPS 相关的配套代码,我就不赘述了,网上多得是。但本地调试的 TLS 证书搞起来比较麻烦,这里推荐使用 mkcert6 为 localhost 域名自动生成证书,非常方便。

Firefox 问题

到这里,我们就有一个能用的 DoH 服务了,它会把所有的 DNS 查询都转发给 DNSPod 来解析。使用 q 工具可以验证查询结果。So far so good。

但如果想把自己的 DoH 服务配置到 Firefox,就会发现有问题。Firefox 提示无法激活。我调了半天也没查到原因。最后使用中 WireShark 抓包发现 DoH 的响应头中没有设置 Content-Type。将其指定为 application/dns-message 就可以了。

实现 ECS

接下来我们要实现 ECS 功能。ECS 需要将客户端的 IP 做脱敏处理。一般 IPv4 地址会改写成对应的 /24 网段,比如 1.1.1.1 会改写成 1.1.1.0/24,也就是说整个 /24 网段内共用一个网络标识。如果是 IPv6 地址,会改写成对应的 /48 网段。网段掩码越长,地理位置就越精准,解析出来的结果就越近,但代价是信息泄漏也就越多;反之,信息泄漏越少,但解析结果也就越不精准。/24/48 分别是 IPv4 和 IPv6 能做 BGP 广播的最小单位,用这两个值可以在隐私保护和解析精度上取得一种平衡。

给 DNS 查询消息添加 ECS 信息显然需要了解 DNS 的数据格式以及 EDNS(0) 的相关规范。不过我们作为草台班子,不需要这么循规蹈矩,先找找有没有现成的轮子可用。还真有,这就是 github.com/miekg/dns 这个包。miekg/dns 提供 DNS 相关的全套工具,可以解析和拼装 DNS 查询消息,处理 ECS 也不在话下。

我们首先需要解析出 DNS 查询消息:

var m dns.Msg
err := m.Unpack(question)

然后判断请求里有没有 ECS 信息。如果客户端已经指定了,我们就不能覆盖。在实践中我还发现有些实现会发送 0.0.0.0/0 这样的网段信息,目的是为了禁用 ECS,这种情况我们也给覆盖掉。代码如下:

var hasSubnet bool
if e := m.IsEdns0(); e != nil {
  for _, o := range e.Option {
    if o.Option() == dns.EDNS0SUBNET {
      a := o.(*dns.EDNS0_SUBNET).Address[:2]
      // skip empty subnet like 0.0.0.0/0
      if !bytes.HasPrefix(a, []byte{0, 0}) {
        hasSubnet = true
      }
      break
    }
  }
}

如果 hasSubnet 为假,则说明需要自动添加 ECS 信息:

if !hasSubnet {
  ip, err := netip.ParseAddrPort(r.RemoteAddr)
  if err != nil { /* ... */ }
  addr := ip.Addr()
  opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
  ecs := &dns.EDNS0_SUBNET{Code: dns.EDNS0SUBNET}
  var bits int
  if addr.Is4() {
    bits = 24
    ecs.Family = 1
  } else {
    bits = 48
    ecs.Family = 2
  }
  ecs.SourceNetmask = uint8(bits)
  p := netip.PrefixFrom(addr, bits)
  ecs.Address = net.IP(p.Masked().Addr().AsSlice())
  opt.Option = append(opt.Option, ecs)

  m.Extra = append(m.Extra, opt)
}

上面的代码根据客户端的 IP 地址构造 ECS 配置,再追加到 Extra 字段中。最后将其序列化之后就能发给上游 DoH 服务了。

question, err = m.Pack()
if err != nil { /* ... */ }

启动服务后可以使用 q 来验证 ECS 是否生效:

q --subnet 1.0.0.0/0 A taoshu.in @https://localhost:4430/dns/

这里通过--subnet来指定 ECS 参数。如果不指定,服务端会自动使用客户端的 IP 填充。

一阵欣喜之后赶紧部署到 VPS 上并配置到 Firefox,发现无法上网。不知道是什么问题,只能对比 Firefox 发的查询和 q 命令发的查询有什么差异。

Padding 问题

解析之后可以使用fmt.Printf("%+v\n", m)打印请求详情。我发现 Firefox 发的请求中带有 EDNS(0) Padding Option7。普通的 DNS 查询长度可能是固定的,通过 HTTPS 加密后也可能有明显的特征,会泄漏用户隐私。这个 Padding Option 就是一组特殊的填充数据,通过它来隐藏原始查询请求的长度。

q可以通过--pad参数指定添加 Padding 信息。

q --pad --subnet 1.0.0.0/0 A taoshu.in @https://localhost:4430/dns/

果然能复现,有 padding 就会有问题。仔细阅读 RFC7830 发现这么一段:

This document does not specify the actual amount of padding to be used, since this depends on the situation in which the option is used. However, padded DNS messages MUST NOT exceed the number of octets specified in the Requestor’s Payload Size field encoded in the RR Class Field.

所以说 padding 数据的长度是有要求的,不能随便改。我前面的代码直接把 ECS 配置追加到 Extra 列表可能会导致问题。最简单的办法是覆盖 Extra 字段:

a.Extra = []dns.RR{opt}

果然,修改之后就正常了。但这样做会不会有其他问题呢?毕竟没有把所有的 EDNS(0) 配置转发给服务器。根据 RFC68918 的说法:

EDNS is a hop-by-hop extension to DNS. This means the use of EDNS is negotiated between each pair of hosts in a DNS resolution process.

看起来问题不大,先用着😋

ZNS

以上就解决了 DoH 转发和 ECS 两个问题。按计划还得实现域名分流,也就是说国内域名走 DNSPod,国外域名走海外服务商。实现该功能也很容易,我们可以从前面的 dns.MsgQuestion 字段获得当前要查询的域名,然后再根据列表分流。

但实测发现,如果从香港访问 DNSPod,就能拿到无污染的解析结果,域名分流都不需要做了。后来才知道,原来 DNSPod 在香港也有 Anycast 节点,可以直接返回纯净结果。如果指定了 ECS,DNSPod 会回源到内地省份的查询节点解析,然后返回就近的结果。简直是完美💯

最后我把所有的代码整合成 https://zns.lehu.in 项目,部署到一台香港的 VPS 主机。虽然延迟不高,但需要按流量收费,所以我也集成了支付宝,每 100M 流量收一毛钱。有兴趣的朋友可以试用。所有代码都发布在 GitHub,采用 MIT 授权,大家可以自己部署,也可以整合到自己的产品中。

为 ZNS 添加支付也很有意思。数据库我用 SQLite,但是这货不支持并发写入。Chrome 启动后会批量发起很多 DNS 查询,SQLite 会直接报 SQLITE_BUSY 错误。我是参考这篇文章9 来优化 SQLite 配置,勉强达到能用的水平。

💡Tip

也有网友说 DoH 也好意思收费~是的,一定要收费。用户不付费就意味着你的服务价值不大,我们要尽量做有价值的事情。另外从功能到付费都做到闭环,无论在产品方面还是技术方面,都有一定的挑战。行百里者半九十,做出某个功能很容易,做成闭环产品就有点挑战了。希望大家都能尝试做自己的闭环产品。

另外,如果是 Macos 或者 iOS 用户,可以使用 DNSecure 软件设置全局 DoH。

以上就是本文的主要内容。有问题或者建议欢迎留言讨论😊


  1. ./dns-privacy.html↩︎

  2. https://datatracker.ietf.org/doc/html/rfc8484↩︎

  3. https://github.com/felixonmars/dnsmasq-china-list↩︎

  4. ./edns-client-subnet.html↩︎

  5. https://github.com/natesales/q↩︎

  6. https://github.com/FiloSottile/mkcert↩︎

  7. https://datatracker.ietf.org/doc/html/rfc7830↩︎

  8. https://datatracker.ietf.org/doc/html/rfc6891↩︎

  9. https://kerkour.com/sqlite-for-servers↩︎