用 mosdns 搭建一套自己的 DNS 分流系统:去广告、加速解析、支持加密查询

用 mosdns 搭建一套自己的 DNS 分流系统:去广告、加速解析、支持加密查询

起因

家里的网络设备越来越多,DNS 请求全都发往运营商或者公共 DNS。这样有几个问题:

  • 广告域名解析出来了,浏览器该加载的广告一个不少;
  • 国内域名和国外域名混在一起解析,有时候国外网站慢得离谱;
  • 运营商能看到你访问了哪些域名,隐私上不太舒服。

于是想着自己搭一套 DNS 服务,放在内网做统一入口。试了几个方案之后,最后选了 mosdns。

这篇文章记录一下完整的搭建过程和踩过的坑,方便以后重装时参考。

为什么选 mosdns

mosdns 是一个用 Go 写的 DNS 转发器,特点是插件化,配置文件写起来像搭积木。相比其他方案:

  • 分流规则灵活,可以按域名列表走不同的上游;
  • 自带缓存,还支持"过期缓存先用着、后台悄悄刷新";
  • 支持 DoT / DoH / DoQ 等加密协议,既能当服务端也能当客户端;
  • 单文件二进制,资源占用低,一台小机器就跑得动。

对我来说最关键的是第三点——对外能提供加密 DNS,手机在外面也能安全查询。

整体架构

先说一下最终的架构:

家里的设备(手机、电脑)
        │
        │ 普通 DNS(UDP/TCP 53)
        ▼
   mosdns(内网一台机器)
        │
        ├── 广告域名 → 直接拦掉
        ├── 国内域名 → 阿里/腾讯/百度 DNS
        └── 其他域名 → 自建的加密 DNS 上游
        │
        │ 加密 DNS(DoT 853,带证书)
        ▼
   外部设备(手机流量下)也能用

缓存分两级:国内域名缓存时间长一点(一天),其他域名短一点(三小时),因为国外的 CDN 地址经常变。

部署步骤

1. 准备 Docker 环境

mosdns 官方提供了 Docker 镜像,直接用就行。新建一个目录,放两个文件:config.yaml(主配置)和 docker-compose.yml。

2. 编写配置文件

配置分几块:缓存、上游、域名列表、处理流程、监听端口。

缓存部分:

plugins:
  # 国内域名缓存,lazy_cache_ttl 设成一天
  - tag: cache_domestic
    type: cache
    args:
      size: 200000
      lazy_cache_ttl: 86400
      dump_file: /etc/mosdns/data/cache-domestic.dump
      dump_interval: 600

  # 其他域名缓存,时间短一些
  - tag: cache_short
    type: cache
    args:
      size: 100000
      lazy_cache_ttl: 10800
      dump_file: /etc/mosdns/data/cache-short.dump
      dump_interval: 600

这里有个概念叫 lazy cache,值得说一下。普通的 DNS 缓存,一旦记录过期就必须重新去问上游,用户得等着。lazy cache 的做法是:记录过期后,先拿旧的答案返回给你,同时在后台悄悄去上游拿最新的。这样用户几乎感觉不到等待,数据又不会太旧。

上游部分:

  # 国内 DNS,三个并发,取最快返回的
  - tag: forward_domestic
    type: forward
    args:
      concurrent: 3
      upstreams:
        - tag: "ali"
          addr: "udp://223.5.5.5"
        - tag: "tencent"
          addr: "udp://119.29.29.29"
        - tag: "baidu"
          addr: "udp://180.76.76.76"

  # 自建的加密 DNS 上游
  - tag: forward_upstream
    type: forward
    args:
      concurrent: 2
      upstreams:
        - tag: "self1"
          addr: "https://你的DoH地址/dns-query"
          bootstrap: "223.5.5.5"
          idle_timeout: 600
          max_conns: 4

concurrent 表示同时向多个上游发请求,谁先回就用谁,相当于自动选最快的。

bootstrap 这个参数解决了一个实际问题:如果上游用的是域名而不是 IP,mosdns 需要先把域名解析成 IP 才能连接。但解析这个域名本身又需要 DNS,会形成循环。所以指定一个固定的 DNS 服务器专门用来解析上游域名。

处理流程:

  - tag: main_sequence
    type: sequence
    args:
      # 1. 本地域名覆写,优先级最高
      - exec: $hosts
      - matches: has_resp
        exec: accept

      # 2. 白名单域名直接放行,不走广告拦截
      - matches:
          - qname $whitelist_domain
        exec: $forward_upstream
      - matches: has_resp
        exec: accept

      # 3. 广告域名拦截
      - matches:
          - qname $ad_domain
        exec: reject 3

      # 4. 国内域名走国内 DNS
      - matches:
          - qname $cn_domain
        exec: $cache_domestic
      - matches: has_resp
        exec: accept
      - matches:
          - qname $cn_domain
        exec: $forward_domestic
      - matches: has_resp
        exec: accept

      # 5. 剩下的走加密上游
      - exec: $cache_short
      - matches: has_resp
        exec: accept
      - exec: $forward_upstream

这个流程读起来就是"先做 A,如果已经有结果就直接返回;否则做 B……",很直观。

监听端口:

  - type: udp_server
    args:
      entry: main_sequence
      listen: ":53"

  - type: tcp_server
    args:
      entry: main_sequence
      listen: ":53"

  # 对外加密 DNS,需要证书
  - type: tcp_server
    args:
      entry: main_sequence
      listen: ":853"
      cert: "/etc/mosdns/certs/dot.pem"
      key: "/etc/mosdns/certs/dot.key"

3. 准备证书

对外提供 DoT 服务需要一张证书,证书里的域名必须和客户端连接的域名一致,否则会报证书错误。

如果有正式域名,可以用 Let's Encrypt 签一张。如果只是内网用,自签名也可以,但客户端可能要手动信任。

4. 启动

docker compose up -d

启动后看日志,出现 all plugins are loaded 就说明配置没问题。

广告拦截规则

拦截规则用的是几个公开的域名列表:

  • Loyalsoldier 的 reject-list:GitHub 上的经典列表;
  • anti-AD:国内维护得比较勤的列表;
  • hagezi 的 pro 和 native.xiaomi:后者专门针对小米设备的广告。

加起来大概六十万条域名。这些列表放在容器里定期更新。

下载源的选择这里有个小坑。一开始用了一个自建的 GitHub 代理来下载,结果大文件只有 89 KB/s,一个 4MB 的列表要等好久。后来换成 jsDelivr 的 CDN,快了几十倍。不过 jsDelivr 有好几个入口,国内网络下 cdn.jsdelivr.net 有时候连不上,换成 gcore.jsdelivr.net 就稳定了。

自动更新列表

规则列表会变,需要定期更新。做法是在容器启动脚本里放一个后台循环,每 6 小时检查一次列表的更新时间,超过 24 小时就重新下载,下载完重启一下 mosdns 进程加载新规则。

这里踩了两个坑:

第一个坑是失败判断失效。原来的写法是:

echo "$urls" | while read -r url file; do
    wget -q -O "$file" "$url" || ok=false
done
touch "$TIMESTAMP_FILE"

管道后面的 while 是在子 shell 里执行的,里面设置的 ok=false 传不出来。结果就是不管下载成功还是失败,时间戳都会被更新,导致 24 小时内不再重试。改成用文件重定向就好了:

while read -r url file; do
    wget -q -O "$file.tmp" "$url" && mv "$file.tmp" "$file" || fail=$((fail+1))
done < "$URL_LIST"

第二个坑是下载中断会破坏原有文件。直接 wget -O 目标文件,如果下载到一半网络断了,文件就只剩一半。改成先下到临时文件,成功了再覆盖。

监控

mosdns 自带 Prometheus 指标接口,在配置里开一个 API 端口就能访问:

api:
  http: "0.0.0.0:5380"

访问 /metrics 能看到缓存命中率、查询次数这些数据。如果上游服务器也配了 tag,还能看到每个上游的响应延迟和错误次数:

- tag: forward_domestic
  args:
    upstreams:
      - tag: "ali"          # 加上这行才有指标
        addr: "udp://223.5.5.5"

这个功能挺实用。搭好之后一看延迟数据,发现某个国内 DNS 的 P95 延迟居然有 4000 多毫秒,还经常超时,果断换掉了。

配合 Grafana 可以做个面板,把缓存命中率、上游延迟、错误率都画出来,一眼就能看出哪里有问题。

查询日志

如果想看具体某个域名是怎么解析的,可以在处理流程里加一行:

- exec: query_summary mosdns_query

它会打印每个查询的域名、客户端 IP、返回码和耗时。

注意位置:这行必须放在流程的最前面。如果放在 accept 之后,请求已经被处理完返回了,根本走不到那行,什么都不会输出。

关于 DNS 泄露

搭好之后建议测一下,确认广告拦截和分流都正常工作。另外提一句,如果家里设备比较多,最好把路由器的 DHCP 下发的 DNS 也改成这台机器的地址,否则有些设备还是走原来的 DNS。

小结

整套东西搭下来,用了一台内网机器,跑一个 Docker 容器,内存占用 140MB 左右(主要是那六十万条规则)。

用下来感受:

  • 广告域名基本都能拦掉,浏览网页清爽不少;
  • 国内域名解析很快,因为有缓存加持;
  • 手机在外面也能用加密 DNS,不用担心被运营商看光;
  • 监控面板能直观看到各个上游的健康状况,出问题好排查。

如果家里有闲置的小主机或者 NAS,这套方案挺值得试一下。


本文记录的是个人部署经验,配置中的域名、IP 等均已隐去,实际使用时请替换成自己的。