中文 English

VPN 连上了,内网域名却死活访问不了?一次 macOS 路由表排查完整复盘

发布时间: 2026-06-01
macOS Network VPN Routing Route Troubleshooting Sysadmin Networking

一句话总结:

macOS 上同时跑了两个 VPN(utun0utun15),其中 utun0 推送了一条 10.0.0.0/8 的聚合路由,把目标 10.x.x.x 全部"吃"掉了——而我真正想走的是 utun15 对端的内网。DNS 能解析,TCP/ICMP 就是不通。route -n get 是定位这类问题的第一把刀

macOS 路由表查找:最长前缀匹配流程

图 1:macOS 内核路由查找流程。从应用进程发出目的 IP,内核按"最长前缀匹配"在路由表里挑一条,匹配上 10.0.0.0/8 就走 utun0,匹配上更精确的 /32 就走 utun15。


问题背景:连上 VPN 后,内网域名访问不了

事情是这样的——我在远程一台 macOS 工作站上同时挂着两个 VPN:

VPN-B 拨号成功后,ifconfig utun15 正常拿到 IP,路由表里多了一条 default → 10.255.255.1 utun15 的兜底路由。

但当我打开浏览器访问几个内网域名时,发现:

DNS 解析没问题,但流量根本到不了。 显然,这是个 L3(路由层)的问题,而不是 L7(应用层)的问题。

更诡异的是,ping 这个域名对应的 IP 也不通:

PING 10.99.99.99 (10.99.99.99): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
--- 10.99.99.99 ping statistics ---
2 packets transmitted, 0 packets received, 100% packet loss

但同样的机器,访问其他几个内网 IP(10.19.x、10.20.x、10.127.x)却都正常。

为什么偏偏 10.99.99.99 不行?


问题分析:路由表的视角

排查路由问题,最快的办法就是把路由表打印出来。我直接 SSH 到那台机器,跑:

netstat -rn

输出节选(关键的几条):

Internet:
Destination        Gateway            Flags     Netif
default            10.255.255.1      UGScg     utun15
10                 utun0             USc       utun0
10.255.255.1/32    127.0.0.1         UGSc      lo0
...

注意第一条 10 utun0 USc——这表示 凡是目的地址落在 10.0.0.0/8 范围(即 10.0.0.0 ~ 10.255.255.255)的包,全部走 utun0

utun15 这边只推送了 default + 几个具体的 /32 路由(DNS 服务器等),没有 10.99.0.0/16 这种网段路由

也就是说:

这就是问题所在。

但仅仅是"走了 utun0"为什么就不通?两个原因:

  1. utun0 对端的 VPN-A 网关并不知道 10.99.99.99 在哪——它自身只承载部分内网子网
  2. 即使对端能转发,TCP 回程包也未必能回到我的机器(因为 10.99.99.99 回包时,它的路由会先查它自己那侧的路由表)

无论哪种情况,包发出去了但回不来,或者对端直接丢弃。


问题根因:最长前缀匹配的"陷阱"

macOS(以及所有 BSD/Linux)的内核在做路由查找时,使用 最长前缀匹配(Longest Prefix Match, LPM) 规则:

在路由表中,挑选前缀长度最长的那条路由作为最终决策;如果多条同样长,再看 metric。

这里有两层容易踩坑的地方:

坑 1:聚合路由"太贪心"

utun0 上的 10 utun0 USc 表示 10.0.0.0/8,前缀长度只有 8。它能"覆盖"的地址范围高达 16,777,216 个 IP,10.99.99.99 自然落在其中。

只要没有任何更精确的路由覆盖 10.99.99.99,它就会被这条 /8 路由截胡

坑 2:VPN 客户端推送路由的"沉默覆盖"

utun0 是系统层自动建立的全路由 VPN,每次连接都会自动塞这条 /8 聚合进路由表;用户毫无感知。

utun15 是按需拨号的,它推送的路由里没包含 10.99.0.0/16 段,客户端不会自动补全

两者一叠加,目标 IP 就被 utun0 静默接管了。

双 VPN 路由冲突拓扑图:utun0 推送 /8 聚合,utun15 缺少 /16 网段路由,10.99.99.99 走错接口

图 2:双 VPN 同时存在时的路由冲突。utun0 推送了覆盖整个 10.0.0.0/8 的聚合路由,utun15 没有 10.99.0.0/16 路由——结果 10.99.99.99 走了 utun0 而不是 utun15。


验证问题:route -n get 是最直接的诊断工具

怀疑路由有问题时,不要靠猜,直接用 route -n get <目标IP> 让内核告诉你答案:

$ route -n get 10.99.99.99

   route to: 10.99.99.99
destination: 10.99.99.99
  interface: utun0        ← 关键看这一行
      flags: <UP,HOST,DONE,WASCLONED,IFSCOPE,IFREF>

如果输出的 interface 不是你期望的那个(这里是 utun15),就坐实了"路由选错了"。

接着对照路由表,看 utun0 上为什么匹配上:

$ netstat -rn | grep -E "^10 |utun15|default"
default            10.255.255.1      UGScg     utun15
10                 utun0             USc       utun0
10.19.180.31/32    10.255.255.1      UGHS      utun15

可以看到 utun15 这边只有几个 /32,没有 10.99.x 任何路由;于是 /8 聚合在 LPM 中"胜出"。


解决问题:加一条更精确的路由

思路:让内核在为 10.99.99.99 选路时,能找到一条前缀长度比 /8 更长的路由(哪怕只是个 /32),就能压过 utun0 的聚合。

macOS 加静态路由的命令是 route -n add

# 单 IP 走 utun15
sudo route -n add -host 10.99.99.99 -interface utun15

# 整个 10.99.0.0/16 网段都走 utun15
sudo route -n add -net 10.99.0.0/16 -interface utun15

注意几个语法点:

执行后再次验证:

$ route -n get 10.99.99.99

   route to: 10.99.99.99
destination: 10.99.99.99
  interface: utun15        ← 现在对了
      flags: <UP,HOST,DONE,WASCLONED,IFSCOPE,IFREF>

$ ping -c 3 10.99.99.99
PING 10.99.99.99 (10.99.99.99): 56 data bytes
64 bytes from 10.99.99.99: icmp_seq=0 ttl=63 time=12.4 ms
64 bytes from 10.99.99.99: icmp_seq=1 ttl=63 time=11.8 ms
64 bytes from 10.99.99.99: icmp_seq=2 ttl=63 time=12.1 ms
--- 10.99.99.99 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss

通了。

修复前后路由表与 ping 对比:左红 / 右绿

图 3:修复前后的 route -n get 输出与 ping 结果对比。左边:包被发往 utun0,ping 100% 丢包。右边:手动添加 /32 主机路由后,包从 utun15 顺利到达。


让路由永久生效:launchd 启动项

route -n add 加的路由只在当前会话有效,重启或者 VPN 重连后会丢失。要让路由在系统启动后自动重建,有几种方案。

方案 1:launchd 启动任务(推荐)

macOS 上最稳妥的方式是写一个 launchd plist 放到 /Library/LaunchDaemons/

sudo tee /Library/LaunchDaemons/com.local.vpn-route-fix.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.local.vpn-route-fix</string>
    <key>ProgramArguments</key>
    <array>
        <string>/sbin/route</string>
        <string>-n</string>
        <string>add</string>
        <string>-host</string>
        <string>10.99.99.99</string>
        <string>-interface</string>
        <string>utun15</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
    <key>UserName</key>
    <string>root</string>
</dict>
</plist>
PLIST

sudo chmod 644 /Library/LaunchDaemons/com.local.vpn-route-fix.plist
sudo launchctl load -w /Library/LaunchDaemons/com.local.vpn-route-fix.plist

RunAtLoad 设为 true 表示系统启动时自动执行。UserName 设为 root 是因为 route 命令需要 sudo 权限。

注意:launchd 任务在系统启动时就会执行,但此时 utun15 还没建立好。所以更稳妥的做法是写个 shell 脚本,先 sleep 等接口出现再加路由。

改进版本——用一个 shell 脚本做包装:

# /usr/local/bin/vpn-route-fix.sh
#!/bin/sh
# 等待 utun15 接口就绪
for i in $(seq 1 30); do
    if ifconfig utun15 >/dev/null 2>&1; then
        break
    fi
    sleep 1
done
# 如果接口存在,加路由
if ifconfig utun15 >/dev/null 2>&1; then
    /sbin/route -n add -host 10.99.99.99 -interface utun15
fi
sudo chmod +x /usr/local/bin/vpn-route-fix.sh

然后 plist 里 ProgramArguments 改成执行这个脚本即可。

方案 2:写到 rc.local / StartupItem

旧式做法。新版 macOS 已经废弃了 /etc/rc.local,不推荐。

方案 3:交给 VPN 客户端

如果 VPN-B 的客户端支持 “Connect Script” 或 “Post-Connect Hook”(OpenConnect、Viscosity、Cloudflare WARP 等大多支持),把上面那段 route -n add 命令填进去最干净,VPN 一连上就立刻生效,不用关心启动顺序。

方案 4(最推荐):让 VPN 服务端推送

最根本的解决办法是联系 VPN-B 的管理员,在服务端配置里把 10.99.0.0/16 推送给客户端。这样:

如果需要支持多个内网段,逐条加就行,直到不再有人工改路由表的需求。


macOS 路由表常用命令速查

最后整理一份日常会用到的命令清单:

查看路由表

# 默认格式(IPv4 + IPv6 全部,flags 字母可读性更好)
netstat -rn

# 数字格式,无 DNS 解析
netstat -rn -f inet

# 看默认路由是哪条
netstat -rn -f inet | grep "^default"

# 看某个目标 IP 走哪条出口
route -n get 8.8.8.8

加 / 删 / 改路由

# 加 /32 主机路由
sudo route -n add -host 10.99.99.99 -interface utun15

# 加 /24 网段路由(指定网关)
sudo route -n add -net 10.99.0.0/24 10.255.255.1

# 加默认路由(一般没必要,会被系统自动覆盖)
sudo route -n add default 10.255.255.1

# 删一条
sudo route -n delete -host 10.99.99.99 -interface utun15
sudo route -n delete -net 10.99.0.0/24 10.255.255.1

# 改一条(先 delete 再 add)
sudo route -n change -host 10.99.99.99 -interface utun0

临时禁用某个接口

# 临时关掉 utun0,强制所有 10.x 走 utun15
sudo ifconfig utun0 down
sudo ifconfig utun0 up

监控路由变化

# 持续观察路由表变化(调试 VPN 客户端的路由推送)
watch -n 1 'netstat -rn -f inet'

清除所有手动加的路由

# 注意:只清你自己加的,不要动 default 和系统路由
# 最稳妥的是重启机器
sudo reboot

Q&A

Q1:能不能直接删掉 utun0 上的 10.0.0.0/8 路由?

技术上可以:

sudo route -n delete -net 10.0.0.0/8 -interface utun0

通常不建议,原因:

更安全的做法是精确加 /32 路由(只覆盖需要的目标),而不是粗暴删除聚合路由。

Q2:加了路由还是不通怎么办?

按下面的顺序排查:

  1. 接口名字是否写错? ifconfig | grep utun 确认实际接口名
  2. VPN 本身是否真的通了? ping 10.255.255.1(对端网关)测试
  3. DNS 解析是否对? dig <hostname>nslookup <hostname> 确认拿到的 IP
  4. 对端防火墙/ACL 是否放行?联系 VPN-B 管理员确认 10.99.99.99 在内网可达
  5. TCP/UDP 端口 是否被中间设备拦截?telnet 10.99.99.99 80nc -zv 10.99.99.99 80

Q3:route -n addroute add 有什么区别?

实际效果一样,脚本里始终用 -n,避免意外。

Q4:能不能在 GUI 里加永久路由?

系统偏好设置 → 网络 → 你要配置的接口 → 高级 → 路由,可以加 IPv4 路由。

但它只对那个物理接口生效,对 utun VPN 接口不友好(每次 VPN 重连 utun 编号会变)。所以命令行 + launchd 更可靠

Q5:怎么知道 VPN-B 客户端推送了哪些路由?

一般 VPN 客户端在连接时日志里会打印,比如 OpenConnect 会显示:

ESP detected: using UDP
Connected as 10.255.255.x
Pushed routes:
  0.0.0.0/0
  10.0.0.0/8 via 10.255.255.1
  ...

也可以连接后立即跑 netstat -rn 对比前后变化。如果不放心,加上 -v 跑:

sudo route -n monitor

会实时打印路由表变更事件。

Q6:utun 编号为什么是动态的?

macOS 给每个 VPN/tunnel 接口分配一个 utun 编号,编号不固定。今天可能是 utun15,明天连另一个 VPN 就变成 utun16。

所以任何脚本里都不要硬编码 utun 编号。可以基于以下信息识别:

Q7:route -n add 加的路由,重启后会消失吗?

route -n add 是在内核路由表里临时插入条目,系统重启或路由守护进程(如 routed)重新加载配置时都会丢失。

要持久化必须用上面提到的 launchd 方案,或者在 VPN 客户端的 hook 脚本里加。

Q8:能不能在 ~/.zshrc 里加 route 命令?

强烈不建议

老老实实写 launchd plist

Q9:什么是 “metric”,在路由选择里起什么作用?

metric 是路由的"代价",数字越小越优先。route -n get 输出里能看到 rtt,msechopcount,但主要看 metric:

$ route -n get 8.8.8.8
...
      flags: <UP,GATEWAY,IFSCOPE>
   recvpipe  sendpipe  ssthresh  rtt,msec    rttvar  hopcount
        0         0         0         0         0         0

如果两条路由前缀长度相同,metric 小的胜出。netstat -rn 里 flags 后面跟的 S(static)和 D(dynamic)等只是来源标识,不影响 metric。

Q10:IPv6 路由表也要单独看吗?

是的。netstat -rn 默认会混着打印 IPv4 和 IPv6,最好分开:

netstat -rn -f inet     # 仅 IPv4
netstat -rn -f inet6    # 仅 IPv6

如果你用的是纯 IPv4 业务,IPv6 路由一般不用管;但如果内网是双栈,IPv6 路由不对也会出现"打不开网页"的现象。


总结

这次排查的整个过程复盘下来,核心其实就三件事:

  1. netstat -rn — 全局掌握当前路由表
  2. route -n get <目标 IP> — 验证某个目标具体走哪条出口
  3. 加 /32 主机路由或 /16 网段路由 — 用最长前缀匹配压过错误的聚合路由

VPN 路由问题的难点不在命令本身,而在于:

route -n get 这个命令刻进肌肉记忆,下次遇到"VPN 通了但某段内网访问不了"的问题时,三秒钟就能定位。


参考资料