Mac 上 VPN 已连接但 ip-up 从未触发:用 launchd 监视它
内置的 Cisco IPSec 与 IKEv2 隧道从不启动 pppd,所以 /etc/ppp/ip-up 一直沉默。监视配置目录,并让脚本证明隧道确实存在。
你在 /etc/ppp/ip-up 里放了一条 route 命令,重新连接公司隧道,文件却从不执行。脚本本身没有问题:那条连接根本不经过 PPP。内置的 Cisco IPSec 与 IKEv2 客户端从不启动 pppd,因此钩子目录永远不会被读取,而 PPTP 和 IPSec 上的 L2TP 仍然会经过它。先确定你属于哪种情况,再用一个 launchd 监视任务触发非 PPP 的那种。
修改任何钩子之前,先确认接口
两条命令就能判定哪种机制可行。先列出已配置的服务,再列出隧道建立期间存在的接口。接口名称才是证据,系统设置里的标签不是。
scutil --nc list
ifconfig -l | tr ' ' '\n' | grep -E '^(utun|ppp)[0-9]+$'
产生 ppp0 的服务会执行 PPP 钩子,也就会运行你的文件。Cisco IPSec 或 IKEv2 服务则产生 utun 设备,那是没有 PPP 层的隧道接口,所以 /etc/ppp 里的任何东西都碰不到它。两种同时出现时,要检查你连的是哪个服务。很多人只读客户端名字,就在错误的钩子上浪费一下午。
走 PPP 这条路,文件必须属于 root 且可执行,因为 pppd 在运行任何东西之前会检查这两项。install 一步就能设好属主和权限。
sudo install -o root -g wheel -m 0755 /dev/null /etc/ppp/ip-up
sudo ls -l /etc/ppp/ip-up
通过 Finder 或图形编辑器创建的文件常常归你的账户所有,或者少了执行位,于是和缺失的钩子一样悄无声息地失败。现在就把 ip-down 放在它旁边。属主列里是 root 和 wheel、权限列里是 rwxr-xr-x,才是你要的状态。
让 launchd 在隧道出现时触发脚本
对从不碰 PPP 的客户端,触发必须来自别处,而系统配置存储是可靠的来源:macOS 在每次隧道切换时都会重写它。监视该目录的 launchd 任务会在隧道出现或消失时被调用。
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>WatchPaths</key>
<array><string>/Library/Preferences/SystemConfiguration</string></array>
<key>Label</key><string>net.example.tunnel-watch</string>
<key>ProgramArguments</key>
<array><string>/bin/sh</string><string>/usr/local/sbin/tunnel-hook</string></array>
<key>RunAtLoad</key><true/>
<key>ThrottleInterval</key><integer>20</integer>
</dict>
</plist>
把它装成 launch daemon,而不是按用户的代理,因为路由修复属于机器。bootstrap 在当前版本中取代旧的 load -w 写法。RunAtLoad 让首次执行可被观察,ThrottleInterval 则阻止一串 Wi-Fi 连接变化反复启动脚本。
sudo install -o root -g wheel -m 0644 tunnel-watch.plist \
/Library/LaunchDaemons/net.example.tunnel-watch.plist
sudo launchctl bootstrap system \
/Library/LaunchDaemons/net.example.tunnel-watch.plist
陷阱在于,被监视的目录也会因为与 VPN 无关的事件而变化:连接 Wi-Fi、续租 DHCP、插上网线。下面这段守卫把吵闹的触发变成可用的钩子,一行日志则解释某个奇怪时段出现的路由。
所有权既覆盖脚本,也覆盖 plist。一个守护进程如果执行的是你账户能写入的目录里的文件,就等于把 root 交给了那个账户。
保护脚本,让第二次运行什么都不改
脚本先判断隧道是否存在,不存在就安静退出。之后的一切都必须可以安全重复,因为触发远比一次连接频繁。接口要在运行时解析,而不是写死一个下次连接就会变的索引。
#!/bin/sh
set -u
TUN=$(ifconfig -l | tr ' ' '\n' | grep -m1 '^utun[0-9]' || true)
[ -n "$TUN" ] || exit 0
netstat -rn -f inet | grep -q '^10\.42' && exit 0
route -n add -net 10.42.0.0/16 -interface "$TUN"
route add 在路由已存在时会以非零状态退出,所以把任何非零状态都当失败的脚本,每次做对事都会报一次钩子损坏。上面的 netstat 测试把这种情况变成显式的空操作。
接口存在比看上去更弱的证据,因为其他 VPN 客户端和一些容器运行时也会创建 utun 设备。用 scutil --nc status "Office VPN" 去问服务本身,并在动路由之前要求已连接状态。把答案留在日志行里,因为一周之后那条记录会是你唯一的解释。
在不破坏 DNS 的前提下收回网关
当隧道接管默认路由,普通浏览就从公司出去,本地互联网路径随之失效。先抓取物理接口的路由器,再把默认路由指回它。DHCP 胜过解析一张已经含有隧道自身默认路由的表。
PHYS=en0
GW=$(ipconfig getoption "$PHYS" router)
[ -n "$GW" ] && sudo route -n change default "$GW"
解析器是独立的一层、有独立的工具,修路由并不会修它们。用 scutil --dns 看当前生效的配置,然后修正产生它的东西:服务顺序和物理服务上的 DNS 条目。清缓存用于诊断切换网络后卡住的解析,不是该排进计划的事。
隧道扛过了 Wi-Fi 切换、DNS 却卡住,这是第三种故障:sudo killall -HUP mDNSResponder 会让解析器重新读取配置。用手工执行一次就好。
证明任务会自己触发
信任监视路径之前,先用 kickstart -k 强制跑一次,再读任务记录的退出状态。干净退出证明脚本能用,但不证明触发能用。然后在任务已加载的情况下断开再重连,观察有没有你没要求过的运行。
sudo launchctl kickstart -k system/net.example.tunnel-watch
sudo launchctl print system/net.example.tunnel-watch | grep -E 'last exit code|state ='
每次尝试前后都用 scutil --nc status 检查。隧道断开时跑过的脚本,对连接路径什么也证明不了。
拆除分支值得和连接分支同样的待遇:手工删掉路由,让任务跑一次,确认它把路由加回来。只加路由、从不删除的钩子,会留下一台一周后行为异常的机器,而没有任何东西把两件事联系起来。两种行为都成立之后,用 sudo launchctl bootout system/net.example.tunnel-watch 干净地退役这个任务。把监视守护进程留在加载状态,正是一次性修复在几个月后变成无法解释的路由的方式。