You put a route command in /etc/ppp/ip-up, reconnect the office tunnel, and the file never runs. Nothing is wrong with the script: that connection does not pass through PPP at all. The built-in Cisco IPSec and IKEv2 clients never start pppd, so the hook directory is never read, while PPTP and L2TP over IPSec still go through it. Work out which case you have, then trigger the non-PPP one from a launchd watch job.

Check the interface before you edit any hook

Two commands settle which mechanism can work. List the configured services, then list the interfaces that exist while the tunnel is up. The interface name is the evidence; the label in System Settings is not.

scutil --nc list
ifconfig -l | tr ' ' '\n' | grep -E '^(utun|ppp)[0-9]+$'

A service that produces ppp0 runs the PPP hooks and will execute your file. A Cisco IPSec or IKEv2 service produces a utun device instead, a tunnel interface with no PPP layer, so nothing inside /etc/ppp can reach it. When both kinds appear, check which service you connected. People waste an afternoon on the wrong hook by reading the client name.

On the PPP path the file has to be owned by root and executable, because pppd checks both before running anything. install sets owner and mode in one step.

sudo install -o root -g wheel -m 0755 /dev/null /etc/ppp/ip-up
sudo ls -l /etc/ppp/ip-up

A file created through Finder or a graphical editor often ends up owned by your account, or without the execute bit, and then fails as silently as the missing hook did. Put ip-down beside it now. root and wheel in the owner column plus rwxr-xr-x in the mode column is what you want.

Let launchd fire the script when a tunnel appears

For clients that never touch PPP the trigger must come from elsewhere, and the system configuration store is the honest source: macOS rewrites it on every tunnel transition. A launchd job watching that directory gets called when a tunnel appears or disappears.

<?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>

Install it as a launch daemon, not a per-user agent, because route repair belongs to the machine. bootstrap replaces the older load -w form on current releases. RunAtLoad makes the first execution observable, and ThrottleInterval stops a burst of Wi-Fi-join changes from launching the script repeatedly.

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

The trap is that the watched directory changes for events with nothing to do with your VPN: a Wi-Fi join, a DHCP renewal, a cable being plugged in. The guard below turns a noisy trigger into a usable hook, and a log line explains an odd-hour route.

Ownership covers the plist as well as the script. A daemon that executes a file in a directory your own account can write hands that account root.

Guard the script so a second run changes nothing

The script first decides whether the tunnel exists, and exits quietly when it does not. Everything after that must be safe to repeat, because the trigger fires far more often than a single connect. Resolve the interface at runtime rather than hardcoding an index that changes on the next connect.

#!/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 exits nonzero when the route is already present, so a script that treats every nonzero status as failure reports a broken hook each time it does the right thing. The netstat test above turns that case into an explicit no-op.

Interface presence is weaker evidence than it looks, since other VPN clients and some container runtimes also create utun devices. Ask the service itself with scutil --nc status "Office VPN" and require the connected state before touching routing. Keep the answer in a log line, because a week later that record is the only explanation you will have.

Bring the gateway back without breaking DNS

When the tunnel takes over the default route, ordinary browsing leaves through the office and the local internet path stops working. Capture the physical interface's router first, then point the default back at it. DHCP beats parsing a table that already holds the tunnel's own default.

PHYS=en0
GW=$(ipconfig getoption "$PHYS" router)
[ -n "$GW" ] && sudo route -n change default "$GW"

Resolvers are a separate layer with a separate tool, and repairing the route does not repair them. Look at what is in effect with scutil --dns, then fix what produces it: the service order and the DNS entries on the physical service. Flushing the cache diagnoses a lookup stuck after a network switch; it is not something to schedule.

A tunnel that survives a Wi-Fi change while DNS stalls is a third fault: sudo killall -HUP mDNSResponder makes the resolver re-read its configuration. Use it once, by hand.

Prove the job fires on its own

Force one run with kickstart -k before trusting the watch path, then read the exit status the job recorded. A clean exit proves the script works; it does not prove the trigger works. Then disconnect and reconnect with the job loaded, and watch for a run you did not ask for.

sudo launchctl kickstart -k system/net.example.tunnel-watch
sudo launchctl print system/net.example.tunnel-watch | grep -E 'last exit code|state ='

Check scutil --nc status before and after each attempt. A script that ran while the tunnel was down proves nothing about the connect path.

The teardown branch deserves the same treatment as the connect branch: remove the route by hand, let the job run, and confirm it comes back. A hook that adds a route and never removes it leaves a machine that misbehaves a week later, with nothing connecting the two events. Once both behaviours hold, sudo launchctl bootout system/net.example.tunnel-watch retires the job cleanly. A watch daemon left loaded is how a one-off repair becomes an unexplained route months later.

ХӀокхунах тера йаззамаш