Skip to content

我一直在改 Clash 规则,根因却在“设置为系统代理”那个勾 ​

文中的域名、DNS 地址和网段均为脱敏示例,不对应真实环境。

一个自相矛盾的现象 ​

那几天我需要两件事同时成立:公司内网只能走公司自己的 VPN,日常调试又必须让 ClashX Meta 开着 TUN——查文档、拉依赖、上 GitHub 都指着它。两个工具各自用都没问题,凑到一起就出事了:单独开 VPN,内网后台一切正常;一旦把 ClashX 也打开,后台就只剩浏览器里那句 ERR_CONNECTION_TIMED_OUT,把 ClashX 关掉又立刻恢复,稳定复现。

作为前端,我的第一反应几乎是条件反射:控制台写着连接超时,要么是后端挂了,要么是我哪条代理规则写歪了。前者很快被同事排除,于是我就一头扎进第二个方向。现在回头看,那句超时只说明连接没建起来,至于断在解析、路由还是握手,它什么都没交代。

最早让我警觉的其实不是超时,而是另一个细节。我在 macOS 网络设置里把公司域名加进“忽略代理的域名”,页面立刻恢复,可过一会儿那几行又自己消失了——那张列表被别的东西持续重写,Clash 接管系统代理时会把自己的配置整体写回去。当时我只把它当成一个需要绕开的麻烦。事后看,这个细节已经指向根因:系统代理一直是开着的。

第一个假设:规则写错了 ​

最顺手的动作,是给公司域名加一条直连,放在 RULE-SET、GEOIP 这些宽泛规则前面:

yaml
rules:
  - DOMAIN-SUFFIX,corp.example,DIRECT

Clash 日志确实显示命中了这条规则,页面照样打不开。我反复检查规则顺序、通配写法、有没有被订阅更新覆盖,又试着按公司网段补 IP-CIDR 规则。这里插一句,“网段”就是一组连续的 IP 地址:198.51.100.0/24 里的 /24 表示这些地址共享前 24 位,也就是从 198.51.100.0 到 198.51.100.255 这 256 个算一组。按网段写规则,比逐个域名、逐个 IP 地维护稳得多——内网服务会换域名、换 IP,但很少跨出公司划好的那段地址。可惜不管怎么补,超时的样子一点没变。

我在这里犯的错,是把 DIRECT 读成了“绕过 Clash”。它真正的含义窄得多:这条连接不交给远端代理节点。至于连接从哪块网卡发出、要不要进公司 VPN,规则一个字都没说。日志里那句“命中 DIRECT”回答的问题,和我心里想问的,压根不是同一个。那几天我把规则文件改了十几遍,每一遍都信心满满,每一遍都输给同一个超时。

第二个假设:DNS 或 Fake-IP 在捣乱 ​

我这份 TUN 配置使用 Fake-IP:Clash 在解析阶段直接返回 198.18.0.0/16 里的合成地址,让应用先把连接交给自己,再按域名匹配规则、代查真实 IP。系统拿到的是假 IP,自然没法按公司网段选路。于是我把公司域名后缀排除出 Fake-IP,用 nameserver-policy 把它交给公司 DNS,又把公司 DNS 和内网网段写进 TUN 的排除地址。

改完再查,dscacheutil 返回正确的内网 IP,HTTPS 证书也没问题。我还不放心,在 /etc/hosts 里把域名钉死到那个 IP,想看看解析结果是不是在哪个环节被偷换——页面依旧打不开。hosts 只是在查 DNS 之前插了一脚,管的是“域名换成哪个 IP”;换完之后包往哪走,它一点都管不着。到这里,名字解析这一层已经干净了,故障只可能在更下面:连接从哪里出去。

顺带纠正当时的一个想当然:开系统代理并不意味着 DNS 一定被接管。浏览器走 HTTP 代理时,通常把域名原样交给代理、由 Clash 去解析,本机的 hosts 和系统 DNS 依然各司其职。解析正常和路径正常是两件独立的事,我却一直拿前者证明后者。

一组对照实验把问题钉住 ​

真正有用的是两条只差一个变量的 curl:

bash
curl -vkI https://admin.corp.example/
curl -vkI -x http://127.0.0.1:7890 https://admin.corp.example/

同一台机器、同一个域名、同一条公司 VPN:直连走完 TLS 握手,拿到 HTTP 200;只是在前面套上 127.0.0.1:7890,就卡在 TCP 阶段超时。两条命令之间唯一的差别是“是否经过本地 Clash”,所以结论也只能指向它——服务器活着,VPN 通着,证书没事,坏的只有这一条路径。这是整个排查里我拿到的第一份能站住的证据,之前的每一轮修改,说到底都只是猜测。

Clash 日志把话说得更明白:这次请求命中 DIRECT,解析出正确的内网 IP,然后 TCP 超时;同一份日志里写着,它的默认出口接口是 en0,Mac 上那块真实的物理网卡。而我用 route -n get 查同一个内网 IP,系统给出的 interface 是 utun4,公司 VPN 建出来的虚拟隧道接口。路由表这个名字听着像在规划完整路径,其实它只回答一个问题:这个目标 IP 的包,本机先交给哪块接口、发给哪个下一跳;下一跳之后怎么走,是别人的设备操心的事。眼下,这一跳已经够用了。

矛盾终于摆上台面:系统认为该走 utun4,Clash 认为该走 en0。问题从模糊的“Clash 和 VPN 冲突”,收敛成具体的“谁在发起这条连接”。

根因:两个入口叠加,换掉了发起者 ​

系统代理和 TUN 根本不在同一层。系统代理是应用层的约定:浏览器主动去连本地的 127.0.0.1:7890,把请求托付给 Clash;TUN 和公司 VPN 都在网络层,接管的是 IP 数据包本身。两者同时开着,浏览器走的是前者。应用层的约定优先生效:浏览器一旦知道自己有代理,就根本不会向内网 IP 发起连接,TUN 能看到的只有一股流向 127.0.0.1 的本地流量,真正的目标只写在发给 Clash 的代理请求里。接下来的事顺理成章:Clash 匹配到 DIRECT,再以自己身份新建一条到内网 IP 的 TCP 连接。

要命的是选路发生的时机。内核为一条新连接发出第一个 SYN 时,就已经查好路由表、定好出口接口、决定要不要交给某个 utun 做加密封装,而不是等连接建成再补。在我这份开启了 auto-detect-interface 的配置里,Clash 会把出站连接绑在探测到的默认物理接口上,免得自己发的包又被 TUN 收回去绕成环;副作用是路由表里“该走 utun4”那条对它不再生效。这条连接从诞生那一刻起就没进过公司 VPN,后面再也拐不回去。TUN 的排除网段同样帮不上忙——浏览器连的是 127.0.0.1,那段配置根本没被用到。

顺带把接口的事说完。utun 的编号按创建顺序分配,每次连 VPN 都可能变,今天 utun4,明天也许 utun8,所以任何配置都不该写死它。虚拟接口自己不连网线:内网流量先进 utun 完成封装,外层包最终仍借 en0 出门,两者从来不是二选一。关掉系统代理后,链路正是这样顺起来的:浏览器拿到真实内网 IP,直接 connect,内核查路由表,公司网段命中 VPN 的 utunN,封装后的外层包再由 en0 发出。

最后留下的东西 ​

稳定组合很朴素:公司 VPN 开,ClashX TUN 开,“设置为系统代理”关。配置里真正必需的只有几行示意:

yaml
dns:
  fake-ip-filter: ['corp.example', '*.corp.example']
  nameserver-policy:
    '+.corp.example': [10.0.0.53]
tun:
  route-exclude-address: ['10.0.0.53/32', '198.51.100.0/24']

前者让公司域名拿到真实 IP,系统才有依据选路;后者把公司网段还给 macOS 路由表。网段要以 VPN 连上后的实际路由为准,别想当然写 10.0.0.0/8;这些改动要放进 Mixin 或覆写配置,否则订阅一更新就被冲掉。

排查顺序我也固定下来:先只开 VPN 建立基准,确认目标本身可达;再看域名解析出的 IP 对不对;然后 route -n get 目标 IP,看系统打算从哪块接口出去;接着用直连和显式代理两条 curl 做对照;最后 scutil --proxy 回头确认,自己是不是同时开了两个入口。这个顺序的原则只有一条:每一步都要能单独证伪一层,而不是又往配置里添一笔。

这次真正救我的不是多加规则,而是设计了一个只差一个变量的对照。网络问题最麻烦的地方在于分层,每一层都可能坏,也都能装得没坏;与其在某一层的配置里越陷越深,不如先想清楚手头这条证据到底能证明哪一层。配置文件只会告诉你它被写成了什么样,只有对照实验会告诉你包实际去了哪里。

内容均为个人学习与公开总结,不含未脱敏的内部资料。