自建 DoH 服务器保护隐私:mosdns + unbound 从零到上线

AI 摘要997笔记AI

文章介绍用闲置云服务器自建私人DoH的完整方案:nginx按SNI分流并终结TLS,mosdns负责缓存与主备兜底,unbound从根递归,查询内容不经过商业DNS。适合有域名和服务器、重视查询隐私的人,整套内存占用低于200MB,实测单次查询约70毫秒。

此内容由AI根据文章内容自动生成。

公共 DoH 服务解决了「查询不被中间人看到」,但没解决「谁看到了你的查询」。223.5.5.5 也好、1.1.1.1 也好,你的每一次域名解析都完整落在别人的日志里——访问什么网站、几点访问、哪个 App 在后台偷偷解析,一目了然。想彻底拿回来,只能自己搭一台。

这篇是把一台云服务器改成私人 DoH 的完整过程:nginx 在 443 做 SNI 分流、8443 终止 TLS、mosdns 负责缓存和分流、unbound 从根自己递归。全程不用任何第三方 DNS 作为主上游,只有主上游挂了才降级到公共 DoH 兜底。如果你只是想给自己的网络换一个更快的加密 DNS,站点里 DoH/DoT 实测:13 家公共加密 DNS 延迟对比 已经量过了,不用自己搭;只有在意「查询不出门」才值得动手。

这篇适合谁

有一台闲置云服务器(1 核 1G 也能跑)、有自己的域名、愿意花半小时配 nginx 的人。整套东西跑起来占内存不到 200MB,单次查询实测 70ms 往返。

一、为什么自己搭 DoH

先把「加密」和「隐私」分开看。DoH 只保证你到 DNS 服务器这一段是加密的,中间人看不到、运营商改不了。但服务器那一端仍然完整知道你在查什么。所以选公共 DoH,等于把「谁都能看」换成「某一家能看」——比明文好,但不彻底。

自己搭之后,链路变成这样:你 → 你的服务器(只记连接元数据,不记域名)→ 自己的递归器 → 根/权威服务器。中间没有任何商业 DNS 参与,查询内容不出你这台机器。

维度 公共 DoH 自建 DoH
链路加密 有 有
查询内容给谁看 服务商 只有自己的机器
污染 / 劫持 取决于服务商 自己递归,不依赖上游
延迟 就近节点,通常更低 取决于服务器位置,冷查询 100ms 起
维护成本 零 一台机器 + 一张证书

二、四层链路,各管一段

别看组件多,每个只干一件事,职责划清楚之后调试很快:

  • nginx stream(443):拿到 TLS 握手里的 SNI,判断这是不是自己的域名,然后转给对应后端。这一层不做加解密,只做分流。
  • nginx http(8443):真正终止 TLS,只放行一个随机路径,其余一律 404。
  • mosdns(8053):缓存 + 主备兜底 + 提供 http_server 这个 DoH 入口。
  • unbound(5354):真正的递归器,从根开始自己走,不转发给任何人。
自建 DoH 的四层链路:nginx 443 分流、8443 终止 TLS、mosdns 8053、unbound 5354 递归
一条 DoH 查询的实际路径:443 分流 → 8443 终结 TLS → 8053 分流缓存 → 5354 递归

为什么 443 不能直接跑 DoH?因为同一台机器上 443 往往已经被 xray、面板、反代占着。nginx 的 stream 模块可以在不解密的情况下按 SNI 把连接分给不同后端,这是唯一能「一个 443 端口同时服务多个 HTTPS 站点」的做法。

顺带解释一个常见疑问:unbound 做不了 DoH 服务端。它从 1.12 起能对外提供 DoT,但没有 HTTP 层,所以 DoH 必须由前面的 nginx(或 dnsdist、caddy)来终结 TLS 和 HTTP/2,unbound 只负责递归这一件事。这也是为什么链路里必须多一个 nginx。

三、准备:机器、域名、证书

1. 机器与系统

实测环境是一台 2 核 4G 的腾讯云轻量,Debian 13,跑完 nginx + mosdns + unbound 内存占用不到 200MB,1 核 1G 同样够用。装基础组件:

apt update
apt install -y nginx unbound curl dnsutils openssl

# mosdns 用官方二进制,别用源里的旧版本
cd /tmp
wget https://github.com/IrineSistiana/mosdns/releases/latest/download/mosdns-linux-amd64.zip
unzip mosdns-linux-amd64.zip -d /usr/local/bin/
chmod +x /usr/local/bin/mosdns
/usr/local/bin/mosdns version

2. 证书:DNS-01 签发,不占 80 端口

DoH 的证书用 acme.sh 走 DNS-01 验证,好处是完全不依赖 80 端口,也能签泛域名。以阿里云 DNS 为例:

curl https://get.acme.sh | sh -s email=you@example.com

export Ali_Key="你的AccessKeyId"
export Ali_Secret="你的AccessKeySecret"

~/.acme.sh/acme.sh --issue --dns dns_ali -d doh.example.com

# 装到 nginx 目录,并让续期后自动 reload
~/.acme.sh/acme.sh --install-cert -d doh.example.com 
  --key-file       /etc/nginx/ssl/doh.example.com.key 
  --fullchain-file /etc/nginx/ssl/doh.example.com.fullchain.cer 
  --reloadcmd      "systemctl reload nginx"

acme.sh 会自动装一条 cron 做续期检查。默认是每天一次,想更勤可以改成每 6 小时,Let’s Encrypt 有 30 天宽限期,怎么都够:

crontab -l | grep acme
# 46 3,9,15,21 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null

AccessKey 别用主账号

acme.sh 的 DNS-01 需要能改 DNS 记录的密钥,一定建 RAM 子账号、只授 DNS 解析权限。这个密钥会明文存在 ~/.acme.sh/account.conf 里,服务器被拿下就等于域名被拿下。

四、先让 unbound 跑通递归

从下往上装,每一层单独验证,出问题好定位。unbound 只监听回环,绝不对外开 53:

cat > /etc/unbound/unbound.conf.d/doh.conf <<'EOF'
server:
    interface: 127.0.0.1
    port: 5354
    do-ip4: yes
    do-ip6: yes
    do-udp: yes
    do-tcp: yes
    num-threads: 2

    # 缓存
    msg-cache-size: 64m
    rrset-cache-size: 128m
    cache-min-ttl: 600
    cache-max-ttl: 86400

    # 关键两项:过期先秒回旧值,后台再刷新
    serve-expired: yes
    serve-expired-client-timeout: 1800
    prefetch: yes
    prefetch-key: yes

    qname-minimisation: yes
    verbosity: 1
    log-queries: no
EOF
systemctl restart unbound
dig @127.0.0.1 -p 5354 www.baidu.com +short

能返回 A 记录就说明递归通了。cache-min-ttl: 600 和 serve-expired-client-timeout: 1800 这两行是后面性能调优的核心——前者把 TTL 抬到 10 分钟起步,后者让过期条目先秒回旧值、后台再刷新,把「TTL 到期就得干等」变成「0.1ms 拿到旧值」。

五、mosdns:缓存、兜底和 DoH 入口

mosdns 在这套里干三件事:第一层缓存、主备兜底、把 DoH 入口暴露出来。配置文件 /etc/mosdns/config.yaml:

log:
  level: warn
  file: ""

plugins:
  # 第一层缓存:6.5 万条,过期后还能兜底 1 天
  - tag: cache
    type: cache
    args:
      size: 65536
      lazy_cache_ttl: 86400

  # 主上游:本机递归,不经过任何第三方
  - tag: forward_primary
    type: forward
    args:
      concurrent: 1
      upstreams:
        - addr: "127.0.0.1:5354"

  # 备用:只在主上游超时/失败时才启用
  - tag: forward_backup
    type: forward
    args:
      concurrent: 1
      upstreams:
        - addr: "https://dns.alidns.com/dns-query"
          bootstrap: 223.5.5.5
          enable_pipeline: true

  - tag: forward_cn
    type: fallback
    args:
      primary: forward_primary
      secondary: forward_backup
      threshold: 1500

  - tag: main
    type: sequence
    args:
      - exec: $cache
      - exec: $forward_cn

  # DoH 入口
  - tag: http_server
    type: http_server
    args:
      listen: "127.0.0.1:8053"
      entries:
        - path: /dns-query-请换成你的随机串
          exec: main

两个坑,都踩过

① http_server 里 exec 后面跟的是裸 tag(main),不加 $。$ 是 sequence 插件里引用插件的语法,混用会直接启动失败。
② always_standby 默认 false 才对。设成 true 会让备用上游每次都收到查询,等于把隐私又送出去一半,只剩下「主上游先返回」这点好处。

要不要再做国内外分流

这台机器的定位是纯递归:国内国外都交给 unbound 自己走,不依赖任何上游,所以配置里没有 geosite 判断。如果你希望国内走递归、国外走代理,就在 main 的 sequence 里加一条 matches: qname $geosite_cn 拆成两条链路——代价是要维护 geosite 数据,而且国外域名就离开这台机器了。

# 启动服务
cat > /etc/systemd/system/mosdns.service <<'EOF'
[Unit]
Description=mosdns
After=network.target

[Service]
ExecStart=/usr/local/bin/mosdns start -c /etc/mosdns/config.yaml -d /etc/mosdns
Restart=always
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now mosdns
ss -lntp | grep 8053

本机验证 DoH 后端(还没到 nginx 那层):

curl -s -o /dev/null -w "%{http_code}n" 
  -X POST -H "Content-Type: application/dns-message" 
  --data-binary @query.bin 
  http://127.0.0.1:8053/dns-query-你的随机串

六、nginx 两层:443 分流与 TLS 终结

这是整套配置里最容易出错的一段,因为它要同时满足两件事:不抢占已有的 443、又能让 DoH 走标准 HTTPS。

DoH 四个组件对应的端口与配置文件对照
四个组件对应四个端口、四个配置文件,改哪个一目了然

1. stream 层:按 SNI 分流

/etc/nginx/nginx.conf 里确保有 stream 段的 include,然后建 /etc/nginx/stream.conf:

stream {
    # 按 SNI 决定后端:自己的域名进 DoH,其余照旧
    map $ssl_preread_server_name $backend {
        default           127.0.0.1:8443;   # 换成你原来的后端(xray / 站点)
        doh.example.com   127.0.0.1:8443;   # DoH 走这里
    }

    log_format stream_wl '$remote_addr [$time_local] sni=$ssl_preread_server_name => $backend';
    access_log /var/log/nginx/stream443.log stream_wl;

    server {
        listen 443;
        listen [::]:443;
        ssl_preread on;          # 不解密,只读 SNI
        proxy_protocol on;       # 把真实客户端 IP 传给后端
        proxy_pass $backend;
        proxy_connect_timeout 10s;
        proxy_timeout 3600s;
    }
}

proxy_protocol on 这一行决定了后面能不能拿到真实 IP。开了它,后端 listen 必须写 proxy_protocol,否则 nginx 会解析失败、连接直接 400。

2. http 层:TLS 终结 + 只放行一个路径

路径里的随机串用 openssl rand -hex 8 生成一个就行,十六位十六进制,撞不上,也念不出来。每次改完记得同步到 mosdns 的 http_server 和客户端配置里——三处必须完全一致,差一个字符就是 404。

/etc/nginx/conf.d/doh.conf,完整可用:

server {
    listen 127.0.0.1:8443 ssl proxy_protocol;
    listen [::1]:8443 ssl proxy_protocol;
    server_name doh.example.com;

    ssl_certificate     /etc/nginx/ssl/doh.example.com.fullchain.cer;
    ssl_certificate_key /etc/nginx/ssl/doh.example.com.key;
    ssl_protocols TLSv1.3;
    add_header Strict-Transport-Security "max-age=31536000" always;

    http2 on;

    access_log /var/log/nginx/doh.log doh_min;
    error_log  /var/log/nginx/doh_error.log;

    # DoH 请求体极小:GET 无体,POST 最多几百字节
    client_max_body_size 8k;

    location = /dns-query-你的随机串 {
        limit_req  zone=doh burst=40 nodelay;
        limit_conn dohconn 20;

        proxy_pass http://127.0.0.1:8053;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;

        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
    }

    location / {
        return 404;
    }
}

几个刻意的选择:

  • location = 精确匹配 + 随机串,扫描器枚举 /dns-query 只会拿到 404
  • ssl_protocols TLSv1.3 只留一个版本,握手快、没有降级空间
  • proxy_request_buffering off:DoH 是小包交互,别让 nginx 攒够再转
  • location / 直接 404,不返回任何指纹信息

七、限流与隐私:日志别写查询内容

/etc/nginx/conf.d/00-doh-limit.conf,文件名用 00- 保证先加载,limit 的 zone 才能被引用:

limit_req_zone  $binary_remote_addr zone=doh:10m     rate=30r/s;
limit_conn_zone $binary_remote_addr zone=dohconn:10m;

# 不含 $request —— 避免把 ?dns= 里的查询内容写盘
log_format doh_min "$time_iso8601 $remote_addr $status $body_bytes_sent $request_time";

这个日志格式是全套里最容易被忽略、又最要紧的一行。默认的 combined 格式会记 $request,而 DoH 的 GET 请求把整个 DNS 查询 base64 编码后放在 ?dns= 里——等于把「谁查了什么域名」逐条写进磁盘,还保留 14 天。改成只记 5 个字段之后:

记什么 能推断出什么
时间、客户端 IP、状态码、字节数、耗时 谁在什么时候用过、用了多久、有没有报错
不记 request / 不记 body 查了哪个域名,日志里查不到

真实的一行长这样,只有 5 段:

2026-10-30T23:25:46+08:00 203.0.113.10 200 223 0.000

再配上 mosdns log.level: warn(正常查询不输出)和 unbound log-queries: no,三层都不落域名。真要排查某个域名,临时开 log-queries: yes,查完记得关。

八、客户端怎么接进来

各家对「自定义 DoH」的支持差别很大,这一节别照抄,按平台看:

平台 能不能用 怎么做
路由器 / 软路由(mosdns、AdGuard Home、iStoreOS) 最好用 上游直接填 https://doh.example.com/dns-query-随机串,全屋设备自动生效
Windows 11 可以,需命令行注册模板 netsh dns add encryption server=1.1.1.1 dohtemplate=https://doh.example.com/dns-query-随机串 autoupgrade=yes,然后在网络设置里选「加密(仅 DoH)」
iOS / iPadOS 要描述文件或 App 系统不开放手填 URL,用 .mobileconfig 描述文件或 DNSecure 这类客户端
Android 系统级只支持 DoT 「私人 DNS」填的是域名不是 URL;DoH 只能走 App 或浏览器自带安全 DNS
Chrome / Edge 只认内置列表 自定义 DoH 一般填不进去,别在这上面浪费时间

最省事的做法是只配路由器一层:家里所有设备(含手机连 Wi-Fi)都自动走你的 DoH,出门再用手机关掉或换回公共服务。这样只需要维护一处配置。

九、验证与排障

1. 用 curl 发一个真实查询

DoH 的报文是二进制,先造一个最小的 A 记录查询(www.baidu.com),再 POST 上去:

printf 'x00x00x01x00x00x01x00x00x00x00x00x00x03wwwx05baidux03comx00x00x01x00x01' > /tmp/q.bin

curl -s -o /tmp/r.bin -w "HTTP %{http_code}  %{size_download}B  %{time_total}sn" 
  -X POST -H "Content-Type: application/dns-message" 
  --data-binary @/tmp/q.bin 
  "https://doh.example.com/dns-query-你的随机串"

xxd /tmp/r.bin | head -3

返回 HTTP 200、一百来字节、能看到 www.baidu.com 的 CNAME 和 A 记录,就说明整条链路通了。实测一次往返 70ms(含 TLS 握手复用)。

2. 常见错误码对照

现象 原因 怎么改
404 路径不匹配,或 SNI 没命中 server_name 核对 location = 的路径;curl -v 看 SNI 是不是 doh.example.com
400 Bad Request stream 开了 proxy_protocol on,8443 的 listen 没写 proxy_protocol 两边必须成对
502 mosdns 没起,或 8053 不通 ss -lntp | grep 8053,看 journalctl
证书告警 证书域名与 SNI 不一致 证书必须签在 DoH 那个域名上,不是主域名
日志里客户端 IP 全是 127.0.0.1 没配 set_real_ip_from / real_ip_header proxy_protocol 补上真实 IP 还原
nginx 起不来,报 address in use stream 和 http 同时抢 443 443 只能给 stream,http 挪到 8443

十、性能实测:为什么快要先命中缓存

上线之后最容易被质疑的是「为什么比阿里云慢」。先看数据,同一台机器上不同场景差了三个数量级:

DoH 延迟对比:缓存命中 0.087ms,unbound 冷启动 111~1240ms
同一个递归器,缓存命中 0.087ms,冷启动 1240ms —— 讨论「快慢」必须先说清是哪种场景

几个结论:

  • 命中缓存时比公共 DNS 还快:本地缓存 0.087ms,阿里 13.57ms,差两个数量级,因为不用出网。
  • 全新域名的冷迭代并不慢:delegation 已缓存的情况下 4~60ms,和公共 DNS 的 40~88ms 持平甚至更快。
  • 慢的是长尾:某个权威服务器响应慢,或者某个 NS 不应答要等超时,才会出现几百毫秒。unbound 自报的中位数 38ms、平均 185ms,就是长尾拉高的典型形态。
  • 刚启动时最慢:进程刚起、缓存全空,第一个查询要从根开始走,111~1240ms 都正常。这也是为什么要做预热。

所以「感觉慢」的调优顺序应该是:先把 serve-expired-client-timeout 打开(过期先秒回),再把 cache-min-ttl 抬到 600,最后才考虑预热清单。反过来做没有意义——TTL 只有 300~600 秒,凌晨预热一次,早上八点早就过期了。

十一、装完之后

到这一步你有一台只属于自己的加密 DNS:查询内容不出机器、路径不可枚举、限流挡住滥用、日志不落域名。剩下几件事值得接着做:

最后一个提醒:自建 DoH 的隐私收益,完全建立在「日志真的不记」这件事上。配置写对了不等于日志就干净——nginx、mosdns、unbound、journald 四处都得看一遍,系统日志轮转默认只留两周,也不代表内容安全。真要较真,就把日志目录整个挂到 tmpfs 上,重启即清。

上一篇 iStoreOS 安装 DDNS-GO:动态 IP 也能远程访问家里设备