资讯动态

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

发布时间:2026/10/23 13:14:48 来源:尧图企业网站定制
好久没折腾基础设施了前阵子因为家里和办公网络对公共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来跑这件事就顺理成章。它有固定公网IP443端口可控安全组能精确限制来源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查询发给本地unboundunbound则真正负责递归解析和缓存。这样做的好处是每一层职责清晰出了问题能快速定位浏览器上TLS握手失败那就是Nginx层的事能握手但解析失败就要看DoH转化进程和unbound层了。之前也考虑过直接用CoreDNS做DoH服务端。CoreDNS的DoH插件确实能直接用但它把TLS终止、HTTP处理、DNS递归全揉在一个进程里遇到并发问题或者需要加访问策略时配置起来不够灵活。Nginx加独立转化进程的方案更符合我对可运维性的要求。2. 环境准备与软件选型2.1 云服务器与系统基础环境我用的是一台2核2G的云服务器ECS系统是Debian最新稳定版。选Debian而不是CentOS纯粹是因为包版本新、资料多、踩坑容易搜到解决方案。如果选Ubuntu LTS也一样命令基本通用。安全组规则我只放行了三个端口TCP 22SSH、TCP 80证书验证用、TCP 443DoH服务。其他端口一律拒绝包括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 nginxunbound的配置文件在 /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] DescriptionDoH Proxy Afternetwork.target unbound.service [Service] ExecStart/usr/local/bin/doh-proxy -listen 127.0.0.1:8080 -upstream 127.0.0.1:53 Restartalways RestartSec3 Userdoh Groupdoh [Install] WantedBymulti-user.target这一步有一个坑很容易踩想当然地把上游地址填成8.8.8.8或公共DNS结果本地unbound的缓存完全没用到解析延迟反而变高。正确做法是填127.0.0.1:53让DoH进程只负责协议转换真正的数据解析全部交给unbound。如果你不想依赖现成程序自己用Python写一个最小实现也很容易。下面这段代码用aiohttp做HTTP服务核心逻辑就是把DoH请求中的DNS报文原样转发到本地unboundimport 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(status405) resp await query_unbound(raw) return web.Response(bodyresp, content_typeapplication/dns-message) app web.Application() app.router.add_route(*, /dns, handle_doh) web.run_app(app, host127.0.0.1, port8080)这段代码够跑通链路但production环境我还是推荐用Go实现单个二进制文件丢上去就能跑内存和并发表现远好于Python解释器。3.3 申请TLS证书并配置NginxDoH服务必须跑在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 nginx3.4 客户端接入与实测验证服务端跑起来之后先用命令行工具做一次完整的DoH请求验证。下面这个命令用的是GET模式dns参数是base64url编码的DNS查询报文curl -sk -H accept: application/dns-message \ https://dns.example.com/dns?dnsAAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB -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 zonedoh:10m rate5r/s; server { location /dns/3f9a1c2e { limit_req zonedoh burst10 nodelay; proxy_pass http://127.0.0.1:8080; } }rate5r/s表示每个IP每秒最多5个请求burst10允许短时间突发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?dnsAAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB -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配置等链路整体跑通之后再逐步加访问控制这样排查问题会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑