返回博客

curl 能通、浏览器不通——macOS TUN 代理劫持 LAN 流量的排查全记录

内网联调是家常便饭。某天我在自己的 Mac 上打开 Chrome 访问局域网开发服务器,迎头撞上 ERR_CONNECTION_CLOSED。加了 hosts、折腾了证书、禁用了插件,错误码依次变成 ERR_TUNNEL_CONNECTION_FAILED 再到 ERR_ADDRESS_UNREACHABLE——像在剥洋葱,每剥一层又是一层。与此同时终端里 curl 直接拿到 200 OK,ping 也畅通无阻。

这是我踩过的最迷惑的网络坑之一,根因与证书、hosts、Chrome 插件统统无关。

现象

  • 浏览器(Chrome / Safari / Edge)访问局域网 IP 或私有 TLD(.internal / .lan):ERR_CONNECTION_CLOSED → 折腾后变 ERR_TUNNEL_CONNECTION_FAILED → 再折腾变 ERR_ADDRESS_UNREACHABLE
  • DevTools 网络面板主文档状态显示「已屏蔽:来源」
  • 终端 ping <局域网 IP> 全程 0% 丢包
  • 终端 curl https://<局域网地址>/ 正常返回响应
  • scutil --proxy 显示 HTTPProxy: 127.0.0.1:<某端口>HTTPEnable: 1
  • 但对每一个网络服务执行 networksetup -getwebproxy <SVC> 均返回 Enabled: No

根因

机器上装了翻墙代理工具(Shadowrocket / Clash / Mihomo / Surge 等),且开启了 TUN 模式

TUN 模式的实现原理:代理 App 通过 macOS NEPacketTunnelProvider(Network Extension 框架)创建一块虚拟网卡 utunN,并将其设为系统默认路由的 PrimaryInterface。所有 GUI 应用(包括浏览器)发出的 TCP 流量,在进入内核路由之前就被 NE 框架的 flow handler 截获,走进 utun 隧道。

networksetup -setproxybypassdomains 操作的是 macOS「经典代理」配置,TUN 模式完全绕过了这一层,bypass 列表在此场景下形同虚设。

坑:错误码会随你修复表层问题而漂移,但根因始终未变

ERR_CONNECTION_CLOSEDERR_TUNNEL_CONNECTION_FAILEDERR_ADDRESS_UNREACHABLE,这三个错误码只是 Chrome 连接状态机在不同层面的表达,背后都是同一件事:TUN 代理截走了流量且未能正确转发到局域网目标。每次你「修好一个问题」其实只是触发了下一层的错误展示。

同理,ping 成功不等于 TCP 畅通——ICMP 走的是 raw socket,部分 NE 扩展不拦截,且 ping 本身会走已连接路由(connected route);而 curl 在无 http_proxy 环境变量时通过 en0 的 connected route 直出,不经 NE 截获。不要把这两个成功当作"网络没问题"的证据。

快速诊断

一条命令确认是否是 TUN 代理问题:

echo "=== 系统代理 ===" && scutil --proxy | grep -E "HTTPEnable|HTTPProxy|HTTPPort"
echo "=== 主网卡 ===" && echo 'show State:/Network/Global/IPv4' | scutil | grep PrimaryInterface
echo "=== 默认路由 ===" && netstat -rn -f inet | awk '$1=="default"'
echo "=== 谁在监听代理端口 ==="
PORT=$(scutil --proxy | awk '/HTTPPort/{print $3}')
lsof -nP -iTCP:$PORT -sTCP:LISTEN

红旗信号PrimaryInterfaceutun8(任意 utunN),lsof 显示是 PacketTunnel / mihomo / sing-box 等进程。命中即确诊。

正解

正解:在代理 App 内部添加局域网 DIRECT 规则,而不是改 macOS 系统代理

修复必须在代理工具自身的规则里做,networksetup 对 TUN 模式无效。

Clash / Mihomo / Stash——编辑配置 YAML 的 rules: 区块顶部加入:

rules:
  - DOMAIN-SUFFIX,internal,DIRECT
  - DOMAIN-SUFFIX,lan,DIRECT
  - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
  - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
  # ... 其余规则

Surge——[Rule] 区块加:

DOMAIN-SUFFIX,internal,DIRECT,no-resolve
IP-CIDR,192.168.0.0/16,DIRECT,no-resolve

no-resolve 是关键——没有它,代理可能会把 IP 字面量拿去公网 DNS 解析(是的,真的会),然后超时。

如果加完规则仍不生效,备用方案按优先级:

  1. 直接用 IP 访问http://192.168.x.x/),IP-CIDR 规则优先于 DNS 步骤,最可靠
  2. 用 Firefox,在其自身网络设置里配「不使用代理」的 CIDR,Firefox 在应用层就把流量走 en0,不进 utun
  3. 关闭 TUN 模式,切回 HTTP/SOCKS 代理模式,此时 networksetup bypass 列表才重新生效

别尝试 kill PacketTunnel 进程——NetworkExtension 框架会在几秒内把它重启。

验证

修完后,在局域网服务器上查访问日志,确认来源 IP 是你 Mac 的 en0 真实 IP(如 192.168.1.50),而不是 utun 隧道网关地址(通常是 198.18.x.x)。真实 IP 出现 = 流量已走 en0,修复生效。

一句话外卖

在 macOS 上,curl 通了不代表浏览器能通——TUN 代理在内核路由之前截获 GUI 流量,networksetup bypass 列表对它毫无影响,唯一出路是在代理 App 自己的规则里放行局域网 CIDR。