Skip to content

Instantly share code, notes, and snippets.

@1d10t
Last active April 7, 2026 21:59
Show Gist options
  • Select an option

  • Save 1d10t/45503c99c3feed4a5da24fdcc2be76c1 to your computer and use it in GitHub Desktop.

Select an option

Save 1d10t/45503c99c3feed4a5da24fdcc2be76c1 to your computer and use it in GitHub Desktop.
macOS launchd watchdog: restarts Yggdrasil when peers drop after Wi-Fi reconnect

macOS net-watchdog for Yggdrasil

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:

  1. Checks the Wi-Fi interface (en1 by default).
    • If it has a 169.254.x.x link-local address — runs ipconfig set en1 BOOTP/DHCP to renew the lease.
    • If there's no IP / no default gateway / gateway not pingable — does nothing (Wi-Fi is genuinely down).
  2. If Wi-Fi is healthy, queries yggdrasilctl -json getPeers via jq and counts peers with up=true.
  3. If zero peers are up, runs launchctl kickstart -k system/yggdrasil to force the daemon to re-establish all outbound connections.
  4. 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.

Why?

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.

Requirements

  • macOS (tested on Tahoe / Apple Silicon M1)
  • yggdrasil already installed under launchd label system/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

Install

# 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-watchdog

Or just run the included install.sh as root.

Configuration

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).

Uninstall

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_kickstart

Troubleshooting

Skipping 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.

License

Public domain. Do what you want.

#!/bin/zsh
# Установщик net-watchdog. Запускать под sudo на mac mini.
set -eu
SCRIPT_SRC="/tmp/net-watchdog.sh"
PLIST_SRC="/tmp/net-watchdog.plist"
SCRIPT_DST="/usr/local/sbin/net-watchdog.sh"
PLIST_DST="/Library/LaunchDaemons/net-watchdog.plist"
# Создаём /usr/local/sbin если не существует
mkdir -p /usr/local/sbin
# Копируем скрипт
cp "$SCRIPT_SRC" "$SCRIPT_DST"
chmod 755 "$SCRIPT_DST"
chown root:wheel "$SCRIPT_DST"
# Копируем plist
cp "$PLIST_SRC" "$PLIST_DST"
chmod 644 "$PLIST_DST"
chown root:wheel "$PLIST_DST"
# Создаём лог-файлы (чтобы launchd не падал на StandardOutPath)
touch /var/log/net-watchdog.log /var/log/net-watchdog.out /var/log/net-watchdog.err
chmod 644 /var/log/net-watchdog.log /var/log/net-watchdog.out /var/log/net-watchdog.err
# Загружаем (если уже загружен — bootout сначала)
launchctl bootout system /Library/LaunchDaemons/net-watchdog.plist 2>/dev/null || true
launchctl bootstrap system /Library/LaunchDaemons/net-watchdog.plist
launchctl enable system/net-watchdog
echo "=== Installed ==="
ls -la "$SCRIPT_DST" "$PLIST_DST"
echo "=== Status ==="
launchctl list | grep net-watchdog || echo "(not in list yet)"
#!/bin/zsh
# net-watchdog.sh - watches Wi-Fi and yggdrasil peers
# Logic:
# 1) If en1 link is inactive (not associated to any AP): toggle Wi-Fi power
# to force auto-join (with TOGGLE_GRACE cooldown), exit.
# 2) If en1 has 169.254.x.x: try DHCP renew, exit.
# 3) If en1 has no valid IP or default gateway is unreachable: exit (Wi-Fi down).
# 4) If en1 alive AND yggdrasil active peers == 0:
# kickstart yggdrasil, but only if last kickstart was > GRACE_SEC ago.
set -u
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WIFI_IF="en1"
LOG="/var/log/net-watchdog.log"
YGG_LABEL="system/yggdrasil"
MIN_PEERS=1
GRACE_SEC=120 # don't kickstart yggdrasil again within this window
TOGGLE_GRACE=60 # don't toggle Wi-Fi power again within this window
STATE_FILE="/var/run/net-watchdog.last_kickstart"
TOGGLE_STATE="/var/run/net-watchdog.last_wifi_toggle"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"; }
# 1. Wi-Fi association check: if link inactive, kick auto-join
link_status=$(ifconfig "$WIFI_IF" 2>/dev/null | awk '/status:/ {print $2; exit}')
if [[ "$link_status" != "active" ]]; then
now=$(date +%s)
last=0
if [[ -f "$TOGGLE_STATE" ]]; then
last=$(cat "$TOGGLE_STATE" 2>/dev/null || echo 0)
fi
elapsed=$(( now - last ))
if (( elapsed < TOGGLE_GRACE )); then
# still inside cooldown, wait for auto-join to finish
exit 0
fi
log "wifi $WIFI_IF: link $link_status, toggling power to force auto-join"
echo "$now" > "$TOGGLE_STATE"
networksetup -setairportpower "$WIFI_IF" off >>"$LOG" 2>&1
sleep 3
networksetup -setairportpower "$WIFI_IF" on >>"$LOG" 2>&1
exit 0
fi
# 2. Current IP on en1
ip=$(ipconfig getifaddr "$WIFI_IF" 2>/dev/null || true)
if [[ -z "$ip" ]]; then
exit 0
fi
# 3. Link-local 169.254.x.x -> renew DHCP
if [[ "$ip" == 169.254.* ]]; then
log "wifi $WIFI_IF: link-local $ip, renewing DHCP"
ipconfig set "$WIFI_IF" BOOTP 2>>"$LOG"
sleep 2
ipconfig set "$WIFI_IF" DHCP 2>>"$LOG"
exit 0
fi
# 4. Default gateway scoped to en1
gw=$(route -n get -ifscope "$WIFI_IF" default 2>/dev/null | awk '/gateway:/ {print $2}')
if [[ -z "$gw" ]]; then
gw=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}')
fi
if [[ -z "$gw" ]]; then
exit 0
fi
# 5. Ping default gateway
if ! ping -c 2 -W 1000 -t 3 "$gw" >/dev/null 2>&1; then
exit 0
fi
# 6. Wi-Fi alive. Check yggdrasil peers via JSON
peers_up=$(yggdrasilctl -json getPeers 2>/dev/null \
| jq '[.peers[]? | select(.up == true)] | length' 2>/dev/null)
if [[ -z "$peers_up" || "$peers_up" == "null" ]]; then
log "yggdrasilctl/jq failed, skipping"
exit 0
fi
if (( peers_up >= MIN_PEERS )); then
exit 0
fi
# 6. Grace period: skip if we kickstarted recently
now=$(date +%s)
last=0
if [[ -f "$STATE_FILE" ]]; then
last=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
fi
elapsed=$(( now - last ))
if (( elapsed < GRACE_SEC )); then
log "yggdrasil: peers_up=0, but only ${elapsed}s since last kickstart (grace=$GRACE_SEC), waiting"
exit 0
fi
log "yggdrasil: peers_up=0, kickstarting $YGG_LABEL"
echo "$now" > "$STATE_FILE"
launchctl kickstart -k "$YGG_LABEL" >>"$LOG" 2>&1
<?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>net-watchdog</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/sbin/net-watchdog.sh</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/net-watchdog.out</string>
<key>StandardErrorPath</key>
<string>/var/log/net-watchdog.err</string>
</dict>
</plist>
#!/bin/zsh
# net-watchdog.sh - watches Wi-Fi and yggdrasil peers
# Logic:
# 1) If en1 link is inactive (not associated to any AP): toggle Wi-Fi power
# to force auto-join (with TOGGLE_GRACE cooldown), exit.
# 2) If en1 has 169.254.x.x: try DHCP renew, exit.
# 3) If en1 has no valid IP or default gateway is unreachable: exit (Wi-Fi down).
# 4) If en1 alive AND yggdrasil active peers == 0:
# kickstart yggdrasil, but only if last kickstart was > GRACE_SEC ago.
set -u
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WIFI_IF="en1"
LOG="/var/log/net-watchdog.log"
YGG_LABEL="system/yggdrasil"
MIN_PEERS=1
GRACE_SEC=120 # don't kickstart yggdrasil again within this window
TOGGLE_GRACE=60 # don't toggle Wi-Fi power again within this window
STATE_FILE="/var/run/net-watchdog.last_kickstart"
TOGGLE_STATE="/var/run/net-watchdog.last_wifi_toggle"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"; }
# 1. Wi-Fi association check: if link inactive, kick auto-join
link_status=$(ifconfig "$WIFI_IF" 2>/dev/null | awk '/status:/ {print $2; exit}')
if [[ "$link_status" != "active" ]]; then
now=$(date +%s)
last=0
if [[ -f "$TOGGLE_STATE" ]]; then
last=$(cat "$TOGGLE_STATE" 2>/dev/null || echo 0)
fi
elapsed=$(( now - last ))
if (( elapsed < TOGGLE_GRACE )); then
# still inside cooldown, wait for auto-join to finish
exit 0
fi
log "wifi $WIFI_IF: link $link_status, toggling power to force auto-join"
echo "$now" > "$TOGGLE_STATE"
networksetup -setairportpower "$WIFI_IF" off >>"$LOG" 2>&1
sleep 3
networksetup -setairportpower "$WIFI_IF" on >>"$LOG" 2>&1
exit 0
fi
# 2. Current IP on en1
ip=$(ipconfig getifaddr "$WIFI_IF" 2>/dev/null || true)
if [[ -z "$ip" ]]; then
exit 0
fi
# 3. Link-local 169.254.x.x -> renew DHCP
if [[ "$ip" == 169.254.* ]]; then
log "wifi $WIFI_IF: link-local $ip, renewing DHCP"
ipconfig set "$WIFI_IF" BOOTP 2>>"$LOG"
sleep 2
ipconfig set "$WIFI_IF" DHCP 2>>"$LOG"
exit 0
fi
# 4. Default gateway scoped to en1
gw=$(route -n get -ifscope "$WIFI_IF" default 2>/dev/null | awk '/gateway:/ {print $2}')
if [[ -z "$gw" ]]; then
gw=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}')
fi
if [[ -z "$gw" ]]; then
exit 0
fi
# 5. Ping default gateway
if ! ping -c 2 -W 1000 -t 3 "$gw" >/dev/null 2>&1; then
exit 0
fi
# 6. Wi-Fi alive. Check yggdrasil peers via JSON
peers_up=$(yggdrasilctl -json getPeers 2>/dev/null \
| jq '[.peers[]? | select(.up == true)] | length' 2>/dev/null)
if [[ -z "$peers_up" || "$peers_up" == "null" ]]; then
log "yggdrasilctl/jq failed, skipping"
exit 0
fi
if (( peers_up >= MIN_PEERS )); then
exit 0
fi
# 6. Grace period: skip if we kickstarted recently
now=$(date +%s)
last=0
if [[ -f "$STATE_FILE" ]]; then
last=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
fi
elapsed=$(( now - last ))
if (( elapsed < GRACE_SEC )); then
log "yggdrasil: peers_up=0, but only ${elapsed}s since last kickstart (grace=$GRACE_SEC), waiting"
exit 0
fi
log "yggdrasil: peers_up=0, kickstarting $YGG_LABEL"
echo "$now" > "$STATE_FILE"
launchctl kickstart -k "$YGG_LABEL" >>"$LOG" 2>&1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment