ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手把手自建DoH服务:加密DNS查询与隐私保护实践

手把手自建DoH服务:加密DNS查询与隐私保护实践

好久没折腾基础设施了,前阵子因为家里和办公网络对公共DNS的解析结果一直被各种“改造”,加上越来越在意查询隐私,我决定用一台云服务器ECS自己搭一套DoH服务。DoH全称DNS over HTTPS,简单说就是把传统明文DNS查询包裹进HTTPS请求里,让解析过程既加密又防篡改。这篇实践文章不是纯理论科普,而是从我选型、配置、踩坑到最终跑通的全过程记录,内容偏动手向,适合有一定Linux基础、熟悉Nginx、想自己掌握DNS解析链路的人参考。

1. 动手之前:先把DoH这件事想清楚

1.1 DoH到底解决了什么痛点

传统DNS查询走的是UDP 53端口,数据包是明文。这就意味着从你的电脑到DNS服务器之间,任何一个中间设备都能看到你在查询哪个域名。公共Wi-Fi下,冒充路由器的恶意设备可以轻松劫持响应;运营商层面,动态污染和缓存投毒也不是什么稀罕事。更常见的是,你打开一个网页却被强制跳到一个广告域名,多半就是DNS响应被篡改的结果。

DoH的思路很简单:把DNS查询和响应内容封装成HTTP请求,再走TLS加密通道。这样查询数据被加密,中间人看到的是普通HTTPS流量,无法轻易读取你查询的域名;同时TLS层能校验服务器身份,响应内容不容易被篡改。DoH走的是443端口,和普通网页浏览端口一致,和DoT那种单独的853端口相比,在流量特征上更接近日常Web流量。另外它天然能复用HTTP生态里的特性,比如长连接、缓存、负载均衡,甚至后续的HTTP/3。

这里多说一句,DoH并不等于“私有DNS客户端随便填个地址就行”。客户端要兼容DoH服务端,需要处理两种标准请求模式:GET模式把二进制DNS报文做base64url编码放在query参数里;POST模式则直接把二进制报文放在请求体里。这两种模式对端都有要求,配置时容易出问题,后面我会详细讲。

1.2 为什么选云服务器ECS而不是软路由或者树莓派

我之前也考虑过在软路由上直接跑DoH,但很快否决了。第一,家里的公网IP是动态的,你不可能让手机在外网随时通过固定域名连回家里;第二,家用宽带的上行带宽和稳定性都不够理想;第三,TLS证书申请需要一个公网可达的域名和端口,家庭宽带甚至会被封掉80/443端口。

用云服务器ECS来跑这件事就顺理成章。它有固定公网IP,443端口可控,安全组能精确限制来源IP;系统挂了可以快照回滚,性能不够了可以升配;域名解析和证书签发都是常规操作。跑DoH对计算要求很低,1核1G的实例就能跑得很稳,就算加上Nginx和unbound,资源占用也远达不到瓶颈。

1.3 整体架构是怎么设计的

我最终采用的架构是:

客户端 -> HTTPS 443 -> Nginx -> DoH转化进程 -> 127.0.0.1:53 -> unbound递归DNS

这个链路里,Nginx只负责TLS终结、路径分发、速率限制这些入口层工作;DoH转化进程负责把标准DoH请求里的二进制DNS报文提取出来,转成普通DNS查询发给本地unbound;unbound则真正负责递归解析和缓存。这样做的好处是每一层职责清晰,出了问题能快速定位:浏览器上TLS握手失败,那就是Nginx层的事;能握手但解析失败,就要看DoH转化进程和unbound层了。

之前也考虑过直接用CoreDNS做DoH服务端。CoreDNS的DoH插件确实能直接用,但它把TLS终止、HTTP处理、DNS递归全揉在一个进程里,遇到并发问题或者需要加访问策略时,配置起来不够灵活。Nginx加独立转化进程的方案更符合我对可运维性的要求。

2. 环境准备与软件选型

2.1 云服务器与系统基础环境

我用的是一台2核2G的云服务器ECS,系统是Debian最新稳定版。选Debian而不是CentOS,纯粹是因为包版本新、资料多、踩坑容易搜到解决方案。如果选Ubuntu LTS也一样,命令基本通用。

安全组规则我只放行了三个端口:TCP 22(SSH)、TCP 80(证书验证用)、TCP 443(DoH服务)。其他端口一律拒绝,包括UDP 53、TCP 53。这里有一个细节我反复提醒自己:DNS本身需要走UDP 53,但那是给内部局域网用的,云服务器的安全组不需要对外暴露53端口。unbound只监听127.0.0.1,完全不参与公网访问。

2.2 域名与DNS记录

DoH服务不能只靠IP访问,HTTPS证书和客户端校验都需要域名。我拿了一个闲置域名,在解析服务商那里给ECS的公网IP添加了一条A记录,子域名类似dns.example.com这种。域名选择上我建议越短越好,因为DoH地址是要被写进浏览器配置、手机描述文件里的,域名越长越容易在配置时敲错。

如果你手头只有纯IP,技术上也能通过自签证书跑通,但浏览器里会出现证书不受信任的警告,实际使用体验很差,所以还是建议准备一个域名。

2.3 软件选型对比

这是整个实践里比较关键的一步,我把自己对比过的几套方案整理了一下:

方案组件优点缺点适用场景
方案ANginx + DoH转化进程 + unbound分层清晰、可扩展、Nginx生态成熟需要多维护一个转化进程生产级个人服务
方案BCoreDNS一个进程全搞定、DoH插件原生支持TLS终止和访问控制偏弱、配置偏复杂想快速验证DoH
方案Cdnsmasq + Nginx配置简单dnsmasq只适合做缓存转发,不支持DNSSEC验证对解析质量要求不高的场景

我选了方案A。DoH只是解决“传输通道”问题,真正决定解析质量和速度的是上游解析器。unbound作为递归解析器,支持DNSSEC验证、缓存策略精细、内存占用低,比dnsmasq纯粹做转发要强得多。Nginx则负责尽可能稳定地承接外部HTTPS请求。

2.4 关于DoH转化进程的选择

DoH转化进程是整个链路里最容易让人头疼的部分。现成的开源方案很多,找起来费神,而且质量参差不齐。有些项目只支持POST模式,GET模式直接返回400;有些项目很久没人维护,依赖一堆老库,部署到新系统上编译都过不去。

我最后选择了一个用Go写的轻量开源程序,单文件部署,内置HTTP服务,支持RFC 8484标准的GET和POST两种模式,可以把请求中的DNS报文转发到指定上游。至于具体是哪个名字,我不打算在这里做广告,但你搜“doh proxy”这类关键词,能找到多个类似实现,选的时候注意关注三点:是否同时支持GET和POST、是否能配置上游地址、项目最近是否有活跃更新。

如果找不到合适的二进制,自己写一个也不难,后面我会给出一段核心代码做参考。

3. 核心配置与实操过程

3.1 安装unbound并配置递归解析

先装基础工具:

apt update && apt install -y unbound nginx

unbound的配置文件在 /etc/unbound/unbound.conf,下面这段是精简后的核心配置:

server: interface: 127.0.0.1 port: 53 do-ip4: yes do-ip6: no do-daemonize: yes access-control: 127.0.0.0/8 allow access-control: 0.0.0.0/0 refuse cache-min-ttl: 3600 cache-max-ttl: 86400 prefetch: yes prefetch-key: yes num-threads: 2 msg-cache-size: 64m rrset-cache-size: 128m val-clean-additional: yes

配置里有两个点值得展开说。第一个是access-control,我只允许本机回环地址访问,其他来源全部refuse。这是防止unbound变成开放式递归解析器,避免被人利用做DNS放大攻击。第二个是prefetch,这个开关可以让unbound在缓存快到期的前几秒自动重新查询,避免缓存刚过期后第一个查询要等完整递归。

另一个重点是验证unbound是否能正常解析:

dig @127.0.0.1 example.com

如果返回了A记录,说明unbound本身工作正常。如果遇到SERVFAIL,多半是DNSSEC验证没过,可以先用dig +dnssec @127.0.0.1查看具体的返回状态码。

3.2 部署DoH转化进程

我用的Go开源程序,部署方式就是下载二进制、给执行权限、写systemd服务文件。核心systemd配置如下:

[Unit] Description=DoH Proxy After=network.target unbound.service [Service] ExecStart=/usr/local/bin/doh-proxy -listen 127.0.0.1:8080 -upstream 127.0.0.1:53 Restart=always RestartSec=3 User=doh Group=doh [Install] WantedBy=multi-user.target

这一步有一个坑很容易踩:想当然地把上游地址填成8.8.8.8或公共DNS,结果本地unbound的缓存完全没用到,解析延迟反而变高。正确做法是填127.0.0.1:53,让DoH进程只负责协议转换,真正的数据解析全部交给unbound。

如果你不想依赖现成程序,自己用Python写一个最小实现也很容易。下面这段代码用aiohttp做HTTP服务,核心逻辑就是把DoH请求中的DNS报文原样转发到本地unbound:

import asyncio import base64 import socket from aiohttp import web async def query_unbound(raw: bytes) -> bytes: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, _query_sync, raw) def _query_sync(raw: bytes) -> bytes: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(3) s.sendto(raw, ("127.0.0.1", 53)) data, _ = s.recvfrom(4096) return data async def handle_doh(request): if request.method == "GET": dns_param = request.query.get("dns") raw = base64.urlsafe_b64decode(dns_param + "===") elif request.method == "POST": raw = await request.read() else: return web.Response(status=405) resp = await query_unbound(raw) return web.Response(body=resp, content_type="application/dns-message") app = web.Application() app.router.add_route("*", "/dns", handle_doh) web.run_app(app, host="127.0.0.1", port=8080)

这段代码够跑通链路,但production环境我还是推荐用Go实现,单个二进制文件丢上去就能跑,内存和并发表现远好于Python解释器。

3.3 申请TLS证书并配置Nginx

DoH服务必须跑在HTTPS上,我用ACME协议从免费证书机构申请了一张证书。申请方式很多,我建议直接用支持自动续期的客户端工具。先安装客户端:

apt install -y certbot

然后执行证书签发:

certbot certonly --webroot -w /var/www/html -d dns.example.com

这里有个非常容易踩的坑:如果你已经在Nginx里配置了location / { deny all; },证书验证请求会被拦截,签发过程直接失败。所以要么在申请证书之前先把Nginx停掉,要么在Nginx里单独为ACME验证路径做放行。我自己就被这个坑卡了将近半小时,后面在Nginx配置章节里会专门给出放行配置。

证书签好之后,Nginx监听443端口,并把DoH路径代理给本地8080端口。核心配置如下:

server { listen 443 ssl; http2 on; server_name dns.example.com; ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; location ^~ /.well-known/acme-challenge/ { root /var/www/html; } location = /dns { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; large_client_header_buffers 4 16k; client_body_buffer_size 1k; client_max_body_size 4k; proxy_read_timeout 10s; } location / { deny all; } access_log off; }

几个配置点我需要强调:

  • ssl_session_cache和ssl_session_timeout能让同一客户端复用TLS会话,避免每次查询都重新走一遍完整握手,对降低延迟很有帮助。
  • access_log off关掉了访问日志。DoH请求的URL里本身就带有base64编码的DNS查询报文,如果开着访问日志,相当于把你所有DNS查询记录明文写在磁盘上,那做DoH的隐私意义就大打折扣了。
  • location = /dns 用的是精确匹配,只暴露这个路径,其他路径一律deny。这样即使有人扫描到你服务器的443端口,也找不到可用端点。

改完Nginx配置后一定要先测试再重载:

nginx -t systemctl reload nginx

3.4 客户端接入与实测验证

服务端跑起来之后,先用命令行工具做一次完整的DoH请求验证。下面这个命令用的是GET模式,dns参数是base64url编码的DNS查询报文:

curl -sk -H 'accept: application/dns-message' \ 'https://dns.example.com/dns?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' -o /tmp/doh.bin

正常返回的是一个二进制的DNS响应报文,可以用xxd /tmp/doh.bin查看内容,能看到响应标志位和具体的A记录信息。如果返回的是JSON,那说明你访问到的可能是个JSON接口,不是标准的RFC 8484 DoH接口。

实测通过后,就可以配置终端设备了。浏览器里通常在隐私设置或安全设置中找到“安全DNS”选项,在里面填入https://dns.example.com/dns即可。手机系统如果原生支持私有DNS,也可以填入这个DoH地址;不支持的可以通过配置描述文件方式下发。配置好之后,访问一个域名,然后在服务器上观察unbound的查询日志,如果能看到来自127.0.0.1的查询记录,说明整条链路已经通了。

4. 性能调优、访问控制与日常运维

4.1 从缓存到并发:性能还能再压一压

DoH服务跑通只是第一步,性能调优直接决定实际使用体验。我的调优方向有三个层面。

第一是unbound的缓存调整。cache-min-ttl和cache-max-ttl控制缓存的最短和最长保留时间,prefetch开启后,高频域名的TTL快到期时会自动提前查询,让缓存永远“不冷”。我设置cache-min-ttl为3600秒,意味着即使某个域名原TTL只有60秒,在unbound层也会缓存至少1小时。这样做的好处是减少上游递归请求,但代价是域名解析结果更新可能变慢。对个人服务来说,这个取舍完全值得。

第二是Nginx的连接复用。HTTP/2天然支持多路复用,同一个连接上可以并行发起多个DoH请求,不需要每次都做TCP握手和TLS握手。如果你的Nginx版本支持HTTP/3,还可以把UDP 443的流量也纳入进来,不过普通场景下HTTP/2已经够用。打开ssl_session_cache之后,TLS握手的开销进一步降低,实测下来长连接场景的延迟表现比独立短连接好很多。

第三是DoH转化进程的并发处理。Go语言实现的转化进程本身就有很好的并发能力,但如果你的上游unbound成为瓶颈,那就需要检查unbound的num-threads和outgoing-range参数。num-threads设置成CPU核数即可,outgoing-range控制同时向上游发起的查询数量,默认值太低时可以通过调大来提升并发处理能力。

4.2 防滥用:个人DoH服务最大的风险

开放DoH服务最大的风险不是被攻击,而是被扫描工具发现后当作免费公共DNS使用。别人的设备走你的DoH服务解析,会白白消耗你的带宽、CPU,而且会增加日志记录的敏感数据量。更麻烦的是,如果你的服务被用于大量异常查询,可能被云厂商标记为异常行为。

我的防滥用策略有三层,建议照抄:

第一层,路径混淆。不要把DoH端点放在 /dns 这种一眼就能猜到的路径下,改成类似 /dns/3f9a1c2e 这种带随机串的路径。功能上没有任何区别,但可以让扫描器无法轻易判断这是一个DoH端点。

第二层,Nginx速率限制。针对单个来源IP做请求频率限制:

limit_req_zone $binary_remote_addr zone=doh:10m rate=5r/s; server { location = /dns/3f9a1c2e { limit_req zone=doh burst=10 nodelay; proxy_pass http://127.0.0.1:8080; } }

rate=5r/s表示每个IP每秒最多5个请求,burst=10允许短时间突发10个请求。正常浏览器使用DoH的频率远低于这个值,所以不会影响体验,但能有效压制批量扫描。

第三层,安全组白名单。在云厂商安全组里只放行自己的家庭出口IP。如果家里是动态IP,写一个定时脚本,定期检测本机出口IP变了就调用云厂商API更新安全组规则。对于防滥用来说,这一层最有效,但需要一点脚本开发能力。如果不想写脚本,也可以退一步,只保留路径混淆和速率限制。

4.3 日志、证书自动续期与健康检查

个人服务的日常运维,重点就三件事:保证服务在跑、保证证书没过期、保证没有异常访问。

日志方面,Nginx access_log直接关掉。unbound的日志也建议只记录错误级别,不要记录每一条查询日志。如果你确实需要分析查询统计,可以在unbound里单独配置统计模块,而不是依赖访问日志。

证书自动续期我用系统的定时任务来实现:

certbot renew --quiet --deploy-hook "systemctl reload nginx"

为了避免定时任务出问题导致证书悄悄过期,我额外写了一个简单的systemd定时器,每天检查一次证书剩余天数,如果小于30天就强制续期。同时配合云厂商的监控告警,服务器能收到系统资源超限提醒。整个运维不需要投入太多精力,但必要的检查不能省。

5. 常见问题与排查思路

5.1 证书验证一直失败

证书签不下来,优先级最高的排查项是80端口ACME验证路径是否可达。一种情况是云厂商安全组没放行80端口,ACME服务端无法访问你的验证文件;另一种情况是Nginx配置了全局deny,把验证请求拦截掉了。排查方法很简单:

curl -I http://dns.example.com/.well-known/acme-challenge/test

如果不是返回404而是直接连接失败,那就是安全组或服务没起来的问题。如果返回403,就是Nginx的localtion匹配顺序问题,需要在server块里加上前面给出的ACME验证路径放行配置。

5.2 浏览器提示无法连接DoH服务器

这种问题一般出在协议兼容性上。浏览器对DoH服务端的校验很严格,除了TLS证书有效外,还要求服务端正确响应GET和POST两种模式,并且返回的Content-Type必须是 application/dns-message。

用curl分别测试两种模式:

# GET模式 curl -sk -H 'accept: application/dns-message' \ 'https://dns.example.com/dns?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' -w '%{http_code}' # POST模式 echo -n "AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" | base64 -d > /tmp/dns.bin curl -sk -H 'accept: application/dns-message' -H 'content-type: application/dns-message' \ --data-binary @/tmp/dns.bin 'https://dns.example.com/dns' -w '%{http_code}'

正常情况下两个请求都应该返回200,响应体是二进制DNS报文。如果GET模式返回400或405,多半是转化进程不支持GET模式;如果返回的Content-Type是text/html,说明请求被某个反代规则误处理了。

5.3 解析正常但延迟明显偏高

DoH请求链路比普通DNS多出了TLS握手和HTTP解析环节,延迟理应比传统DNS高一些,但如果高到几百毫秒就需要排查了。

第一个排查点在unbound。直接在本机执行dig @127.0.0.1 example.com,看响应时间。如果本身就慢,说明unbound递归解析上游有问题,可以把prefetch: yes打开,或者检查上游是否访问缓慢。第二个排查点在Nginx是否成功复用了TLS会话。如果每次请求都重新握手,延迟会明显增加,检查ssl_session_cache是否生效。第三个排查点是网络链路本身的RTT。如果你的手机和云服务器之间跨运营商,延迟高是正常的,可以换一台离自己更近的节点。

5.4 证书到期了但自动续期没触发

踩过这个坑之后我学乖了。certbot自带的定时任务不是所有系统都会自动启用,你手动执行过certbot命令,但定时器可能根本没有拉起。用systemd timer的化,检查状态:

systemctl status certbot.timer

如果定时器没启用,手动启用:

systemctl enable --now certbot.timer

如果你用的是cron,那就要检查certbot.log。另一个容易被忽视的问题是续期时的deploy-hook,证书续期后Nginx还在用旧证书,直到你手动reload配置。所以deploy-hook里一定要写Nginx reload,不然就算证书续期成功,服务端仍然提供旧的过期证书。

5.5 服务器日志里出现大量陌生IP请求

这说明你的DoH端点已经被扫描器发现了。不用慌,按前面提到的三层防御逐级处理:先把端点路径改掉,然后在Nginx层加速率限制,最彻底的手段是安全组白名单。扫描器的攻击大多来自海外IP,如果你用不上海外访问,直接在安全组里只放行自己的国家和地区IP段,能省掉很多麻烦。

如果在安全组层面已经限制了白名单,但日志里还是有陌生IP,那多半不是公网进来的,而是你的服务器上某个服务被当作了跳板。这时候要检查系统进程和对外开放的端口,确认没有其他未授权服务在运行。

搭建DoH这套东西,技术难度其实不算高,真正花时间的是理解每一层为什么要这么配。我在这个过程中踩过最大的坑,就是一开始把DoH转化进程的上游直接指向了公共DNS,结果unbound的缓存完全没起作用,解析速度反而比直接用公共DoH还慢。后面改成走本地unbound之后,整个链路才真正顺畅起来。如果你也想搭一套个人DoH服务,我的建议是先把架构想清楚,不要一上来就去改Nginx配置,等链路整体跑通之后再逐步加访问控制,这样排查问题会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进