环境与说明
Xray-core v26.6.1,配置里的 warp 出站是保守侧的中转,它可以是 Cloudflare WARP 或其他出站,不重要。
文中的域名数据来自 @Loyalsoldier/v2ray-rules-dat 的 geosite.dat,构建日期 2026-08-30。换个上游或者换个构建时间,具体条目会有出入。
下面假设读者自己写过分流规则,知道域名规则和 IP 规则各自什么时候生效。示例是 Xray 的服务端配置,不过整套思路和内核无关,sing-box 和 mihomo 一样能落地,客户端也适用,只是方向要反过来。
geosite 的 cn 里有什么
Xray 官方的教程对这张表的描述是 「大杂烩,只要沾点中国关系的都往里丢」
把两张表拉出来对一遍,能看到不少域名同时命中:
CN full:www.gstatic.comCN full:www.apple.comCN full:init.itunes.apple.comCN full:ocsp.apple.comCN full:dl.google.comCN full:fonts.gstatic.comCN full:c.pki.goog
GEOLOCATION-!CN domain:gstatic.comGEOLOCATION-!CN domain:www.apple.comGEOLOCATION-!CN domain:init.itunes.apple.comGEOLOCATION-!CN domain:ocsp.apple.comGEOLOCATION-!CN domain:dl.google.comGEOLOCATION-!CN domain:fonts.gstatic.comGEOLOCATION-!CN domain:c.pki.googocsp.globalsign.com 的情况稍有不同,它只命中 cn 里的 domain:globalsign.com。
条目这样写是存在合理性的。对于一个在北京的客户端,把 fonts.gstatic.com 当境内域名是合理的,Google Fonts(谷歌字体)服务确实有境内节点,直连比走代理快得多。
那它什么时候就不适用了呢?把同一份表搬到海外的服务端,「做路由决策的机器在境内」这个前提就没了,这些域名被归进 geosite:cn 也就从优势变成了误判。
这种归类也不是没人质疑,PR#328 的提议方觉得这类域名该单独成表,理由是客户端直连它们有 IP「送中」的风险,维护者不认可。
路由:境内外交给 IP 判断
平时容易混在一起的,其实是不同层面的几个问题:
- 这条连接的目的 IP 落在哪一侧?只取决于地址本身。
- 这个域名由哪一侧的节点提供服务?取决于从哪里问,以及权威 DNS 怎么回。
- 这个域名属于谁,会不会被干扰?这是策略问题,和地理位置相关但不等价。
geosite:cn 回答的是第二个,而且前提是提问的人在境内。
在一台不希望代理回国流量的服务器上,更应当使用 ip 进行境内外的判断。服务器用自己的 IP 同时代理出国和回国流量,出入境时两条方向相反的流量在时间上可以被关联起来,且可以被识别(这件事社区讨论过很多,也有实测)
那么连接国内公司在海外的边缘节点会被关联与识别吗? 对于跨境链路上的观测方,答案是不会,但是站在服务提供方那一侧要 另算。
比换列表更省事的一步,是换成不依赖社区维护的判据,例如在客户端分流的场景下,对于微信和 QQ 这类程序,与其枚举腾讯的域名段(会滞后,而且它们的 CDN 域名还和别人共用),不如直接写进程规则。
对应的配置:
"routing": { "domainStrategy": "IPIfNonMatch", // 域名规则不命中时解析成 IP 再匹配,IP 判据靠它生效 "rules": [ // api 必须在 geoip:private 之前,否则 API 自己的连接(目的是 127.0.0.1)会被拦掉 { "type": "field", "inboundTag": ["api"], "outboundTag": "api" }, { "type": "field", "inboundTag": ["dns-remote", "dns-remote-backup"], "outboundTag": "direct" }, { "type": "field", "ip": ["geoip:private"], "outboundTag": "blocked" }, { "type": "field", "protocol": ["bittorrent"], "outboundTag": "blocked" }, // 需要 inbound 开 sniffing { "type": "field", "ip": ["geoip:cn"], "outboundTag": "warp" } // 到这里结束,一条指向 direct 的例外都没有,因为 direct 就是默认出站 ]}境内解析器的查询请求会命中最后一条规则走 warp 出站,用不着也不应该为它单独写一条直连。
内置 DNS 服务器
域名表只用来选解析器
expectedIPs 对返回的 IP 的进行校验,只保留包含其列表中的地址;unexpectedIPs 反之。
Xray 构建解析器列表的顺序是命中 domains 条件的解析器按配置顺序排在前面,其余没标 skipFallback 的按配置顺序追加,核心将依次使用最终服务器列表中的服务器进行查询,并使用 expectedIPs 和 unexpectedIPs 过滤结果,为空则尝试列表中的下一个,第一个产出非空结果的解析器终止查询。
设置 expectedIPs: ["geoip:!cn"] 保留境外解析器答出的境外 IP,为空则使用列表中下一个解析器再次查询。境内解析器无过滤,并用 finalQuery 终止列表构建。
finalQuery和expectedIPs不要一起开Xray 的
finalQuery为真的时候后面的解析器不再参与,而过滤到空恰恰要靠后面的解析器来兜,两个一起开,一次过滤失败就等于解析失败。如果希望请求不至于失败,
expectedIPs里可以放一个*条目,过滤后为空时保留原始结果。
enableParallelQuery会破坏这里依赖的顺序语义这套做法整个建立在顺序语义上。
enableParallelQuery:"true"将启用分组并发,相邻且clientIP、skipFallback、queryStrategy、tag、domains、过滤条件全都相同的服务器合成一组,组内谁先成功就用谁的地址,组间按顺序回落。
选用更合适的域名表
这份 geosite.dat 里,cn 有十一万条多,绝大部分来自 china-list,也就是 dnsmasq-china-list 的 accelerated-domains,剩下的是 geolocation-cn 和 tld-cn,以及 apple.china 和 google.china 这两个 opt-in 文件。
dnsmasq-china-list 本身为选择解析器而建立,Apple 和 Google 是单独的 opt-in,符合在服务端选择解析器的需求。
部分域名需单独列出,比如 OCSP 域名,其是否单独处理需要取舍。Chrome 对叶证书不做在线 OCSP,实际依赖 CRLSets,Let’s Encrypt 在 2025 年停止 OCSP 服务并从证书里移除了 OCSP URL;
但 Windows 和 Safari 还是会查,这类客户端上它确实卡在 TLS 握手的关键路径上,一次境内解析加一跳绕行是能感觉到的。
设 ECS 防的是哪一种暴露
为了让保守规则命中,可以给境内解析器设一个境内的 EDNS Client Subnet,让国内公司的服务返回大陆地址,IP 规则就会命中,绕行也就生效了。
上述域名虽然从境外解析会落到海外边缘节点,不存在回国流量,但提供服务的仍然是国内公司,例如使用代理登录微信、支付宝等行为会留下一条机房 IP 和一个国内身份的关联记录,这是存在风险的。
机器直连海外边缘节点留下的是机器的 IP,而走 ECS 并分流绕行至保守侧留下的是中转方的 IP,直接缓解了上述风险。但在实际中,搭建服务端的人有这个意识,用的人未必有,分流出问题,或者干脆故意连国内服务,代价都会记在机器的 IP 上。
这使得在服务端考虑这样的策略是有必要的,虽然 ban 掉所有回国流量也能解决风险,但代价是会影响用户的使用体验,官方文档 选 WARP 而不选黑洞也是这个理由。
不过要强调的是,这一套只是服务端的兜底。真正兼顾使用体验和安全的配置是在客户端用 process 进程名规则或者分应用代理。
对应的配置:
"dns": { "queryStrategy": "UseIP", "servers": [ // 主力解析器 { "address": "https://1.1.1.1/dns-query", "tag": "dns-remote", "domains": [ "geosite:google-cn", "geosite:apple-cn", "geosite:microsoft@cn", "geosite:steam@cn", "geosite:gfw", "domain:ocsp.globalsign.com", "domain:status.rapidssl.com", "domain:ocsp.r2m01.amazontrust.com", "domain:oneocsp.microsoft.com", "domain:c.lencr.org", "domain:c.pki.goog" ], "expectedIPs": ["geoip:!cn"], "skipFallback": false, // 同时作为主力/默认解析器 "finalQuery": false, "timeoutMs": 1000 }, // 境内解析器 // clientIP 是 per-server 的 EDNS Client Subnet 字段(全局的那个键叫 clientIp) { "address": "223.5.5.5", "tag": "dns-cn-direct", "domains": ["geosite:china-list"], "clientIP": "192.0.2.1", // 换成境内 ISP 的 IP "skipFallback": true, // 只服务命中的域名,不参与回落 "finalQuery": true, "timeoutMs": 1500 }, // 兜底 { "address": "https://8.8.8.8/dns-query", "tag": "dns-remote-backup", "timeoutMs": 2000 } ]}运行时有四种情况:
| 域名 | 最终列表 |
|---|---|
| google-cn / apple-cn / c.lencr.org 等(仅命中 dns-remote) | [1.1.1.1 DoH, 8.8.8.8 DoH] |
| 仅命中 china-list | [223.5.5.5] |
| 未命中任何规则 | [1.1.1.1 DoH, 8.8.8.8 DoH] |
| 同时命中两者 | [1.1.1.1 DoH, 223.5.5.5] |
如果有 DNS 解锁服务,将 "address": "https://1.1.1.1/dns-query" 替换为服务提供商的地址即可,解锁服务故障时会自动回落 dns-remote-backup。
应用层判定漏洞
当 IPIfNonMatch 下的一个域名解析出多个地址时,每条 IP 规则会拿全部地址去试,任一命中就算命中。例如 www.bilibili.com 一次返回 20 个地址,只要其中一个落在 geoip:cn 里,整条连接就走保守侧,而实际建连用的可能是另一个地址:
22:48:55.438714 UDP:223.5.5.5:53 got answer: www.bilibili.com. -> [61.147.236.101, 183.131.147.48, 183.131.147.30, 183.131.147.29, 183.131.147.28, 183.131.147.27, 175.4.62.129, 175.4.62.128, 175.4.62.127, 119.84.174.97, 119.84.174.96, 119.84.174.68, 119.84.174.67, 114.230.222.141, 114.230.222.140, 114.230.222.139, 114.230.222.138, 61.147.236.104, 61.147.236.103, 61.147.236.102] 337.538933ms22:48:55.438724 accepted tcp:www.bilibili.com:443 [inbound-443 -> warp]在时序方面:
22:49:13.697898 UDP:223.5.5.5:53 got answer: www.jd.com. -> [] 4.000179582s <app/dns: record not found>22:49:14.108768 DOH//1.1.1.1 got answer: www.jd.com. -> [140.150.36.51, 138.113.102.14] 411.088937ms22:49:14.108794 accepted tcp:www.jd.com:443 [inbound-443 >> direct]22:49:14.108804 UDP:223.5.5.5:53 cache HIT: www.jd.com. -> [1.194.193.81]境内解析器用默认 4 秒超时返回空,回落至境外解析器,得到海外节点 ip ,路由据此判定直连。10 微秒后出站建连再次解析,这次命中缓存得到 1.194.193.81,一个大陆地址。判定用的是海外 IP,连接用的是大陆 IP。
缓存里的记录从哪来,日志没有直接记下,可能是境内解析器的应答在超时判定之后才到。
出站再解析一次不是默认行为,Xray 的 freedom 出站 domainStrategy 默认 AsIs,此时 Xray 将域名交给 Go 按操作系统 DNS 设置解析并连接,当填写其他值时使用内置 DNS 模块进行解析,才出现第二次内置查询。
只要有两个解析器可能对同一个域名给出不同答案,而其中一个不稳定,这个窗口就在。为了解决该问题,将 dns-cn-direct 的 timeoutMs 调整为 1500;并设置 finalQuery 阻止其使用 fallback 解析器,同样的超时行为将直接作为失败的解析,以防止分流错误。
仅内核判定存在局限性,可以考虑在内核之外加一道防火墙级的过滤器,监测物理网卡的出站,目的地址落在一个独立维护的 CN 地址集合里就拦截。该集合最好来源和内核不同并定时刷新,和内核的数据形成两份并集。
验证
对访问日志的分析:
# 域名 + 出站sed -nE 's#.*accepted tcp:([^ ]+) \[([^]]*)\].*#\2\t\1#p' access.log | sort -u
# 域名 + 解析器 + IPsed -nE 's#.*(DOH//[^ ]+|UDP:[^ ]+) (got answer|cache HIT): ([^ ]+)\. -> \[([^]]*)\].*#\3\t\1\t\4#p' access.log | sort -u被路由规则命中是 [inbound-443 -> warp],回落默认出站为 [inbound-443 >> direct]。
批量测试期望明确的域名:
| 类别 | 例子 |
|---|---|
| 外国公司的境内服务 | www.gstatic.com, dl.google.com, www.apple.com, ocsp.globalsign.com, c.pki.goog |
| 真正的境内服务 | www.baidu.com, www.bilibili.com, www.taobao.com |
.cn 顶级域 | nvidia.cn, mirrors.tuna.tsinghua.edu.cn |
| 境外对照组 | github.com, www.youtube.com, www.cloudflare.com |
while read -r d; do curl -sI --max-time 5 "https://$d" >/dev/null; done < domains.txt测服务端行为的时候客户端要切到全局模式,防止客户端分流的干扰。
总结
- 先确认社区建这张表要解决的问题,和自己的需求是不是一回事。
- 服务端对境内外的判定适合用 IP,选解析器才用域名表,而且要用专为选解析器而建的表。
- 对于服务端,IP 是比通用域名分类更合适的判据,但它仍然受 DNS、CDN、多地址、缓存、时序影响。
这样列表滞后带来的后果,就从「某天某个域名会走错,而且可能是有风险的那个方向」变成「某天某个域名会慢一点,扫一遍日志就能发现」。
附录:完整配置
warp 是保守侧的中转出站,direct 是默认出站。
如果要用 warp 出站需要注意:
mtu 为 1280,Xray 的 wireguard 出站默认 1420 偏大,Cloudflare 自家客户端是 1280。
keepAlive 写 25,是由于正常情况下该出站几乎空闲,握手过期后重新握手要是撞上丢包,就足以让一个 5 秒超时的探测失败。
{ "dns": { "queryStrategy": "UseIP", "servers": [ { "address": "https://1.1.1.1/dns-query", "tag": "dns-remote", "domains": [ "geosite:google-cn", "geosite:apple-cn", "geosite:microsoft@cn", "geosite:steam@cn", "geosite:gfw", "domain:ocsp.globalsign.com", "domain:status.rapidssl.com", "domain:ocsp.r2m01.amazontrust.com", "domain:oneocsp.microsoft.com", "domain:c.lencr.org", "domain:c.pki.goog" ], "expectedIPs": ["geoip:!cn"], "skipFallback": false, "finalQuery": false, "timeoutMs": 1000 }, { "address": "223.5.5.5", "tag": "dns-cn-direct", "domains": ["geosite:china-list"], "clientIP": "192.0.2.1", //换成境内 ISP 的 IP "skipFallback": true, "finalQuery": true, "timeoutMs": 1500 }, { "address": "https://8.8.8.8/dns-query", "tag": "dns-remote-backup", "timeoutMs": 2000 } ] }, "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ { "type": "field", "inboundTag": ["api"], "outboundTag": "api" }, { "type": "field", "inboundTag": ["dns-remote", "dns-remote-backup"], "outboundTag": "direct" }, { "type": "field", "ip": ["geoip:private"], "outboundTag": "blocked" }, { "type": "field", "protocol": ["bittorrent"], "outboundTag": "blocked" }, { "type": "field", "ip": ["geoip:cn"], "outboundTag": "warp" } ] }, "outbounds": [ { "protocol": "freedom", "tag": "direct", "settings": { "domainStrategy": "UseIPv4" } }, { "protocol": "wireguard", "tag": "warp", "settings": { "mtu": 1280, "secretKey": "...", "peers": [{ "publicKey": "...", "endpoint": "...", "keepAlive": 25 }] } }, { "protocol": "blackhole", "tag": "blocked" } ]}