所有系统运行正常 6 个离岸区域 无 KYC 结账
Hands-on 实战指南

VPS 上自建 DNS 解析器:没有别人能留存的日志

你把流量加密了,把机器搬到了离岸,还用门罗币付了钱。可你敲下一个域名的那一刻,在任何一个加密字节离开这台机器之前,你的设备先问了一个陌生人该把它送到哪里去——而那个陌生人有权把这个问题留下来。解析器日志是网络上能描述一个人最完整的东西:每一个网站、每一个应用、每一个后台服务,按顺序、带时间戳,全都在里面。本指南要做的,就是把这份日志搬到一台你自己租下的机器上——一台递归式、支持 DNSSEC 验证的 Unbound,跑在清单里最便宜的那档套餐上,只能通过你自己的隧道访问,而且——这是大多数教程都会略过的部分——它不是一个谁都能拿去指向受害者的开放解析器。

更新于 2026-09-15 · 15 分钟阅读 · 服务器运维
本页内容
  1. 比你的浏览器历史更懂你的那份日志
  2. 转发只是搬走日志。递归才能把它拆散。
  3. 这套做法藏得住什么,又明明藏不住什么
  4. 这个错误会把你的解析器变成别人的武器
  5. Unbound 的默认值是理智的。但它们并不私密。
  6. 两条进来的路:隧道,或者 DNS-over-TLS
  7. 拦截是另一件产品;先想清楚,再决定要不要装上
  8. 你自己跑的解析器,是你自己扛的一个依赖
  9. 分步操作
SP·01

比你的浏览器历史更懂你的那份日志

在你的机器能给一个网站发出第一个加密字节之前,它得先问个人这个网站在哪儿。这个问题以明文传播,传给一个你几乎肯定没有自己选过的解析器,而谁留着这份记录,决定了你的生活有多少被写了下来。浏览器历史是你自己选择要留下的那些页面的清单。解析器日志则是一切:每一个网站、每一个在检查更新的应用、你手机在你睡觉时跟每一个云服务的对话、你打开过的每一封邮件里的每一个域名,按顺序、带时间戳,不管当时有没有人盯着屏幕看。

读一个星期的记录,你就能把一个人还原出来。他们用哪家银行,刚订了哪趟航班,去哪家药店,用哪个交友软件,招聘方用的是哪套应聘者追踪系统,几点起床,几点收工。这些都不需要破解任何加密。光是这些名字本身就足够了,而在大多数配置里,名字恰恰是整笔交易中仍然被设计成要交给第三方的那一部分。

如今这个第三方就是你的 DHCP 租约当初把你指向的那个谁——你的 ISP、你的雇主、街角的咖啡馆。注意到这一点的人通常会换用一个公共解析器,这在完整性上是真正的进步,在隐私上却只是平移:你没有移除那个观察者,你只是换了一家公司来当这个观察者,用一家受监管的电信运营商换来了一家跟广告业务沾亲带故的 CDN,或者一家政策可能随着资金状况说变就变的非营利组织。好的那几家会公布诚实的留存政策,其中一些也确实说到做到。但政策是对行为的一句承诺,配置文件是对能力的一句陈述。你没法审计一句承诺。你能审计的是你自己写的二十行配置,在一台你租来的机器上,在一个你自己挑的国家里。

SP·02

转发只是搬走日志。递归才能把它拆散。

几乎每一篇标题写着"自己搭一台 DNS 服务器"的教程,最后搭出来的都是一个转发器:你网络里的一个小缓存,把它不知道答案的一切都转手甩给 1.1.1.1 或者 9.9.9.9。这东西确实有用——快,只要五行配置,还能让你的本地网络没法再盯着你看。但它也把那份汇总日志原样留在了原地。你查的每一个名字依然会抵达同一家公司,现在还很贴心地预先贴上了你那个解析器唯一的 IP 地址标签,这只会让这股数据流更容易被归到你头上,而不是更难。

递归解析器是自己把活干了,而不是把活转包出去。问它 news.example.io,它会先问根服务器谁在管 .io,再问 .io 的服务器谁在管 example.io,然后直接去问那个运营方。三段对话,三个互不相干的对象,没有一个是干汇总生意的,而且——这才是要紧的地方——没有任何一个能看到你完整的查询流。根服务器的运营方只看到零零星星的顶级域请求。Verisign 只看到你这个地址上有人碰了 .com 下面的什么东西。某个域名的权威服务器看到的是它反正本来就会看到的流量,因为你马上就要去连它了。

QNAME 最小化让这一点更加锐利,而现代版的 Unbound 默认就会这么做。它不会把完整的名字发给链条上的每一台服务器——这是解析器们过去三十年一直在做的事——而是只把每台服务器回答所需的那个标签发给它:把 io. 发给根,把 example.io. 发给 .io 的服务器,然后才把完整的 news.example.io. 发给真正有权知道的那个运营方。层级结构从此不再是你意图的一次广播,而变成了它最初被画出来时的样子:一次委托。

这笔代价是真实的,值得说清楚。一次冷启动的递归查询要跑好几个来回,而转发器只需要一个来回,所以第一次访问一个陌生域名会明显更慢。你接手了一个原本是别人的麻烦的缓存,现在归你负责。而且你也不再能沾上那个被几百万人共同用热的共享缓存的光。作为交换,那份完整、有序、带时间戳的、记录着你查过什么的记录,从此在你自己的磁盘之外不复存在。

SP·03

这套做法藏得住什么,又明明藏不住什么

老实说,这套做法的实际范围比围绕私有 DNS 的宣传话术暗示的要窄得多,弄清楚边界在哪里,才能让你不会仅凭一时的好感觉,就做出糟糕的决定。

它去掉的是一件事:那份被单一观察者掌握、汇总起来的域名日志。这已经是一件大事,因为正是这份汇总数据才具有商业价值,才会被成批索要。但这不是全部。

它藏不住这次连接本身。解析完成之后,你的设备依然会向那个地址发起一个会话,任何盯着你上行流量的人都能看到目的地 IP。对于跑在专用基础设施上的站点来说,IP 就是身份。它也藏不住线路上的主机名:除非两端都支持 Encrypted Client Hello,TLS 握手依然会用明文带着服务器名称,这跟你的解析器原本会知道的信息是同一份。

它也藏不住你托管服务商看到的这些查询。这是人们最容易搞错的一点,所以有必要毫不含糊地说清楚:你的解析器发往上游的流量——它问根服务器、顶级域服务器和权威服务器的那些问题——会以未加密的 UDP 53 端口流量离开这台 VPS,而你服务器所在的那张网络能读到其中的全部内容。到根服务器没有 DoT。你与其说是删除了那个观察者,不如说是把它挪了个地方:从一个会出售数据、在你自己国家里应答传票的消费级 ISP,挪到了一个你刻意挑选的司法辖区里的托管网络,而且由于 QNAME 最小化,它看到的只是一股被拆散的数据流。这是一次真正的改善,但它讨论的是这条线路该适用谁的法律,而不是一件配置文件能解决的事。

它也没法给你一群人当掩护。一个只被一户人家用的解析器,会把里面的每一次查询毫无歧义地归到那一户人家头上。面对一个全球性的被动监听者,一个繁忙的共享解析器确实是更好的藏身之处。而面对你的 ISP、你的雇主、那些购买解析器遥测数据的数据经纪商,以及实际落在普通人头上的那些例行批量请求,你自己的解析器更好——前提是你设备的最后一跳是在隧道里面。要搭配你自己的 WireGuard 隧道一起用,而不是用它取而代之。单独用的话,私有解析器大体上只是把你的元数据搬了个地方。在隧道后面用,它才关上了隧道留下的那一个缺口。

SP·04

这个错误会把你的解析器变成别人的武器

把这件事搞得很糟只有一种方式,一不小心就会碰上它,而后果会先落在陌生人头上,才轮到你自己。

一个谁问都答的解析器就是一个开放解析器,而开放解析器就是一个放大器。DNS 跑在 UDP 上,UDP 的源地址伪造起来轻而易举,一个很小的查询能换来一个大出好几倍的回应。攻击者给你的机器发一个 60 字节的问题,把发送者伪造成受害者的地址;你的机器则老老实实把一个大出几十倍的响应发给了那个受害者。同时对着几千个开放解析器这么干,受害者就会被彻底挤下网——它收到的这股洪流,在包一级看,确凿无疑地像是来自你。你不是目标。你是那把枪,事件报告里的流量是你的。

接下来的场面毫无光彩:来自你从没听过的那些网络的投诉举报、一个为了保护自己中转线路把你的地址整个拉黑的服务商,还有一场你巴不得别发生的账号对话。你最终会站到我们DDoS 攻击第一小时应对手册里描述的那种事件的肇事者一端,而那个故事不管怎么讲,都没有一个版本是你的在线时长能撑过去的。

两道互相独立的锁能防住这件事,而你需要两道都上,因为它们各自补的是对方失手时的那个洞。第一道锁是 Unbound 自己的 access-control:应该在两个 IP 协议族上都 refuse 整个互联网,然后再显式地 allow 回环地址和你的隧道子网——这是一份默认拒绝的清单,不是一份带着宽松尾巴的允许清单。第二道锁是这个守护进程到底监听在哪里:把它绑在 127.0.0.1 和隧道地址上,永远不要绑在 0.0.0.0 上,并且在防火墙上让公网接口的 53 端口保持关闭。

陷阱在于只做了第一道锁。一条 access-control 规则并不会让端口消失;一个被拒绝的查询依然是收到了一个包、发出了一个包,你的地址依然会出现在那些给开放解析器画地图的扫描结果里,而以后随便哪次编辑改错了段落,就能把一次拒绝变成一次应答。绑定加防火墙能让这个错误在结构上就变得不可能,而不是仅仅隔着一行配置。如果这台机器上还跑着容器,先重新读一遍Docker 是如何绕开你的防火墙发布端口的,再去假定你写的那条规则就是实际生效的那条规则。

SP·05

Unbound 的默认值是理智的。但它们并不私密。

Unbound 出厂调的是正确性和稳定性,对于一款主要由 ISP 部署的软件来说,这个默认取向是对的。只要改动一小把设置,就能把它变成一个真正为运行它的这个人而生的东西。这些设置没有一个多稀奇;它们在开箱状态下只是被关掉了,或者没有预设立场。

别再回答跟你自己有关的问题了。默认情况下,解析器会很乐意通过 CHAOS 类——也就是 version.bindhostname.bind——报出自己的软件版本和主机名,这对任何正在判断你这台机器是否值得留意的人来说,都是白送的侦察情报。hide-identityhide-version 不花你一分钱代价,却能去掉一个指纹。

失败时要关闭,不要开着。DNSSEC 验证决定了一个解析器是能识破伪造应答,还是会把伪造应答原样端给你,这两者的差别。harden-dnssec-stripped 会拒绝接受一个本该被签名的区却收到未签名应答的情况,harden-glueharden-below-nxdomain 堵住了两条经典的缓存投毒路径,aggressive-nsec 能让解析器直接从缓存的否定记录里回答不存在的名字,而不必再问一遍。use-caps-for-id 靠随机大小写增加额外的熵,用来对抗盲发式伪造——代价很低,但偶尔会跟某个实现很糟的权威服务器不兼容,这一点最好在你为一个解析不出来的域名耗上一整个下午之前就知道。

什么都不要写下来。除非你专门要求,Unbound 不会记录查询,但会记录的那些设置只差取消注释那一行就会被打开,而有些发行版打包时默认就更爱说话。把 verbosity: 0 设上,并明确写上 log-queries: no,这样这个意图是写在文件里能看见的,而不是靠它的缺席去推断出来的。然后记住那个不是一项设置的部分:缓存本身就是一份记录。在一台正在运行的机器上执行 unbound-control dump_cache,会打印出这台机器最近查过什么的历史,而这份历史活在一台别人的手能够得着的机器的内存里。它会自己过期,这正是缓存寿命短和隐私之间存在那么一点点张力的全部原因,也正是不该让一个解析器在一台你完全不信任的主机上活上好几个月的原因。

刻意去做缓存。每一个从缓存里应答出去的问题,都是一次从没发生在上游的观察,所以一个健康的缓存是一项隐私功能,不只是一项速度功能。prefetch 会在热门记录过期之前把它们续上,这样常见情形就完全不用再碰网络了;serve-expired 能在权威服务器短暂无法访问时让你继续在线。调高 cache-min-ttl 能进一步减少上游的交谈量,但这也会覆盖域名运营方刻意做出的选择——低 TTL 正是 CDN 和故障切换赖以运作的机制——所以设个一两分钟的下限是合理的,而设成一小时迟早会把你困在一个已经失效的地址上。

SP·06

两条进来的路:隧道,或者 DNS-over-TLS

你的设备总得想办法连到这个解析器,而在两个靠谱的选项之间该怎么选,很大程度上是个你愿意公开什么的问题。

对几乎所有人来说,隧道都是更好的答案。如果你的笔记本和手机已经跟那台服务器保持着一个 WireGuard 会话,解析器就可以监听在隧道地址上,在 53 端口上说普通的 DNS。这份流量已经被隧道加密、也已经被隧道认证过了,所以不用去申请证书,不用给互联网开一个新端口,不用把一套 TLS 栈暴露给陌生人,还有——这一点被严重低估了——哪里都不会出现主机名。这个网站上的 WireGuard 指南让客户端一直指向 DNS = 9.9.9.9,正是因为当时还没有更好的东西可以填在那里。现在该填进那一行的就是这个:你自己服务器的隧道地址。

DNS-over-TLS 是给那些没法维持一条隧道的设备用的。Android 的私人 DNS 字段是它最有力的用例——它是系统级生效的,能在重启后存活,还能覆盖到你没法直接插手的那些应用。代价是需要一个带有效证书的主机名,而一张证书意味着在证书透明度日志里留下一条公开、永久的记录,把这个名字和你创建它的那个时刻绑在一起。之后被动 DNS 会把这个名字和服务器的地址绑到一起。如果这么做的目的是不让基础设施上留下你的名字,那就用一个跟你本人扯不上任何关系的主机名,并按照匿名注册域名里讲的那份细致去注册它——不要用你其他一切都在用的那个域名下的子域名,那会把两者永久地链在一起。

还有第二层更隐蔽的代价。一个能被漫游中的手机连到的 DoT 端点没法按来源地址加以限制,所以按定义它就是一个陌生人只要知道名字就能用的解析器。它不是一个放大器——TCP 上的 TLS 需要一次完整的握手,所以来源没法伪造,也没什么可反射的——但这依然是你在往外送的算力,也是一个值得被扫描发现的服务。把按 IP 的速率限制开着,挑一个谁都猜不到的主机名,把它当成为一两台设备特意开的例外,而不是默认的大门。

DNS-over-HTTPS 是第三个选项,而在这里通常是错的那个。它需要在解析器前面架一个 Web 服务器,这意味着更多的活动部件、更大的攻击面,换来的唯一好处是跟普通 Web 流量混在一起分不出来。如果你是要绕过一个屏蔽 DoT 的网络,这个好处就是决定性的;如果不是,这个好处就跟你没什么关系。

SP·07

拦截是另一件产品;先想清楚,再决定要不要装上

早晚会有人提议加上黑名单,而这个说法确实很有吸引力:在解析器这一层过滤,能覆盖隧道上的每一台设备,包括智能电视和那些扩展程序伸不进去的手机应用。尤其在移动端,这几乎是唯一能实际插手的地方。但这也是最容易让你悄悄开始不信任自己这套基础设施的那个功能,值得在事情发生之前先弄明白原因,而不是等到事情发生的时候。

第一个代价是:故障看起来不像故障。一个坏掉的解析器会自己告诉你出问题了;一个被拦截的域名表现出来的样子,是一个按下去没反应的结账按钮,一个卡在转圈图标上的应用,一封永远收不到的邮件。这个症状会在你装上名单的几个星期之后,出现在一台你根本没在想着的设备上,而没有任何东西会把它和你在另一个月做的那个 DNS 决定联系起来。你装上的每一份黑名单,都是一个陌生人写的政策,被悄无声息地强加在你家里执行。如果你要加一份,就把这件事记下来,选小而口碑好的名单,并留一条一条命令就能关掉它的路——你网络上任何解释不清的问题,第一步排查都该变成"绕开这个过滤器再试一次",而这一步必须做起来很轻松才行。

第二个代价是:它做不到人们希望它做到的事。一个硬编码了解析器地址、或者内置了自己的 DoH 客户端的应用,根本不会问你的解析器任何问题;它直接连上那个硬编码的 IP,然后就没了。浏览器也越来越多地默认走自己的 DoH,除非被特意关掉。DNS 拦截是一层卫生层面的清理,能去掉一大堆低成本的跟踪和广告噪音,但它不是一项安全控制手段,因为任何存心作对的东西天生就是绕着它设计的。

如果你还是想要这个功能,宁可在 Unbound 里加一个小的本地区域,也别再起第二个守护进程——local-zone: "tracker.example." always_nxdomain 不需要额外的软件,不需要一个你还得另外去守卫的端口上的 Web 界面,也不会多一个可能垮掉、还顺带拖垮你的域名解析的新服务。让这台机器的职责说明只保留一行:它负责解析名字。你给它加的每一项额外职责,都是让一切同时崩掉的又一种方式。

SP·08

你自己跑的解析器,是你自己扛的一个依赖

当你托管的某个网站挂掉时,一些人只是读不到某个东西。而当你的解析器挂掉时,什么都不能用了——浏览器不能用,邮件不能用,包管理器不能用,就连那个本来要告诉你服务器挂了的应用也不能用。这是你能放在一台便宜机器上的、承重最大的一项服务,而它出故障的样子往往看起来根本不像是 DNS 的问题。

值得提前了解一下那些实际会发生的故障模式,因为每一种都有不同的表现。VPS 重启了,而 Unbound 从来没设成开机启动,于是一切正常运作,直到第一次计划外的重启才露馅。一份很大的黑名单把缓存挤进了一台 1 GB 实例的交换区,然后 OOM killer 选中了这个解析器动手。某处的一个区把自己的 DNSSEC 签名弄坏了,你那台配置正确的解析器拒绝了这个答案,而所有用着不做验证的解析器的人却还在正常浏览——你的机器是对的,但那个网站在你看来依然像是坏的,如果你忘了自己开着验证,这会让你困惑上五分钟。又或者隧道掉线了,而由于 DNS = 10.66.0.1 只存在于隧道内部,设备就变得完全没有解析器可用,还会报告说自己离线了。

好在补救办法都不贵。把这项服务设成开机启动,并真的用一次重启去测试它,而不是想当然地以为没问题。给客户端配一个次要解析器,这样隧道掉线时是降级,而不是彻底停摆——放在这个位置上的一个公共解析器,是一次很小、很明确的隐私妥协,只在你自己的解析器无法访问时才会用上,通常也是值得的一笔交易。让 serve-expired 保持开启,这样上游一次短暂的中断就不会变成你自己的中断。要从别的地方检查这个解析器,而不是从这台机器本身检查,这是唯一能让你分清"宕机"和"不可达"这两者区别的办法。

另外,留一份配置的备份。这整套东西也就几十行,花了你一个下午才调对,一年之后你肯定记不住了;它应该待在你那份异地加密备份里,跟 WireGuard 的密钥放在一起,这样重建起来只需要二十分钟,而不是再花一个下午。这就是这整套折腾最诚实的总结:一台便宜机器只干一件事,花的是清单上最便宜那档套餐的钱,用一份记录压根不会被创建出来的安排,取代了一句关于留存的承诺。

SP·09

分步操作

  1. 01

    从一台已经加固好的机器开始

    解析器是个安静的小服务,这容易让人想拿手边随便一台机器来装它。别这么做——这台机器会看到你隧道上每一台设备查的每一个名字,所以它该享受跟任何持有机密的东西一样的待遇。挑你喜欢的最小套餐来部署就行;1 GB 内存对一户人家足够宽裕了,因为下面这些缓存大小都是以几十兆字节来算的。先老老实实做完部署后的第一小时该做的事,再动别的:一个具名用户、只认密钥的 SSH、两个 IP 协议族上都默认拒绝、无人值守的安全更新。

    然后安装 Unbound。发行版打的这个包自带根提示文件和 DNSSEC 根信任锚,还接好了信任锚的自动轮换机制,这是少数几个打包版本能真正帮你躲开一整类未来故障的场合。

    sudo apt update && sudo apt install -y unbound dnsutils
    unbound -V | head -n 3
    
    # the packaged trust anchor the resolver will validate against
    sudo ls -l /var/lib/unbound/root.key

    如果 root.key 不存在,说明你的包没有运行过 unbound-anchor,验证会对所有东西都失败关闭。继续之前,先用 sudo -u unbound unbound-anchor -a /var/lib/unbound/root.key 生成一次。

  2. 02

    从 systemd-resolved 手里把 53 端口要回来

    在大多数现在的发行版上,53 端口早就被别的东西占着了:systemd-resolved 会在 127.0.0.53 上跑一个 stub 监听器,而 /etc/resolv.conf 是一个指向它的符号链接。在这个问题解决之前,Unbound 会拒绝启动,或者启动了却绑不到任何有用的地址上。动手改之前先看清楚现状。

    ss -ulpn 'sport = :53'
    ls -l /etc/resolv.conf

    运行下一段代码之前先读完这一段:在关掉 stub 监听器和启动 Unbound 之间的这段时间里,这台机器完全没有能用的 DNS。步骤三和步骤四要一口气做完,而且别先在另一个窗口里起一个 apt 操作——它会卡在一次名字查询上,然后你会误以为是那个你还没启动的解析器出了问题。

    # stop resolved from holding the port (appends inside the [Resolve] section)
    printf 'DNSStubListener=no\n' | sudo tee -a /etc/systemd/resolved.conf
    sudo systemctl restart systemd-resolved
    
    # point the host at the resolver it is about to run
    sudo rm -f /etc/resolv.conf
    printf 'nameserver 127.0.0.1\noptions edns0 trust-ad\n' | sudo tee /etc/resolv.conf
    
    # the port should now be free
    ss -ulpn 'sport = :53'
  3. 03

    写一份既会递归又什么都不记的配置

    别去动打包自带的配置,而是在 drop-in 目录里加一份你自己的文件,这样软件包升级就永远不会悄悄把你的决定改回去。下面的每一行,要么是在拒绝陌生人,要么是在从根开始递归,要么是在不留记录。如果你自己 WireGuard 服务器上的隧道地址和子网不一样,就把 10.66.0.110.66.0.0/24 换成你自己的。

    sudo tee /etc/unbound/unbound.conf.d/private-resolver.conf > /dev/null <<'EOF'
    server:
        # listen for the host itself and for the tunnel — never on 0.0.0.0
        interface: 127.0.0.1
        interface: 10.66.0.1
        port: 53
    
        # default-deny: refuse the internet, then allow what you trust
        access-control: 0.0.0.0/0 refuse
        access-control: ::/0 refuse
        access-control: 127.0.0.0/8 allow
        access-control: 10.66.0.0/24 allow
    
        # recurse from the root and send each server only the label it needs
        qname-minimisation: yes
        harden-dnssec-stripped: yes
        harden-below-nxdomain: yes
        harden-glue: yes
        aggressive-nsec: yes
        use-caps-for-id: yes
    
        # answer nothing about the software or the host
        hide-identity: yes
        hide-version: yes
    
        # keep no query log, and say little to the journal
        verbosity: 0
        log-queries: no
        log-replies: no
    
        # a warm cache is an upstream observation that never happens
        cache-min-ttl: 120
        cache-max-ttl: 86400
        prefetch: yes
        prefetch-key: yes
        serve-expired: yes
    
        # belt and braces if a rule above is ever loosened
        ratelimit: 1000
        ip-ratelimit: 100
    
        # sizing for a small instance
        num-threads: 2
        so-reuseport: yes
        msg-cache-size: 32m
        rrset-cache-size: 64m
    EOF

    注意这份文件里没有什么:没有 forward-zone。正是它的缺席,才让这是一个递归解析器,而不是架在别人解析器前面的一个缓存。如果你以后从某篇教程里粘贴了一段加上它的代码片段,你就已经悄悄把这整件事的意义给推翻了。

  4. 04

    启动它,然后证明 DNSSEC 真的在验证

    重启任何东西之前先检查语法——在一台自己的 resolv.conf 现在指向 Unbound 的机器上,一个配置错误就意味着你调试期间完全没法解析名字。

    sudo unbound-checkconf
    sudo systemctl enable --now unbound
    systemctl --no-pager status unbound | head -n 5

    现在来验证两个真正要紧的行为,因为一个会应答的解析器,跟一个会验证的解析器不是一回事。一个签过名的名字回来的时候必须带着 ad 标志——已认证数据——而一个签名被故意破坏掉的名字,必须以 SERVFAIL 失败关闭,而不是被解析出来。

    # should show: flags: qr rd ra ad
    dig @127.0.0.1 example.com +dnssec | grep -E '^;; flags'
    
    # should show: status: SERVFAIL  (not NOERROR, not an address)
    dig @127.0.0.1 dnssec-failed.org | grep -E '^;; ->>HEADER'
    
    # and a normal name should simply work
    dig @127.0.0.1 +short cloudflare.com

    如果那个坏掉的区解析出了一个地址,说明验证根本没在跑:检查 /var/lib/unbound/root.key 是否存在,并且能被 unbound 用户读取。如果所有东西都是 SERVFAIL,通常的原因是时钟严重不准——签名是有有效期窗口的,一台时间差了好几个小时的机器会拒绝整个互联网。

  5. 05

    关上公网的门,只给隧道留一个口子

    配置已经在拒绝陌生人了。这一步要做到让陌生人从一开始就没有任何东西可以碰到——这是两道锁里的第二道,也是那道能在第一道以后被改动时依然扛得住的一道。

    # DNS is reachable from the tunnel interface only
    sudo ufw allow in on wg0 to any port 53 proto udp
    sudo ufw allow in on wg0 to any port 53 proto tcp
    sudo ufw status verbose

    然后用唯一算数的方式来验证它,从另一台机器。从服务器本身测试什么都证明不了——回环地址是故意放行的。把隧道断开,从你的笔记本上跑一遍这个,或者从你手头任何别的机器上跑。

    # must time out. an answer here means you are running an open resolver.
    dig @SERVER_PUBLIC_IP example.com +time=3 +tries=1
    
    # same question over IPv6, which is the half people forget
    dig -6 @SERVER_IPV6 example.com +time=3 +tries=1

    如果这两个测试有任何一个返回了应答,现在就停下来把它修好,别等投诉报告找上门。常见的原因是某个打包配置文件里还留着一条 interface: 0.0.0.0,或者是早些时候某次试验留下的、全局放行 53 端口的防火墙规则,又或者是 Docker 发布了一个容器端口,把 ufw 整个绕了过去。

  6. 06

    让你的设备指向它

    在客户端这一侧,这只是改一行的事。在 WireGuard 客户端配置里,DNS 这一行从一个公共解析器变成你服务器的隧道地址——这一处改动,正是要把 DNS = 9.9.9.9WireGuard 指南里退休掉。

    [Interface]
    PrivateKey = <paste client.key>
    Address = 10.66.0.2/32, fd86:ea04:1115::2/128
    DNS = 10.66.0.1
    
    [Peer]
    PublicKey = <paste server.pub>
    Endpoint = YOUR_SERVER_IP:51820
    AllowedIPs = 0.0.0.0/0, ::/0
    PersistentKeepalive = 25

    把隧道连起来,然后从客户端确认两件各自独立的事:应答确实是从你的解析器那儿来的,而且递归查询确实是从你的 VPS 出去的,不是从别的什么地方。第二项检查才是真正有用的那个——whoami.akamai.net 会返回不管是哪个解析器发问、它自己的地址,所以它打印出来的应该就是你服务器的公网 IP,别的什么都不该有。

    # answers should come from the tunnel address
    dig example.com | grep -E 'SERVER:'
    
    # should print your VPS public IP — this is the leak test
    dig +short whoami.akamai.net
    
    # and the ad flag should still be there, end to end
    dig example.com +dnssec | grep -E '^;; flags'
  7. 07

    只为没法维持隧道的设备加上 DNS-over-TLS

    除非你有一台特定的设备——通常是一台 Android 手机,通过它系统级的私人 DNS 设置——想在不维持一条常驻隧道的情况下覆盖到它,否则就跳过这一步。这需要一个主机名和一张证书,而那个主机名会在证书透明度日志里变成一条永久的公开记录,所以要挑一个跟你其他身份扯不上任何关系的名字。

    sudo apt install -y certbot
    sudo ufw allow 80/tcp comment 'certbot, temporarily'
    sudo certbot certonly --standalone -d dns.example.net
    sudo ufw delete allow 80/tcp
    
    # unbound must be able to read the key
    sudo usermod -a -G ssl-cert unbound 2>/dev/null || true
    sudo chgrp -R ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive
    sudo chmod -R g+rX /etc/letsencrypt/live /etc/letsencrypt/archive

    把这个 TLS 监听器加成它自己独立的一份 drop-in 文件,这样如果你改变主意,一步就能把它删掉。

    # NOTE: drop-in files are read in alphabetical order and the last
    # access-control line for a given prefix wins — so this file must
    # sort AFTER private-resolver.conf, or its refuse rule overrides
    # the allow below and every DoT client gets REFUSED.
    sudo tee /etc/unbound/unbound.conf.d/zz-dot.conf > /dev/null <<'EOF'
    server:
        interface: 0.0.0.0@853
        interface: ::0@853
        tls-service-key: "/etc/letsencrypt/live/dns.example.net/privkey.pem"
        tls-service-pem: "/etc/letsencrypt/live/dns.example.net/fullchain.pem"
        # roaming clients have no fixed address, so this endpoint must accept any.
        # safe only because port 53 stays bound to loopback + wg0 and blocked at
        # the firewall — TLS over TCP cannot be spoofed, so it cannot be reflected.
        access-control: 0.0.0.0/0 allow
        access-control: ::/0 allow
    EOF
    sudo ufw allow 853/tcp
    sudo unbound-checkconf && sudo systemctl restart unbound

    先在隧道外面测试一下,确认没问题了再信任它,然后把这个主机名填进手机的私人 DNS 字段。证书续期是那个会在三个月后悄悄坏掉的环节——certbot 会换上新证书,但 Unbound 会把旧的那张继续留在内存里,所以要加一个部署钩子来重新加载它。

    kdig -d @dns.example.net +tls-ca +tls-host=dns.example.net example.com
    
    echo 'systemctl reload unbound' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/unbound.sh
    sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/unbound.sh
SP·10 — 常见问题

快速解答

我自己的解析器比 1.1.1.1 快还是慢?

两者都对,只是发生在不同的时刻。对一个你网络里从没人访问过的域名来说,一次冷查询会更慢,因为完整的递归意味着要先问根,再问顶级域,再问权威服务器,而一个公共解析器通常是从一个被几百万人用热的缓存里直接给你答案。而一次热查询比任何公共解析器都要快,因为这个缓存就在你自己隧道的另一端,不用跨越整个互联网。在日常使用里,热查询才是主流:一户人家会反复访问那么几百个域名,prefetch 会在这些热门域名过期之前把它们续上,命中率会在一天之内就爬上去。如果第一次访问一个陌生网站时那一点点变慢的感觉,比这份日志更让你在意,那这笔交易对你就是错的,你应该改用转发。

这样做能不能阻止我的 ISP 看到我访问了哪些网站?

单靠它自己不行。它只有在这些查询是在隧道内部传输的情况下,才能阻止你的 ISP 读到你的查询内容——否则你发往 VPS 的这些查询在你 ISP 的网络上依然是明文的 UDP,你只是换了个目的地,什么都没藏住。即便有了隧道,你的 ISP 依然能看到一个通往某个地址的加密会话,而如果你在隧道之外浏览,它依然能看到目的地 IP,并且在没有 Encrypted Client Hello 的情况下,还能看到 TLS 握手里的服务器名称。把私有解析器看作是负责关闭命名这条通道的组件,把隧道看作是负责关闭传输这条通道的组件。两者谁也替代不了谁。

我是不是干脆通过 DoT 转发给 Quad9 或者 Cloudflare 就好了?

这是一个正当的选择,对很多人来说也是对的选择。通过 DoT 转发更简单,配起来更快,能给你一大群人当掩护,也能打败大多数人实际面对的那个观察者——本地网络和 ISP。它做不到的是消除汇总:还是有一个机构能收到你完整的查询流,现在还整整齐齐地贴上了单一来源地址的标签。当你的威胁模型是咖啡馆 Wi-Fi 和你的 ISP 时,选转发;当你反对的恰恰是汇总这件事本身,或者你不想再去决定该信哪家公司的留存政策时,选递归。

我的托管服务商能读到我的 DNS 查询吗?

能,这一点最好想得清楚一点。发往根、顶级域和权威服务器的递归查询,会以未加密的方式经 UDP 53 端口离开这台 VPS——到根服务器没有 DoT。任何能看到这台服务器上行流量的人都能读到它们。QNAME 最小化意味着每一段单独的对话只会露出一个片段,而且它们跟这台机器做的其他一切事情混在一起,但它们并没有被隐藏起来。你做的事情,是把这个观察者从一个依法必须留存数据、又对这份数据有商业利益的、你本国的消费级 ISP,挪到了一个你自己挑的托管网络。这就把问题变成了选哪个司法辖区、哪家服务商的问题,而不是一个配置文件能回答的问题。

这样做需要一个域名吗?

如果你的设备是通过隧道去连解析器,就不需要——这也是更该选这条路线的主要现实理由:不需要主机名,不需要证书,也不会留下任何证明这个服务存在的公开记录。你只有在用 DNS-over-TLS 时才需要一个域名,因为客户端要拿一个名字去验证证书。要清楚,拿到那张证书会把这个主机名永久发布进证书透明度日志,而不久之后,被动 DNS 就会把它和服务器的地址绑到一起——所以不要用一个已经指向你的域名下的子域名。匿名注册域名那篇讲了你这么做时还会泄露些什么。

这样能拦住广告和追踪器吗?

默认不能——一个原生的 Unbound 只解析名字,不会过滤它们。你可以在它上面另外加黑名单或者本地区域,而在移动设备上,这是唯一一层还能碰到应用流量的地方。但预期要放实际一点:硬编码了解析器 IP、或者内置了 DoH 客户端的应用,根本不会问你的解析器任何问题,浏览器也越来越多地走自己的加密传输,而一份很大的第三方黑名单,总有一天会在你早已忘记装过它的某一天弄坏点什么东西。它能去掉一些低成本的跟踪噪音。它不是一项安全控制手段,任何存心作对的东西天生就是绕着它设计的。

付诸实践

VPS 在 15 min 内上线,独立服务器在 2–12 h 内交付。从 $30.00 起用加密货币充值——不绑定任何身份。

部署一台 VPS