加密 DNS 部署的核心,是把明文 53 端口的解析请求搬进 TLS 隧道,让链路中间的运营商设备、公共 Wi-Fi 网关与旁路探针既读不到查询的域名,也改不了应答结果。本文给出 DoT(DNS over TLS)与 DoH(DNS over HTTPS)两种形态的完整落地步骤:证书签发、Unbound 开放 DoT、dnsdist 提供 DoH、客户端强制加密、明文端口收敛与逐项验证。
适用场景
- 企业内网或自建机房的递归解析器,需要防止运营商 DNS 劫持、旁路设备投毒
- 员工终端在外部网络使用公共 DNS,查询内容(访问了哪些域名)会暴露给链路上的任何中间人
- 合规审计要求”域名查询内容不得以明文形式传输”
- 已有 Unbound 或 BIND 递归服务,希望在不停机的前提下叠加加密入口,而不是重建解析体系
前置条件
- 一台具备固定地址的 Linux 服务器,示例环境为 Debian 12 / Ubuntu 22.04
- 已安装 unbound 1.19 以上与 dnsdist 1.8 以上
- 一个可签发证书的域名,例如
dns.example.com,A/AAAA 记录已指向该服务器 - 853(DoT)与 443(DoH)端口可用;若 443 已被 Web 服务占用,DoH 需绑定独立地址,或由前置 Nginx 按路径反代
- root 或 sudo 权限,且服务器时间已同步(DoT 校验证书有效期,时间偏差过大必然握手失败)
原理说明
DNS 协议本身没有加密设计,53 端口上的查询与应答都是明文 UDP/TCP。运营商可以返回广告页、旁路设备可以伪造 A 记录、公共 Wi-Fi 可以把你导向钓鱼站,而客户端完全无法察觉。加密 DNS 就是在 DNS 报文外面套一层 TLS:DoT 使用专用 853 端口,DoH 复用 443 端口并把 DNS 报文封装成 HTTP 请求体。
DoT 与 DoH 的差别
- DoT:853/tcp,TLS 直连,开销小、延迟低,网络设备一眼就能识别出这是加密 DNS,因此可被针对性阻断
- DoH:443/tcp,走标准 HTTPS,与普通网页流量混在一起,难以按协议特征识别,但多一层 HTTP 解析开销
- 客户端支持:Android 9+、systemd-resolved、Chrome/Firefox 均原生支持;老系统只能靠本地转发器
三个容易混淆的概念
DNSSEC 解决的是”应答是否被伪造”,通过签名链验证数据完整性;DoT/DoH 解决的是”查询过程是否被看见”,通过加密保护传输机密性。两者互补,不能互相替代——只做 DNSSEC 依然会泄露你在查哪些域名,只做 DoT 依然可能收到上游伪造的应答。
操作步骤
步骤一:签发并部署服务器证书
证书的 SAN 必须与客户端配置的主机名完全一致,否则严格校验的客户端会直接拒绝连接。用 Let’s Encrypt 签发后复制到 Unbound 可读目录:
certbot certonly --standalone -d dns.example.com --agree-tos -m ops@example.com
install -d -m 750 /etc/unbound/certs
cp /etc/letsencrypt/live/dns.example.com/fullchain.pem /etc/unbound/certs/dns.crt
cp /etc/letsencrypt/live/dns.example.com/privkey.pem /etc/unbound/certs/dns.key
chown -R unbound:unbound /etc/unbound/certs
chmod 640 /etc/unbound/certs/dns.key
证书 90 天到期,必须配置续期后自动重载,否则到期当天全域解析中断:
cat >/etc/letsencrypt/renewal-hooks/deploy/unbound-reload.sh <<'EOF'
#!/bin/sh
cp /etc/letsencrypt/live/dns.example.com/fullchain.pem /etc/unbound/certs/dns.crt
cp /etc/letsencrypt/live/dns.example.com/privkey.pem /etc/unbound/certs/dns.key
chown unbound:unbound /etc/unbound/certs/*
systemctl reload unbound
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/unbound-reload.sh
步骤二:Unbound 开放 DoT 入口
关键在四个参数:tls-service-key 与 tls-service-pem 指定证书私钥,interface 0.0.0.0@853 声明加密监听,access-control 收敛允许来源。同时关闭版本与身份泄露:
# /etc/unbound/unbound.conf.d/dot.conf
server:
interface: 127.0.0.1@53
interface: 0.0.0.0@853
tls-service-key: "/etc/unbound/certs/dns.key"
tls-service-pem: "/etc/unbound/certs/dns.crt"
tls-port: 853
access-control: 0.0.0.0/0 refuse
access-control: 127.0.0.0/8 allow
access-control: 10.0.0.0/8 allow
access-control: 192.168.0.0/16 allow
hide-identity: yes
hide-version: yes
qname-minimisation: yes
aggressive-nsec: yes
prefetch: yes
unbound-checkconf && systemctl restart unbound
ss -lntp | grep -E ':53|:853'
注意这里刻意只让 127.0.0.1 监听明文 53,对外只暴露 853。明文入口留在本机供 dnsdist 与本地应用使用,外网无法再用 53 明文查询。
步骤三:dnsdist 提供 DoH 入口
Unbound 自身不带 HTTP 层,DoH 需要 dnsdist 做协议转换,把 HTTPS 请求解出 DNS 报文后转给本机 Unbound。配置写在 /etc/dnsdist/dnsdist.conf:
-- /etc/dnsdist/dnsdist.conf
newServer({address="127.0.0.1:53", name="local-unbound", checkInterval=3})
setACL({"127.0.0.0/8", "10.0.0.0/8", "192.168.0.0/16"})
setLocal("0.0.0.0:443")
addDOHLocal("0.0.0.0:443",
"/etc/dnsdist/certs/dns.crt",
"/etc/dnsdist/certs/dns.key",
{"/dns-query"},
{minTLSVersion="tls1.2", maxConcurrentTCPConnections=0})
-- 只允许 GET/POST 的 /dns-query,其余路径直接拒绝
addAction(NotRule(HTTPPathRule("/dns-query")), RCodeAction(DNSRCode.REFUSED))
addCacheHitResponseAction(SetECSAction("0.0.0.0/0"))
dnsdist --check-config
systemctl restart dnsdist
ss -lntp | grep dnsdist
步骤四:客户端强制加密解析
systemd-resolved 支持 DoT,配置里 DNSOverTLS=yes 表示”不允许回退到明文”,一旦加密握手失败就解析失败而不是悄悄降级——这正是防降级的关键。DNS= 后面的 #主机名 用于证书校验,缺少它 resolved 会按 IP 校验并握手失败:
# /etc/systemd/resolved.conf.d/dot.conf
[Resolve]
DNS=10.0.0.53#dns.example.com
FallbackDNS=
DNSOverTLS=yes
DNSSEC=allow-downgrade
Domains=~.
Cache=yes
CacheFromLocalhost=no
systemctl restart systemd-resolved
resolvectl status | grep -E 'DNS Servers|DNS Domain|DNSOverTLS|\+DNSOverTLS'
Windows 客户端用组策略或注册表开启 DoH 模板,浏览器侧可单独指定 DoH 地址 https://dns.example.com/dns-query,并在策略中关闭”允许回退到不安全 DNS”。
步骤五:收敛明文端口与降级路径
加密做得再好,只要 53 端口还对外开放,攻击者与老旧客户端就会自动回落到明文。用 nftables 只放行本机与内网,并对公网 53 直接丢弃:
nft add table inet filter
nft add chain inet filter input '{ type filter hook input priority 0; policy drop; }'
nft add rule inet filter input ct state established,related accept
nft add rule inet filter input iif lo accept
nft add rule inet filter input tcp dport { 22, 443, 853 } accept
nft add rule inet filter input ip saddr 10.0.0.0/8 udp dport 53 accept
nft add rule inet filter input ip saddr 10.0.0.0/8 tcp dport 53 accept
nft list ruleset > /etc/nftables.conf
配置验证
# 1. DoT 握手与解析是否正常(knot-dnsutils 提供 kdig)
kdig -d @dns.example.com +tls-ca +tls-hostname=dns.example.com example.com A
# 2. 证书有效期与链是否完整
openssl s_client -connect dns.example.com:853 -servername dns.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -dates -ext subjectAltName
# 3. DoH JSON 接口是否可用
curl -sS -H 'accept: application/dns-json' \
'https://dns.example.com/dns-query?name=example.com&type=A' | head -c 400
# 4. 明文 53 是否已对外关闭(应超时,而不是返回结果)
dig @dns.example.com example.com +time=3 +tries=1
# 5. 确认本地解析走的是加密通道
resolvectl query example.com
resolvectl statistics | grep -i tls
判定标准很明确:第 1、3 条返回正常应答,第 2 条能看到 subjectAltName 含 dns.example.com,第 4 条超时无输出,第 5 条显示已通过 TLS 连接。
常见问题
Q1:DoT/DoH 能替代 DNSSEC 吗?
不能。加密只保证”这条隧道没被偷看、没被中间人改写”,但如果上游递归服务器本身被投毒,或者权威服务器返回了伪造记录,加密隧道会把伪造结果原样送达客户端。正确做法是两者叠加:DoT/DoH 保护传输过程,DNSSEC 验证数据来源。客户端把 DNSSEC=yes 设为强制后,未签名域将直接解析失败,需要评估业务容忍度,一般先用 allow-downgrade 过渡。
Q2:开启加密解析后内网域名全部解析不了怎么办?
根因是 Domains=~. 把全部查询都推给了加密解析器,而内网域名(如 git.corp.local)只有内部 DNS 才知道。解决方式是做拆分解析:为内网域单独指定明文内网 DNS,其余走加密通道。
# /etc/systemd/resolved.conf.d/dot.conf
[Resolve]
DNS=10.0.0.53#dns.example.com 10.10.0.2
Domains=~. ~corp.local
DNSOverTLS=yes
DNSOverTLS=opportunistic
实际生效需要把内网域从主 DNS 中剥离:给 10.10.0.2 单独配置 Domains=~corp.local,主解析器保留 Domains=~.。配置后务必用 resolvectl query git.corp.local 与 resolvectl query example.com 分别确认两条路径都通。
Q3:DoH 走 443,怎么防止它被当成隧道绕过上网管控?
DoH 的隐蔽性对防御方同样是问题。可行手段有三条:一是在出口只允许访问白名单内的加密解析器地址,其余 443 目标按 SNI 与应用识别审计;二是终端侧下发解析策略,禁止用户自行修改 DoH 服务器;三是在防火墙记录 TLS 握手 SNI,对异常高频的 /dns-query 目标做告警。需要注意的是,DoH 流量无法通过端口与协议特征区分,任何声称”能精准识别 DoH”的方案都要用真实流量复核。
Q4:Unbound 启动报错 cannot load private key 怎么处理?
绝大多数是权限问题。Unbound 以 unbound 用户运行,私钥权限必须是 640 且属主为 unbound:unbound;同时 /etc/unbound/certs 目录要有执行权限(750),否则子进程无法进入目录读取文件。若使用 certbot 续期后重载失败,检查是否忘了在 deploy hook 里重新 chown。
总结
加密 DNS 部署不是装一个软件就完事,它由五个环节组成:可信证书、加密监听、协议转换、客户端强制、明文收敛。缺少任何一环,实际流量都会从最薄弱的那个环节漏出去。落地顺序建议先在测试网段完成 Unbound + dnsdist 双栈验证,再分批切换客户端,最后关闭公网 53 并观察一周解析失败率。
- 证书 SAN 必须与客户端配置的主机名一致,续期 hook 必须自动重载
- Unbound 只对本地监听明文 53,对外仅开 853
- 客户端 DNSOverTLS=yes 才能防降级,配
#hostname才能通过证书校验 - 内网域必须做拆分解析,否则加密改造会直接打挂内部系统
- 加密与 DNSSEC 是两条独立防线,叠加才有意义