Files
ducoterra 3cdbb405ab add WireGuard wg0 watchdog units for always-on SSH
When SSH to a Pi targets a WireGuard tunnel address, reachability depends on
three things that each fail independently: the unit starting at boot, the NAT
mapping staying open, and the peer hostname still resolving. Rebooting onto a
network with no route to the endpoint previously meant no way back in.

- wg-quick@wg0.service.d/10-boot-resilience.conf: retry a failed boot-time up
  forever (Restart=on-failure, RestartSec=15, StartLimitIntervalSec=0) and cap
  endpoint resolution (WG_ENDPOINT_RESOLUTION_RETRIES=60, TimeoutStartSec=120)
  instead of hanging on the stock infinity.
- wg0-watchdog: POSIX sh healer, oldest symptom first - missing interface or
  peer restarts the unit, a missing PersistentKeepalive is set directly, a
  stale handshake re-points the peer at a freshly resolved endpoint, and a
  very stale one restarts. Restarts are gap-limited so a broken network cannot
  make the unit flap, endpoint resolution falls back to the kernel endpoint
  and then to a last-known-good cache (tunnel DNS may only be reachable
  through the tunnel), and nothing it did not create is ever removed.
  Supports --dry-run.
- wg0-watchdog.service/.timer: oneshot on a 60s timer, first check 30s after
  boot, Persistent=true to catch up after downtime, TimeoutStartSec=120 and
  Nice=10 so a restart cannot wedge the watchdog.
- wg0.conf.template: documents /etc/wireguard/wg0.conf with placeholders only.
  Deliberately omits the DNS key so name resolution does not depend on the
  tunnel (Debian wg-quick has no `DNS = none`), and sets PersistentKeepalive
  25 to hold the NAT mapping for inbound SSH.
- wireguard.md: runbook for the placeholder table, failure modes, deploy, and
  verification.

The real wg0.conf and key material never enter the repo.

LLM-Generated: true
2026-10-16 09:07:56 -04:00

7.8 KiB

Runbook: WireGuard client that survives reboot, NAT, and roaming

For a Pi whose SSH address is a WireGuard tunnel address rather than a LAN address: the physical interface is ordinary DHCP and the Pi moves between networks, while the tunnel peer is a reachable server. Reachability then depends on three separate things, each of which fails independently — the unit starting at boot, the NAT mapping staying open, and the peer hostname still resolving.

Placeholders used below:

Placeholder Meaning
$HOST root@<address you can already reach>
<TUNNEL_ADDR> this Pi's tunnel address, e.g. <addr>/32
<PEER_NETWORK> network routed through the tunnel, e.g. <net>/<cidr>
<WG_SERVER_HOST:PORT> tunnel server endpoint
<PEER_PUBKEY> the server's WireGuard public key

Files on the host

Path Purpose
/etc/wireguard/wg0.conf tunnel config, mode 0600 — see wg0.conf.template, never stored in this repo
/etc/systemd/system/wg-quick@wg0.service.d/10-boot-resilience.conf retry a failed boot-time up: Restart=on-failure, RestartSec=15, StartLimitIntervalSec=0, WG_ENDPOINT_RESOLUTION_RETRIES=60, TimeoutStartSec=120
/usr/local/sbin/wg0-watchdog heals the tunnel once a minute
/etc/systemd/system/wg0-watchdog.service oneshot that runs the script
/etc/systemd/system/wg0-watchdog.timer OnBootSec=30s, OnUnitActiveSec=60s, Persistent=true
/var/lib/wg0-watchdog/endpoint last-known-good endpoint IP, used when DNS is unavailable

Failure modes this closes

  1. No PersistentKeepalive. Behind NAT the server can only send to the Pi while the mapping lives, so once it expires the tunnel looks configured but inbound SSH times out. PersistentKeepalive = 25 punches UDP state often enough for typical 30-60s NAT timeouts.
  2. A boot-time failure is permanent. wg-quick up fails outright if the endpoint hostname does not resolve yet or the server is down, and the unit is not retried — so the tunnel stays dead until someone logs in locally. Restart=on-failure with StartLimitIntervalSec=0 retries forever; WG_ENDPOINT_RESOLUTION_RETRIES is lowered from the stock infinity (which hangs the unit indefinitely) so a retry can actually happen.
  3. Nothing heals a stale tunnel. Roaming to a new network keeps the old endpoint IP in the kernel, and a peer whose endpoint is unreachable simply stops handshaking without the unit ever noticing, because the unit already "succeeded". The watchdog acts on handshake age.

Install

HOST=root@<pi-address>
D=$PWD        # run the snippets from a checkout of this directory

ssh "$HOST" 'install -d -m 0755 /usr/local/sbin /etc/systemd/system/wg-quick@wg0.service.d'
tar -C "$D" -cf - wg0-watchdog wg0-watchdog.service wg0-watchdog.timer \
    'wg-quick@wg0.service.d-10-boot-resilience.conf' | \
ssh "$HOST" 'tar -xf - -C /root &&
  install -m 0755 /root/wg0-watchdog /usr/local/sbin/ &&
  install -m 0644 /root/wg0-watchdog.service /root/wg0-watchdog.timer /etc/systemd/system/ &&
  install -m 0644 "/root/wg-quick@wg0.service.d-10-boot-resilience.conf" \
    /etc/systemd/system/wg-quick@wg0.service.d/10-boot-resilience.conf &&
  rm -f /root/wg0-watchdog /root/wg0-watchdog.service /root/wg0-watchdog.timer \
    "/root/wg-quick@wg0.service.d-10-boot-resilience.conf" &&
  systemctl daemon-reload && systemctl enable --now wg-quick@wg0.service wg0-watchdog.timer'

Keep PersistentKeepalive = 25 in [Peer] (see wg0.conf.template) and check the config file is 0600 — a world-readable wg0.conf exposes the tunnel private key to every local account:

ssh "$HOST" 'ls -l /etc/wireguard/wg0.conf; wg show wg0 persistent-keepalive'

Watchdog behaviour

Oldest symptom first, one action per run:

  1. interface or peer gone → systemctl restart --no-block wg-quick@wg0.service
  2. no PersistentKeepalive in the kernel → wg set ... persistent-keepalive <n> (covers a config edited without a reload)
  3. handshake older than WG_WD_STALE (default 120s) → re-point the peer at the endpoint, preferring a fresh resolution of the hostname from the config file, then the kernel's current endpoint, then the cached IP
  4. handshake older than WG_WD_STALE_RESTART (default 300s) → restart the unit

Restarts are gap-limited to one per WG_WD_MIN_RESTART_GAP (default 180s) so a dead network cannot make the unit flap. --no-block keeps a hung wg-quick up from wedging the watchdog. Peers, addresses, and routes are only ever added. All thresholds and the interface/state paths are environment overrides, and --dry-run prints the decision without acting.

The endpoint resolution order matters when the tunnel config also pushes a DNS server: that resolver is reachable only through the tunnel, so a dead tunnel can mean the endpoint hostname cannot be resolved exactly when it is needed — hence the cached-IP fallback.

DNS

A DNS = <resolver> line in the interface section is pushed through resolvconf/systemd-resolved while the tunnel is up, which can silently make all name resolution on the host depend on the tunnel. If the Pi must resolve names on whatever network it lands on, omit the DNS key entirely and let the LAN's DHCP resolvers stand.

Two gotchas:

  • Debian's wg-quick does not implement DNS = none — any non-IP value becomes a search domain, so the line has to be absent, not set to none.
  • A record already pushed by a previous wg-quick up survives the config edit; withdraw it (resolvconf -d <record>, name visible in resolvconf --list) and confirm the local caching resolver's upstreams before believing it is gone.

Taking wg0 down on purpose

Stop the watchdog first or the tunnel comes straight back:

ssh "$HOST" 'systemctl stop wg0-watchdog.timer wg-quick@wg0.service'
ssh "$HOST" 'systemctl start wg0-watchdog.timer'      # resume

Check on it

ssh "$HOST" 'wg show wg0
  systemctl status wg-quick@wg0 --no-pager | head -6
  systemctl list-timers "wg0-watchdog*" --no-pager | head -3
  journalctl -u wg0-watchdog.service -b --no-pager | tail -15'

Dry-run the decision logic without touching the tunnel:

ssh "$HOST" 'WG_WD_STATE_DIR=/tmp/wd WG_WD_STALE=0 /usr/local/sbin/wg0-watchdog --dry-run; rm -rf /tmp/wd'

Validate an edited config without disturbing the running tunnel:

ssh "$HOST" 'wg-quick strip /etc/wireguard/wg0.conf | sed -E "s/^(PrivateKey[[:space:]]*=).*/\1 [REDACTED]/"'

Diagnosis notes (generic, worth checking on any host)

  • wg-quick@<iface> can be enabled yet inactive while the interface exists and passes traffic — it may have been created by hand or taken over by NetworkManager as an assumed profile (nmcli dev status shows such a device as connected (externally)). Nothing owns it: systemctl reload fails with "unit not active", so apply changes with wg set or a full restart, and let systemctl restart take ownership before trusting a reboot.
  • Restart=on-failure does work for Type=oneshot on systemd 257 — worth proving on a new distribution with a throwaway unit under /run/systemd/system before relying on it: Type=oneshot + ExecStart=/bin/false, then check systemctl show -p NRestarts.
  • systemd-analyze verify <unit> catches mistyped unit keys, and it only warns — read the output instead of trusting the exit status.
  • NetworkManager does not fight wg-quick as long as no keyfile exists for the interface and its assumed profile keeps autoconnect no; check /etc/NetworkManager/system-connections/ before assuming that.
  • The real end-to-end test is a reboot: DHCP → endpoint resolution → wg-quick up → inbound SSH through the tunnel. systemd-analyze critical-chain ssh.service after boot shows whether the ordering held.