Clash Verge TUN 下让公司内网依赖走内网

在家办公、周末排查问题,或任何不在公司办公网络里的场景,机器经常需要同时连上两套网络:一套公司客户端负责访问内网服务,一套外网代理负责日常上网和公开资料访问。理想状态是内网和外网各走各的路,实际开了 Clash Verge Rev 的 TUN 模式后,公司内网域名可能会先被 Clash 的 DNS 接管,最后落到 198.18.x.x 这样的 fake-ip 上。表面上看,公司客户端已经连上了,真正请求却没有走到公司内网。

这个问题不能只靠一条 DIRECT 规则解决。TUN 模式下,路由规则、fake-ip 过滤、公司域名 DNS 和 TUN 路由排除要一起处理:先让公司域名不走代理节点,再让这些域名不被分配 fake-ip,随后为不同内网服务选择正确的 DNS 或固定 host 映射,最后把解析出来的内网 IP 从 TUN 自动路由里排除。

代理的本质

代理的本质是让一台中间节点替你完成一段连接。区别在于这台中间节点站在哪一边:站在客户端这边,是正向代理;站在服务端这边,是反向代理。

正向代理站在客户端这边。应用把「我要访问哪个目标」交给代理,代理再替应用去连接目标服务。Clash 普通模式里的 HTTP / SOCKS 代理就是这种形态。命中 DIRECT 时,本机 Clash 直接连接目标服务;命中 PROXY 时,本机 Clash 会先把请求交给远端节点服务器,再由节点服务器连接目标服务。

正向代理走代理节点时的请求和响应链路

目标服务看到的来源通常是远端节点服务器,而不是应用所在的本机。DIRECT 则少掉远端节点这一段,本机 Clash 会按系统 DNS 和系统路由直接连目标服务。

反向代理站在服务端这边。客户端访问的是业务域名,真正接住请求的是 Nginx、CDN、网关或负载均衡,再由它转发到后端服务。客户端通常不需要知道后面有几台真实机器,也不需要额外配置代理。

客户端
  ↓ 访问 service.example.com
反向代理,例如 Nginx / CDN / 网关
  ↓ 转发、负载均衡、TLS 终止、鉴权
后端服务

这篇文章里的 Clash 是正向代理,影响的是本机出站流量;公司 VPN / 零信任客户端提供的是公司资源访问路径,影响的是公司域名解析、系统路由和访问网关。问题出在这两套客户端侧网络能力同时接管 DNS 或路由时,谁先处理、谁后处理没有对齐。

先判断公司网络是哪种形态

不同公司的远程办公网络不一定需要同一套处理方式。遇到“内网和外网要同时可用”的问题,先判断公司客户端到底接管了哪一层。

大厂常见的是全隧道模式。客户端接管默认路由和 DNS,所有流量先进入企业网关。公司资源在网关内侧继续走内网、专线、VPC 或应用代理,普通外网再从企业统一出口 NAT 出去。用户看起来是“连上 VPN 以后内外网都能访问”,实际是外网也先进了企业网络。

应用
  ↓
企业 VPN / 零信任客户端
  ↓
企业网关
  ├─ 公司资源:内网 / 专线 / VPC / 应用代理
  └─ 普通外网:企业统一出口 NAT

这种模式下,企业客户端已经承担了 Clash TUN 的角色。普通开发机不建议再叠一层个人 TUN 代理;如果确实需要外网代理,优先用浏览器或单个工具的应用层代理,避免和企业客户端抢默认路由、DNS 和证书策略。

另一类是分流模式。公司域名、公司 IP 段或受控应用进入 VPN,普通外网仍走本地网络。很多零信任客户端也会落到类似效果:只给受控资源下发 DNS、fake-ip、路由或应用代理,不把整台机器的外网都收走。

公司域名 / 公司 IP
  ↓
企业 VPN / 零信任客户端
  ↓
公司资源

普通外网
  ↓
本地网络 / Clash

这篇文章处理的就是分流场景里的冲突:公司客户端负责内网,Clash 负责外网;Clash 一旦开 TUN,就可能抢到本来该交给公司客户端的 DNS 或路由。

还有一种更轻的形态是公网入口加白名单。服务部署在云上,有公网域名,但只允许办公网出口、VPN 出口或企业网关出口访问。这个方案看起来像“卡 IP”,但通常不是唯一防线。成熟一点的企业还会叠身份、设备可信状态、客户端证书、网关鉴权、应用权限和审计。

用户身份 / 设备状态 / 客户端证书
  ↓
企业 VPN / 零信任网关
  ↓
固定企业出口 IP
  ↓
云上服务的白名单 / 安全组 / 应用鉴权

所以“连内网是不是靠卡 IP”要分层看。IP 白名单经常是服务端最后一道入口限制;真正决定这台电脑能不能进入公司资源的,通常还有身份、设备、DNS、路由、证书和网关策略。

可以按下面的方式选方案:

公司网络形态 典型表现 更稳的处理方式
全隧道 连上公司客户端后,内网和外网都从企业出口走 不叠个人 TUN;外网特殊需求优先用应用层代理
分流 VPN 只有公司域名或公司 IP 进 VPN,外网仍走本地网络 Clash 可以负责外网;开 TUN 时排除公司域名、fake-ip 和内网 IP 段
零信任应用代理 只对受控应用下发 DNS、fake-ip、证书或应用代理 让公司客户端优先处理公司域名;Clash 只处理非公司流量
公网入口加白名单 服务有公网域名,但非企业出口访问会 403 或超时 确认请求从企业出口出去;不要只改 DNS 或本地 hosts

如果公司客户端已经是全隧道代理或完整企业网关,后面这套 Clash TUN 调整通常不该继续做。它更适合 iOA / SmartVPN 这类只负责公司资源访问路径的场景:公司客户端让内网服务可达,Clash 继续负责普通外网,两边需要避开 DNS 和路由上的互相抢占。

为什么需要 TUN

普通正向代理通常只接管主动配置了 HTTP / SOCKS 代理的应用。浏览器、IDE、命令行、Gradle、npm 各有自己的代理读取方式,任何一层没跟上,流量就可能绕开代理。

不开 TUN 时,Clash 也可以和公司 VPN 一起工作,而且内网访问通常更不容易被 Clash 抢走。顺序取决于应用有没有使用 Clash 的本地代理端口。

应用使用系统代理或手动配置 Clash 代理时,链路是:

应用
  ↓ 连接 Clash 本地 HTTP / SOCKS 端口
Clash 按域名规则判断
  ↓ 命中 DIRECT
Clash 作为普通进程发起直连
  ↓
系统 DNS / 系统路由继续生效
  ↓
公司域名进入公司 VPN,普通外网进入默认网络

应用没有使用代理时,链路更短:

应用
  ↓
系统 DNS / 系统路由
  ↓
公司 VPN 或普通网络

这种模式能共存的前提是:公司 VPN 已经把公司域名、公司内网 IP 或访问网关路由装好;Clash 只作为应用层代理存在,没有把系统 DNS 和系统路由整体接管。如果系统 DNS 仍被指到 Clash,或者 Clash 的 DNS 增强模式仍在返回 fake-ip,内网域名还是可能被改写,只是问题不再来自 TUN 路由捕获。

TUN 模式换了一个位置拦截。Clash / Mihomo 会创建一块虚拟网卡,再通过系统路由把更多目标地址导向这块网卡。应用不需要知道代理存在,它照常发 DNS 查询、照常连接某个 IP;系统路由把这批 IP 包送进 TUN 网卡后,Clash 再按规则决定走代理节点还是直连。

应用 / 命令行 / 构建工具
  ↓
DNS 得到目标 IP,可能是真实 IP,也可能是 Clash fake-ip
  ↓
系统路由表判断这个 IP 走哪块网卡
  ↓
命中 Clash TUN 路由时,IP 包进入 Clash 虚拟网卡
  ↓
Clash 根据规则选择 PROXY / DIRECT / REJECT
  ↓
Clash 自己再发起真正的出站连接

DIRECT 只表示 Clash 接到这条连接后不走代理节点,不能让系统跳过 Clash TUN。只要 DNS 或路由已经被 Clash 接管,流量就会先进 Clash,再由 Clash 决定下一跳。

公司 VPN / 零信任客户端也可能在本机创建自己的虚拟网卡和路由。内网服务要走通,关键是公司域名最终解析到公司 VPN 能识别的地址,并且系统路由把这个地址送进公司 VPN 的虚拟网卡,而不是送进 Clash TUN。

期望路径:
公司域名
  ↓
公司 DNS / VPN DNS
  ↓
公司 VPN 能识别的 IP
  ↓
系统路由进入公司 VPN 网卡
  ↓
公司网关 / 私服

常见错误路径一:
公司域名
  ↓
Clash DNS 返回 198.18.x.x fake-ip
  ↓
系统路由进入 Clash TUN
  ↓
公司 VPN 没有机会接管

常见错误路径二:
公司域名
  ↓
公司 DNS 返回内网 IP
  ↓
系统路由仍进入 Clash TUN
  ↓
请求卡在 Clash 到公司内网这一段

所以这里要同时处理两件事:DNS 不能把公司域名改成 Clash fake-ip,路由也不能把公司内网 IP 抓进 Clash TUN。fake-ip-filter 负责前半段,tun.route-exclude-address 负责后半段。

先判断卡在哪一层

macOS / zsh 下先看域名解析和路由。这里把真实私服域名写成占位符,避免把公司内部地址放进公开文章。

dig +short <NEXUS_HOST>
route -n get <NEXUS_HOST>
curl -I --connect-timeout 10 --max-time 18 https://<NEXUS_HOST>/repository/gradle/

这三条命令分别看三层问题。dig +short 只看域名最后解析成什么 IP,适合判断当前域名有没有落到 fake-ip,或者有没有拿到公司 DNS 返回的真实内网 IP。route -n get 看系统准备把这个目标 IP 从哪块网卡送出去,适合判断它是走 Clash TUN、普通 Wi-Fi,还是公司 VPN 的 utun 接口。curl -I 发一个只取响应头的 HTTPS 请求,适合判断服务本身能不能握手、能不能返回私服自己的响应。

curl 里的两个超时参数是为了让排障快一点失败。--connect-timeout 10 限制 TCP / TLS 连接阶段最多等 10 秒;--max-time 18 限制整个请求最多等 18 秒。内网路由没通时,不加这两个参数会让命令挂很久,影响判断节奏。

如果 dig 返回 198.18.x.x,通常说明域名被 Clash / Mihomo 的 fake-ip DNS 接管了。Mihomo DNS 文档里的示例也把 fake-ip-range 放在 198.18.0.1/16,这类地址不是公司内网私服的真实 IP。继续看 route,如果网关是 198.18.0.1,接口是 Clash TUN 对应的 utun,就说明请求还在外网代理的 TUN 里打转。

如果加完配置后 dig 不再返回 198.18.x.x,但直接没有结果,问题已经前进了一层:fake-ip 绕开了,内网 DNS 还没有接上。这个状态下 Gradle 常见报错会从 TLS 握手、连接超时,变成更直接的域名解析失败,例如 nodename nor servname provided, or not known

同一个公司根域名下也可能混着几类私服:有的只在 VPN 内网 DNS 里能解析,有的已经有固定公网入口,有的需要公司 DNS 和 host route 同时配。不要因为一个 Nexus 域名需要公司 DNS,就把整个公司根域名都交给同一个 DNS;这样可能会把本来可访问的 Artifactory 或 GitLab 覆盖掉。如果某个高频域名的 IP 已经稳定,直接写 hosts 往往比每次经 nameserver-policy 查询更稳。

如果 dig 能拿到公司内网 IP,但 curl 仍然连不上,再看路由是否真的进入公司 VPN 对应的接口。拿到 401403 或私服自己的响应,反而通常说明网络路径已经通了,后面才轮到认证、仓库路径或依赖版本问题。

macOS 上还可以临时绑定接口验证一次,不改系统路由,也不改 Clash 配置:

curl --interface <VPN_INTERFACE> \
  --resolve <NEXUS_HOST>:443:<NEXUS_IP> \
  -I https://<NEXUS_HOST>/repository/gradle/

--interface 会让 curl 这一次请求绑定到指定网卡,例如公司 VPN 对应的 utun4--resolve 会临时告诉 curl:访问这个 HTTPS 域名时直接连指定 IP,同时仍保留原域名作为 SNI 和 Host。这样可以绕开系统默认 DNS 和默认路由,只验证「这台机器通过公司 VPN 接口能不能访问这个私服」。如果这条能成功,默认请求失败,就能把问题缩小到系统路由或 Clash TUN 捕获。

如果绑定公司 VPN 接口后能拿到私服响应,默认访问却超时,说明私服本身可达,问题在 TUN 路由捕获。这时优先补 tun.route-exclude-address,不要继续在 DNS 上绕。

四段配置缺一不可

Clash Verge Rev 支持用 JavaScript 改写配置。官方 Script Configuration说明入口函数是 main(params),接收 YAML 配置转成的对象,返回修改后的对象。自定义规则要放在原规则前面,因为规则从上到下匹配,遇到 MATCH 后就不会继续往下走;Clash Verge Rev 的 Merge 文档也把这类写法放在 prepend-rules 里。

下面是可复用的 Script 版本。使用前把域名、DNS 和内网 IP 段换成自己公司的值。

function main(config) {
  const corpDomain = "corp.example.com";
  const fixedHosts = {
    [`gitlab.${corpDomain}`]: "10.0.0.10",
    [`nexus.${corpDomain}`]: "10.0.0.11",
    [`artifactory.${corpDomain}`]: "203.0.113.10",
  };
  const corpRouteExclude = [
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",
    "<NEXUS_IP>/32",
  ];
  const corpHosts = [
    `nexus.${corpDomain}`,
    `artifactory.${corpDomain}`,
    `gitlab.${corpDomain}`,
  ];

  const prependRules = [
    `DOMAIN-SUFFIX,${corpDomain},DIRECT`,
    ...corpHosts.map((host) => `DOMAIN,${host},DIRECT`),
  ];

  config.rules = [...prependRules, ...(config.rules || [])];

  config.dns = config.dns || {};

  const oldFakeIpFilter = config.dns["fake-ip-filter"] || [];
  config.dns["fake-ip-filter"] = Array.from(new Set([
    ...oldFakeIpFilter,
    `+.${corpDomain}`,
    corpDomain,
    ...corpHosts,
  ]));

  config.hosts = {
    ...(config.hosts || {}),
    ...fixedHosts,
  };

  config.tun = config.tun || {};
  const oldRouteExclude = config.tun["route-exclude-address"] || [];
  config.tun["route-exclude-address"] = Array.from(new Set([
    ...oldRouteExclude,
    ...corpRouteExclude,
  ]));

  return config;
}

这段配置分四层生效。

第一层是 DOMAIN-SUFFIX,...,DIRECTDIRECT 的意思是不走 Clash 的代理节点;如果流量已经进了 Clash,它仍然会先由 Clash 接住,再按直连方式出站。公司 VPN 正常安装路由时,直连出站才有机会被系统送进公司网络。

第二层是 fake-ip-filter。Mihomo 文档对 fake-ip-filter 的说明是:命中的地址不会下发 fake-ip 映射。公司域名如果还拿到 198.18.x.x,说明 DNS 已经被 Clash 改写;后面即使命中 DIRECT,也只是让 Clash 直连出站,不能保证请求回到公司 VPN 的 DNS 和路由链路。

第三层是 hosts,必要时才用 nameserver-policyhosts 适合处理已经确认的固定入口,能让 Clash DNS 直接返回结果,不再临时问上游 DNS。nameserver-policy 适合探索或处理确实需要公司 DNS 动态解析的域名;Mihomo 文档里的示例也包含了把内部域名交给内网 DNS 的写法,例如把 +.internal.crop.com 交给 10.0.0.1。这一步最容易误伤:如果公司 DNS 只认识其中一个私服,却把 +.corp.example.com 全部指过去,另一个私服可能从可访问变成解析失败;在 TUN 场景里,浏览器还可能直接表现成 DNS_PROBE_FINISHED_NXDOMAIN

第四层是 tun.route-exclude-address。Clash Verge Rev 的 Bypass 文档把 TUN 流量排除单独列成一类,Mihomo TUN 文档里也有 route-exclude-address 字段,用于在启用 auto-route 时排除自定义网段。这个字段解决的是另一种半通状态:公司域名已经解析成真实内网 IP,但 route -n get 仍显示流量走 Clash 的 TUN 网卡。此时 Gradle 可能不再报域名不存在,而是卡在 TLS 握手超时或被对端断开。

验证顺序

改完配置后,不要直接跑 Gradle。先用三条命令确认网络路径已经正确。

dig +short <NEXUS_HOST>
route -n get <NEXUS_HOST>
curl -I --connect-timeout 10 --max-time 18 https://<NEXUS_HOST>/repository/gradle/

几个状态可以快速判断:

现象 说明 下一步
dig 还是 198.18.x.x fake-ip-filter 没生效,或当前配置没有激活 检查 Script 是否启用、规则是否进入最终配置
dig 没结果 fake-ip 已绕开,但公司 DNS 没接上 nameserver-policy 里的占位符换成真实公司 DNS
某个私服被公司 DNS 解析失败,但固定 IP 可访问 这个私服不应该跟随根域名策略 给该私服加精确 hosts
dig @公司 DNS 正常,浏览器却 NXDOMAIN Clash DNS / TUN 里的动态策略不稳定 把高频域名改成 hosts 固定映射
有真实内网 IP,但 route 仍走 Clash TUN 域名解析对了,IP 流量还被 TUN 捕获 把这个 IP 或公司内网段加入 tun.route-exclude-address
绑定公司 VPN 接口后 curl 成功,默认访问超时 私服可达,但默认流量仍被 Clash TUN 接走 tun.route-exclude-address,再重启 Clash TUN
route 显示公司 VPN 接口,但 curl TLS 超时 路由方向对了,但私服链路或 TLS 仍未通 用同一网络下的浏览器、curl --interface 或公司 VPN 说明继续查证
curl 返回 401 / 403 / 私服响应 网络路径基本通了 回到 Gradle 认证、仓库路径和依赖版本

还有一种状态会出现在 Clash Verge 的 UI 配置里:已经在「排除自定义网段」里加了 <NEXUS_IP>/32,但 route -n get <NEXUS_IP> 仍然显示走 Clash TUN。这个时候要先确认最终生成配置里 tun.route-exclude-address 是否真的包含这条记录;如果最终配置已经包含但系统路由还没变,可能需要完整重启 Clash TUN、重新应用配置,或者临时加一条 macOS host route 先把主线走通:

sudo route -n add -host <NEXUS_IP> -interface <VPN_INTERFACE>
route -n get <NEXUS_IP>

sudo route -n add -host 会给某个单独 IP 加一条 host route。-host <NEXUS_IP> 表示只影响这个目标 IP,不影响整个网段;-interface <VPN_INTERFACE> 表示这个目标直接从公司 VPN 的接口走。后面的 route -n get 用来确认系统路由表是否真的变了。

如果提示路由已经存在,可以改用:

sudo route -n change -host <NEXUS_IP> -interface <VPN_INTERFACE>

changeadd 的区别是:add 新增一条不存在的路由,change 修改已有路由。Clash TUN 或公司 VPN 已经写过同一个目标时,add 可能提示已存在,这时用 change 把它改到公司 VPN 接口。它们都只是临时兜底,网络、VPN 或系统重启后可能失效。它们适合用来验证「只要流量进公司 VPN,私服就能访问」这个判断,不适合当长期配置。

Gradle 依赖下载只适合放在这些检查之后:

cd <ANDROID_PROJECT>

JAVA_HOME="<ANDROID_STUDIO_JBR>" \
ANDROID_HOME="$HOME/Library/Android/sdk" \
ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" \
./gradlew -I <INIT_SCRIPT> clean <BUILD_TASK> --console=plain --no-daemon

这条 Gradle 命令把 Java、Android SDK 和本次构建任务的前提都写在同一处。JAVA_HOME 指向 Android Studio 自带 JBR,避免系统没有默认 Java 或版本不对。ANDROID_HOME / ANDROID_SDK_ROOT 指向本机 Android SDK。-I <INIT_SCRIPT> 加载临时 init script,可以在不改项目源码的情况下补仓库配置、屏蔽本机访问不到的仓库,或者注入只对本机生效的构建兜底。clean <BUILD_TASK> 表示先清理再执行目标构建任务;--console=plain 让日志更适合复制;--no-daemon 避免 Gradle Daemon 缓存上一轮网络或 JVM 状态。

这里的 INIT_SCRIPT 不能替代内网 DNS 和公司 VPN。网络没通时,init script 只会把「插件找不到」推进到「私服域名连不上」。网络通了以后,它才适合处理项目仓库声明不完整、本机临时镜像、或者某个内部仓库在当前网络下不可解析这类构建层问题。

真正验证配置是否正确,最后还是要回到实际构建。一次完整的 Android release 构建能越过依赖解析、Kotlin / Java 编译、KAPT / Hilt、R8 和 APK 打包,说明这套网络配置已经覆盖了构建所需的私服链路。中间出现资源字符串、deprecated API 或 R8 optional dependency 的 warning,不一定代表网络还有问题;要看最终失败点是不是仍然停在域名解析、仓库访问或依赖下载。

一个容易误判的变化

198.18.x.x 变成解析失败,看起来像配置变坏了,其实通常是配置修到一半了。前者说明公司域名还在 Clash fake-ip 里,后者说明 fake-ip 已经绕开,但缺真实 DNS。

真正完整的状态应该是:

  1. 公司域名不再返回 198.18.x.x
  2. 公司域名能解析到真实内网 IP。
  3. route 显示请求会进入公司 VPN 能到达的路由,而不是 Clash TUN 网卡。
  4. curl 能拿到私服自己的响应。
  5. Gradle 再继续处理认证、插件仓库、Android SDK 和构建任务。

这类问题最麻烦的地方在于它同时跨了代理、DNS、系统路由和构建工具。把这几层拆开后,排查会轻很多:Clash 只负责外网和规则,TUN 负责接管流量,公司 VPN 负责内网路由,公司 DNS 负责内部域名。哪一层没有接上,命令输出会给出不同形态的失败。