A tiny launchd watchdog for macOS that fixes a common Yggdrasil issue: after a Wi-Fi reconnect, the daemon stays alive but its outbound peers never come back, leaving the mesh-tunnel "up but dead".
The watchdog runs every 30 seconds and:
- Checks the Wi-Fi interface (
en1by default).- If it has a
169.254.x.xlink-local address — runsipconfig set en1 BOOTP/DHCPto renew the lease. - If there's no IP / no default gateway / gateway not pingable — does nothing (Wi-Fi is genuinely down).
- If it has a
- If Wi-Fi is healthy, queries
yggdrasilctl -json getPeersviajqand counts peers withup=true. - If zero peers are up, runs
launchctl kickstart -k system/yggdrasilto force the daemon to re-establish all outbound connections. - A 120-second grace period prevents repeated kickstarts while the daemon is still re-handshaking after the previous restart.
Healthy cycles produce no log output at all — only anomalies are
written to /var/log/net-watchdog.log.
Yggdrasil's outbound TLS/QUIC/WSS sessions don't always notice when the
underlying network goes through an interface reset (Wi-Fi disconnect/
re-associate, DHCP renew, sleep/wake). The process keeps running and
launchctl thinks everything's fine, but yggdrasilctl getPeers shows
all peers as down or absent. The only reliable fix is restarting the
process, which kickstart -k does without disturbing the launchd label.
- macOS (tested on Tahoe / Apple Silicon M1)
yggdrasilalready installed under launchd labelsystem/yggdrasil(typical setup if you used the official installer or Homebrew with the bundled/Library/LaunchDaemons/yggdrasil.plist)/usr/bin/jq(preinstalled on recent macOS)- root access
# 1. Drop the script
sudo mkdir -p /usr/local/sbin
sudo cp net-watchdog.sh /usr/local/sbin/net-watchdog.sh
sudo chmod 755 /usr/local/sbin/net-watchdog.sh
sudo chown root:wheel /usr/local/sbin/net-watchdog.sh
# 2. Drop the launchd plist
sudo cp net-watchdog.plist /Library/LaunchDaemons/net-watchdog.plist
sudo chmod 644 /Library/LaunchDaemons/net-watchdog.plist
sudo chown root:wheel /Library/LaunchDaemons/net-watchdog.plist
# 3. Pre-create log files
sudo touch /var/log/net-watchdog.log /var/log/net-watchdog.out /var/log/net-watchdog.err
# 4. Bootstrap
sudo launchctl bootstrap system /Library/LaunchDaemons/net-watchdog.plist
sudo launchctl enable system/net-watchdogOr just run the included install.sh as root.
Edit the top of net-watchdog.sh:
| Var | Default | Meaning |
|---|---|---|
WIFI_IF |
en1 |
Wi-Fi interface name (networksetup -listallhardwareports to find yours) |
YGG_LABEL |
system/yggdrasil |
launchd label of your yggdrasil daemon |
MIN_PEERS |
1 |
Minimum number of up peers considered healthy |
GRACE_SEC |
120 |
Cooldown before another kickstart is allowed |
The launchd StartInterval is set in net-watchdog.plist
(default: 30 seconds).
sudo launchctl bootout system /Library/LaunchDaemons/net-watchdog.plist
sudo rm /Library/LaunchDaemons/net-watchdog.plist
sudo rm /usr/local/sbin/net-watchdog.sh
sudo rm /var/log/net-watchdog.{log,out,err} /var/run/net-watchdog.last_kickstartSkipping cycles say yggdrasilctl/jq failed — usually a PATH
issue. The script exports a hardcoded
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
If your yggdrasilctl lives elsewhere (e.g. /opt/homebrew/bin), add
that path explicitly.
Watchdog kickstarts yggdrasil while peers are visibly up — make
sure you're using the JSON parser (yggdrasilctl -json getPeers | jq),
not text-table grepping. The text table is drawn with Unicode
box-drawing characters and the column header "Up" can match naïve
greps.
Watchdog never fires even when peers are down — verify the
launchctl bootstrap worked: sudo launchctl list | grep net-watchdog
should show a non-zero PID-or-zero and LastExitStatus=0. Also tail
/var/log/net-watchdog.err for stderr from launchd itself.
Public domain. Do what you want.