oxidns + mihomo 做家庭 DNS 分流:配置文件详解
这套方案解决什么问题
设备一多,代理就难管。每台设备各挂各的客户端,配置分散,电视和 IoT 设备没法配。把决策集中到一台机器:设备只认一个 DNS、一个默认网关,该直连的直连,该翻墙的翻墙。
方案里两个角色跑在同一台机器上:oxidns 管 DNS 分流,mihomo(clash 核心)管流量出口。oxidns 是 mosdns 的重构版,配置是 YAML。下面按实际运行配置逐段拆解,照抄改改就能跑。
角色分工
oxidns 监听 53 端口收全家的 DNS 查询,按 geosite 名单判断域名归属:geosite cn 和直连名单里的域名,交给国内 ISP 的 DNS 解析,返回真实 IP;geolocation-!cn 的域名,转发给 mihomo 拿 fake-ip。mihomo 应答 fake-ip,接收 fake-ip 段流量,按规则出去。
oxidns 配置
判定名单
geoip 和 geosite 数据来自 Loyalsoldier 的 v2ray-rules-dat,启动时缺了自动下载,之后每 12 小时刷新:
plugins:
- tag: geoip_private
type: geoip
args: { file: ./data/geoip.dat, selectors: [private] }
- tag: geoip_cn
type: geoip
args: { file: ./data/geoip.dat, selectors: [cn] }
- tag: geosite_cn
type: geosite
args: { file: ./data/geosite.dat, selectors: [cn, china-list, apple-cn, google-cn] }
- tag: geosite_noncn
type: geosite
args: { file: ./data/geosite.dat, selectors: [geolocation-!cn] }
- tag: data_download
type: download
args:
timeout: 60s
startup_if_missing: true
downloads:
- url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geoip.dat"
dir: "./data"
filename: "geoip.dat"
- url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geosite.dat"
dir: "./data"
filename: "geosite.dat"
- url: "https://cdn.jsdelivr.net/gh/asterwyx/direct@main/direct-list.txt"
dir: "./data"
filename: "asterwyx.txt"
直连名单和代理名单
直连名单由三部分拼成:geosite cn 打底,加上社区直连列表和自己按需补充的域名。代理名单同理,geosite noncn 打底,再补自己需要的:
- tag: domain_direct
type: domain_set
args:
exps:
- za.group # 自补充的直连域名
- argotunnel.com
- openrouter.ai
- integrate.api.nvidia.com
files:
- ./data/asterwyx.txt # 社区直连列表
sets:
- geosite_cn
- tag: domain_proxy
type: domain_set
args:
exps:
- kubevirt.io # 自补充的代理域名
- regexp:^[^.]+-docker\.pkg\.dev$
- b.ai
sets:
- geosite_noncn
特殊域名
内网域名直答,不走任何上游。公司域名转发给公司内网 DNS。来自 Tailscale 网段(100.64.0.0/10)的查询,把内网域名解析成 tailnet 入口地址,保证从外面访问家里服务时走隧道:
- tag: hosts_home
type: hosts
args:
entries:
- regexp:.+\.example\.home 192.168.18.31 # 内网服务统一入口
- tag: hosts_tailscale
type: hosts
args:
entries:
- regexp:.+\.example\.home 100.111.89.100 # tailnet 入口
- tag: domain_deepin
type: domain_set
args:
exps:
- regexp:.+\.example-corp\.com # 公司内网域名
上游
国外通道全部是 IP 型 DoT/DoH。为什么必须是 IP 型,后面单独讲:
- tag: forward_foreign
type: forward
args:
concurrent: 3
upstreams:
- addr: https://1.1.1.1/dns-query
dial_addr: 1.1.1.1
enable_http3: false
- addr: tls://1.1.1.1
enable_pipeline: true
- addr: tls://8.8.8.8
enable_pipeline: true
- addr: https://45.11.104.186
insecure_skip_verify: true
- addr: tls://208.67.222.2
enable_pipeline: true
- addr: tls://208.67.222.222
enable_pipeline: true
国内通道按运营商分两组,环境变量 WH_TELECOM / WH_UNICOM 切换:
- tag: forward_wh-telecom
type: forward
args:
concurrent: 2
upstreams:
- addr: 202.103.24.68 # 武汉电信
- addr: 202.103.44.150
- tag: forward_wh-unicom
type: forward
args:
concurrent: 2
upstreams:
- addr: 218.104.111.114 # 武汉联通
- addr: 218.104.111.122
三个特殊上游,是方案对接的关键:
- tag: forward_local # 本地域名,交给路由器
type: forward
args: { upstreams: [ { addr: 192.168.18.1:5353 } ] }
- tag: forward_clash # 国外域名,转 mihomo 拿 fake-ip
type: forward
args: { upstreams: [ { addr: 127.0.0.1:7874 } ] }
- tag: forward_deepin # 公司域名
type: forward
args: { upstreams: [ { addr: 10.20.0.10 } ] }
公网查询带缓存,13 万条,懒加载 TTL 一天:
- tag: cache_wan
type: cache
args: { size: 131072, lazy_cache_ttl: 86400 }
查询处理链
先看五个查询序列,处理链引用的就是它们:
- tag: query_foreign # 国外:缓存优先,然后 DoT/DoH
args: [query_summary foreign, $cache_wan, jump has_resp_seq, $forward_foreign, jump has_resp_seq]
- tag: query_isp # 国内:缓存优先,然后按运营商
args: [query_summary isp, $cache_wan, jump has_resp_seq, env WH_TELECOM -> $forward_wh-telecom, env WH_UNICOM -> $forward_wh-unicom, jump has_resp_seq]
- tag: query_clash # 转 mihomo
args: [query_summary clash, $forward_clash, jump has_resp_seq]
- tag: query_lan # 本地,转路由器
args: [query_summary lan, $forward_local, jump has_resp_seq]
- tag: query_deepin # 公司
args: [query_summary company, $forward_deepin, jump has_resp_seq]
三段处理链,顺序执行:
- tag: pre_sequence
args:
- client_ip 100.64.0.0/10 # Tailscale 来源的查询
-> jump query_tailscale
- qtype 65 / qname 含关键字 # HTTPS 记录和关键字查询直接拒
-> $reject_3
- qtype 12 / qtype 255 / .lan .local .arpa
-> jump query_lan
- _vlmcs._tcp.lan. SRV # 局域网 KMS 发现
-> $kms_srv
- $hosts_home # 内网域名直答
- qname $domain_deepin
-> jump query_deepin
- tag: main_sequence
args:
- qname $domain_direct 且非 $domain_proxy
-> jump query_isp # 直连名单 → 国内 ISP → 真实 IP
- qname $domain_proxy
-> jump query_clash # 代理名单 → mihomo → fake-ip
- tag: fallback_sequence
args:
- $query_foreign # 兜底:先国外解析
- 结果 private -> query_lan
- 结果 geoip cn -> query_isp
- 其他 -> query_clash
监听端口,三种身份:
- tag: udp_server_main
type: udp_server
args: { entry: sequence, listen: :53 }
- tag: tcp_server_main
type: tcp_server
args: { entry: sequence, listen: :53 }
- tag: udp_server_foreign
type: udp_server
args: { entry: query_foreign, listen: :5353 }
- tag: tcp_server_foreign
type: tcp_server
args: { entry: query_foreign, listen: :5353 }
- tag: udp_server_isp
type: udp_server
args: { entry: query_isp, listen: :5354 }
- tag: tcp_server_isp
type: tcp_server
args: { entry: query_isp, listen: :5354 }
53 是主入口,5353 国外通道,5354 国内通道。后两个端口双重身份:对内是 oxidns 自己的分流通道,对外是 mihomo 的解析上游。
mihomo 怎么配合
mihomo 的完整配置里,和 oxidns 对接的只有 DNS 段:
dns:
enable: true
listen: 0.0.0.0:7874
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
fake-ip-ttl: 300
nameserver: # 回查 oxidns 国外通道拿真实 IP
- 127.0.0.1:5353
direct-nameserver: # 回查 oxidns 国内通道拿真实 IP
- 127.0.0.1:5354
对应关系固定:nameserver 指 5353 国外通道,direct-nameserver 指 5354 国内通道,写反会导致代理侧域名被国内 DNS 解析。
fake-ip-filter 里放音乐、时间同步、微软、苹果和游戏这些必须拿真实 IP 的域名,避免 fake-ip 破坏服务:
fake-ip-filter:
- '*.lan'
- '+.pool.ntp.org'
- 'time.*.com'
- '*.msftconnecttest.com'
- 'mesu.apple.com'
- '+.srv.nintendo.net'
- '*.music.163.com'
- 'y.qq.com'
tun 和基础项:
tun:
enable: true
stack: system
mixed-port: 7893
allow-lan: true
mode: rule
external-controller: 192.168.18.5:9090
规则组织方式和一般 clash 配置没有区别:订阅 provider、应用规则组,GEOIP LAN/CN 直连和 MATCH 兜底,这里不展开。
两者怎么接起来
oxidns 把国外域名查询转发给 mihomo 的 7874 端口。mihomo 收到后要先判断这个域名该走代理还是直连,用 nameserver(127.0.0.1:5353,oxidns 国外通道)或 direct-nameserver(127.0.0.1:5354,oxidns 国内通道)回查 oxidns 拿真实 IP,做 GEOIP 判断。判为代理的返回 fake-ip 给 oxidns,判为直连的返回真实 IP。fake-ip-filter 里的域名也是这一步拿真实 IP 返回。
设备访问 fake-ip,流量进 mihomo tun,反查回域名,用这次解析好的真实 IP 建连,走代理或直连。
这个环不会死锁,因为域名解析在整个链路里只发生一次。mihomo 回查 oxidns 拿真实 IP 之后,GEOIP 判断和后续建连全部基于 IP,不再需要域名解析,DNS 查询链到此终止。oxidns 的 5353/5354 通道直连 DoH/DoT 和国内 ISP 上游,也不会把查询再转回 mihomo。
DNS 请求时序
国外域名(google.com),以 fake-ip 结尾:
sequenceDiagram
participant D as LAN 设备
participant G as 网关 18.1(ADG)
participant O as oxidns 18.5
participant M as mihomo 18.5
participant U as 上游 DoT/DoH
D->>G: 查询 google.com
G->>O: 转发到 :53
O->>M: 命中 geolocation-!cn,转发 :7874
M->>O: 回查真实 IP(nameserver → :5353)
O->>U: DoT/DoH 查询(1.1.1.1 / 8.8.8.8)
U-->>O: 真实 IP
O-->>M: 真实 IP
M-->>O: GEOIP 判定为代理,返回 fake-ip
O-->>G: fake-ip 198.18.x.x
G-->>D: fake-ip
D->>G: 访问 fake-ip
G->>M: 静态路由 198.18/16 → 18.5
M->>M: tun 反查域名,命中代理组
M->>M: 走机场节点出站
国内域名(baidu.com),全程不碰 clash:
sequenceDiagram
participant D as LAN 设备
participant G as 网关 18.1(ADG)
participant O as oxidns 18.5
participant U as 国内 ISP DNS
D->>G: 查询 baidu.com
G->>O: 转发到 :53
O->>U: 命中直连名单,走 :5354 国内通道
U-->>O: 真实 IP
O-->>G: 真实 IP
G-->>D: 真实 IP
D->>G: 直连目标
G->>G: NAT 出网(不经 clash)
为什么必须住同一台机器
oxidns 的国外上游是 8.8.8.8、1.1.1.1 这类 DoT/DoH,目标 IP 都在国外。这些连接从 oxidns 发出,被同网络栈里 mihomo 的 tun 接住,GEOIP 一查是国外 IP,走机场出去。DNS 查询本身翻墙了,防污染、防阻断,不用配任何额外路由。
这个设计带出一个硬约束:上游只能配 IP 型 DoT/DoH,不能配域名型的。dns.google 这种域名要先解析才能连,解析又要查 DNS,查询又回到自己手里,死锁。要么 hosts 里写死它的 IP,要么全用 IP 型。上面配置里全是 IP 型,dial_addr 显式写出目标地址。oxidns 单独部署的话这些裸 DoT 连接出不去,方案不成立。
流量路径分三种
真实 IP 直连。DNS 阶段判了直连,返回真实 IP。设备直接和目标建连,网关 NAT,全程不经过 clash。
fake-ip 走代理。拿到 fake-ip 后设备访问它,网关把 fake-ip 段路由到 mihomo,tun 接住,反查域名,规则命中代理组,从机场出站。
fake-ip 判直连。域名先拿了 fake-ip 进了 clash,但规则判了 DIRECT(Direct 规则集、GEOIP CN,或解析出的真实 IP 属于中国段),mihomo 自己向真实目标建连再原路回给设备。这类场景常见于挂了国内 CDN 的境外站点:域名不在 geosite cn,但 IP 归属在国内。域名名单判断不了 IP 归属,所以先发 fake-ip,把按 IP 裁决的权力留给 mihomo。域名层粗筛、IP 层精筛,这是这套方案的核心。
三种连接流程
flowchart TD
A["设备访问某域名"] --> B["DNS 查询<br/>ADG → oxidns :53"]
B --> C{"oxidns 判定"}
C -- "直连名单 / geosite cn" --> D["国内 ISP 解析 :5354<br/>返回真实 IP"]
D --> E["设备直连目标<br/>网关 NAT 出网<br/>全程不经 clash"]
C -- "geolocation-!cn" --> F["mihomo :7874<br/>返回 fake-ip"]
F --> G["设备访问 fake-ip<br/>网关路由 198.18/16 → 18.5"]
G --> H["mihomo tun 反查域名<br/>回查真实 IP 做 GEOIP"]
H --> I{"规则判定"}
I -- "代理组" --> J["机场节点出站"]
I -- "DIRECT / GEOIP CN" --> K["mihomo 代连真实目标<br/>网关 NAT 出网"]
复刻时最容易踩的坑
DoH 用域名型。写了 dns.google 这种上游,启动即死锁。全部用 IP 型,或者 hosts 写死。
fake-ip 段和内网段冲突。198.18.0.0/16 是文档保留段,拿来当 fake-ip 不会撞任何真实内网。别换成 10.x 或 172.16。
网关漏配路由。只配好 oxidns、静态路由没加,fake-ip 流量会烂在网关。先 dig @oxidns-ip 确认 DNS 通,再 curl 验证流量路径。
nameserver 和 direct-nameserver 写反。对应关系是 nameserver=5353 国外通道、direct-nameserver=5354 国内通道。
geosite 数据过期。靠 12 小时 cron 拉新,启动时拉不到会用旧文件。日志里看到下载失败先排查 oxidns 的出网。
oxidns 的 API 面板默认口令很弱,暴露公网前必须改。
收尾
整套方案两个配置文件,加上一套端口对应关系和一条静态路由。跑起来之后家里所有设备零配置,调整规则只动一处。