VPN 连上了,内网域名却死活访问不了?一次 macOS 路由表排查完整复盘
一句话总结:
macOS 上同时跑了两个 VPN(
utun0、utun15),其中utun0推送了一条10.0.0.0/8的聚合路由,把目标10.x.x.x全部"吃"掉了——而我真正想走的是utun15对端的内网。DNS 能解析,TCP/ICMP 就是不通。route -n get是定位这类问题的第一把刀。
图 1:macOS 内核路由查找流程。从应用进程发出目的 IP,内核按"最长前缀匹配"在路由表里挑一条,匹配上 10.0.0.0/8 就走 utun0,匹配上更精确的 /32 就走 utun15。
问题背景:连上 VPN 后,内网域名访问不了
事情是这样的——我在远程一台 macOS 工作站上同时挂着两个 VPN:
- VPN-A(系统层
utun0):覆盖 10.0.0.0/8 大段网,是工作日常用的隧道 - VPN-B(公司分配
utun15):客户端拨号建立,IP 段10.255.255.0/24,对端是公司内网
VPN-B 拨号成功后,ifconfig utun15 正常拿到 IP,路由表里多了一条 default → 10.255.255.1 utun15 的兜底路由。
但当我打开浏览器访问几个内网域名时,发现:
curl http://internal-nas.example.corp/— 卡住,最终 timeoutcurl http://internal-nas.example.corp/(全限定域名)— 一样卡住- 但是
nslookup internal-nas.example.corp却能正常返回 IP,比如10.99.99.99
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 这种网段路由。
也就是说:
- 10.19.180.31(DNS)有 /32 主机路由 → 走 utun15 ✅
- 10.99.99.99(NAS)没有 /32,匹配到 utun0 的 /8 聚合 → 走 utun0 ❌
这就是问题所在。
但仅仅是"走了 utun0"为什么就不通?两个原因:
utun0对端的 VPN-A 网关并不知道 10.99.99.99 在哪——它自身只承载部分内网子网- 即使对端能转发,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 静默接管了。
图 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
注意几个语法点:
-host等价于-net 10.99.99.99/32,加 /32 主机路由-interface utun15直接绑定到接口,不需要指定网关(因为是点对点接口,接口本身就是网关)- 命令的
-n是"不解析名字",影响显示,不影响行为
执行后再次验证:
$ 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
通了。
图 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 推送给客户端。这样:
- 客户端零配置
- VPN-B 重连后路由自动恢复
- 其他同事也都能用,不限于你一台机器
如果需要支持多个内网段,逐条加就行,直到不再有人工改路由表的需求。
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
但通常不建议,原因:
- utun0 上其他正常工作的 IP(10.19.x、10.20.x、10.127.x)会失去路由
- VPN-A 重连后会自动重新推送这条路由,手动删只是临时
- 删了之后万一 utun15 挂了,10.x 全断
更安全的做法是精确加 /32 路由(只覆盖需要的目标),而不是粗暴删除聚合路由。
Q2:加了路由还是不通怎么办?
按下面的顺序排查:
- 接口名字是否写错?
ifconfig | grep utun确认实际接口名 - VPN 本身是否真的通了?
ping 10.255.255.1(对端网关)测试 - DNS 解析是否对?
dig <hostname>或nslookup <hostname>确认拿到的 IP - 对端防火墙/ACL 是否放行?联系 VPN-B 管理员确认 10.99.99.99 在内网可达
- TCP/UDP 端口 是否被中间设备拦截?
telnet 10.99.99.99 80或nc -zv 10.99.99.99 80
Q3:route -n add 和 route add 有什么区别?
route -n add表示不解析名字(不把 IP 反查成域名、不把 hostname 解析成 IP),适合脚本里写route add会尝试解析参数,可能在 DNS 配置有问题时表现异常
实际效果一样,脚本里始终用 -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 编号。可以基于以下信息识别:
- 接口的 IP(在
ifconfig输出里匹配) - 接口的描述(
ifconfig里interface-id行) - 路由表里
default指向哪个 utun
Q7:route -n add 加的路由,重启后会消失吗?
会。route -n add 是在内核路由表里临时插入条目,系统重启或路由守护进程(如 routed)重新加载配置时都会丢失。
要持久化必须用上面提到的 launchd 方案,或者在 VPN 客户端的 hook 脚本里加。
Q8:能不能在 ~/.zshrc 里加 route 命令?
强烈不建议:
~/.zshrc在每次开新 shell 都会执行,而route -n add已经存在的路由会报 “File exists” 错误- 用户的 shell 不一定用 zsh(iTerm2、SSH 远程登录、launchd 起的进程可能都不读 zshrc)
- 需要 sudo 的命令放在用户登录脚本里非常诡异
老老实实写 launchd plist。
Q9:什么是 “metric”,在路由选择里起什么作用?
metric 是路由的"代价",数字越小越优先。route -n get 输出里能看到 rtt,msec 和 hopcount,但主要看 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 路由不对也会出现"打不开网页"的现象。
总结
这次排查的整个过程复盘下来,核心其实就三件事:
- 看
netstat -rn— 全局掌握当前路由表 - 跑
route -n get <目标 IP>— 验证某个目标具体走哪条出口 - 加 /32 主机路由或 /16 网段路由 — 用最长前缀匹配压过错误的聚合路由
VPN 路由问题的难点不在命令本身,而在于:
- 看不见——路由表是个抽象的列表,VPN 客户端的"自动推送"行为对用户透明
- DNS 正常时容易误判——很多人看到
nslookup能返回 IP 就以为网络通了,其实 L3 还没走通 - 聚合路由的"沉默覆盖"——
10.0.0.0/8这类大段路由被某个 VPN 推上来时,会无声无息接管一大片地址
把 route -n get 这个命令刻进肌肉记忆,下次遇到"VPN 通了但某段内网访问不了"的问题时,三秒钟就能定位。