#!/bin/sh
# wg0-watchdog - keep the WireGuard client tunnel up so SSH to the tunnel
# address works after boot, after roaming to a new network, and after the NAT
# mapping or endpoint IP goes stale.
#
# Decisions, oldest symptom first:
#   interface or peer gone            -> restart wg-quick@wg0.service
#   no PersistentKeepalive in kernel  -> set it (covers a config edited without reload)
#   handshake older than STALE        -> re-point the peer at the endpoint
#                                        (re-resolves the hostname, forces a handshake)
#   handshake older than STALE_RESTART-> restart wg-quick@wg0.service
#
# Restarts are gap-limited so a broken network cannot make the unit flap.
# Never removes peers, addresses, or routes it did not create.

set -eu

IFACE="${WG_WD_IFACE:-wg0}"
UNIT="${WG_WD_UNIT:-wg-quick@wg0.service}"
CONF="${WG_WD_CONF:-/etc/wireguard/$IFACE.conf}"
KEEPALIVE="${WG_WD_KEEPALIVE:-25}"
STALE="${WG_WD_STALE:-120}"              # seconds: poke the endpoint
STALE_RESTART="${WG_WD_STALE_RESTART:-300}"   # seconds: restart the unit
MIN_RESTART_GAP="${WG_WD_MIN_RESTART_GAP:-180}"  # seconds between restarts
STATE_DIR="${WG_WD_STATE_DIR:-/var/lib/wg0-watchdog}"

DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1

now=$(date +%s)
log() { printf 'wg0-watchdog: %s\n' "$*"; }
run() {
    if [ "$DRY" -eq 1 ]; then
        printf 'wg0-watchdog: DRY-RUN would run:'
        printf ' %s' "$@"
        printf '\n'
    else
        "$@"
    fi
}

read_state() { cat "$1" 2>/dev/null || echo 0; }
write_state() {
    if [ "$DRY" -eq 1 ]; then
        return 0
    fi
    mkdir -p "$STATE_DIR"
    printf '%s\n' "$2" > "$1"
}

restart_unit() {
    reason=$1
    last=$(read_state "$STATE_DIR/last-restart")
    case "$last" in ''|*[!0-9]*) last=0 ;; esac
    since=$((now - last))
    if [ "$since" -lt "$MIN_RESTART_GAP" ]; then
        log "restart of $UNIT suppressed ($reason); last restart ${since}s ago"
        return 0
    fi
    log "restarting $UNIT ($reason)"
    write_state "$STATE_DIR/last-restart" "$now"
    # --no-block: a wg-quick up stuck on endpoint resolution must not wedge the
    # watchdog; the next run reports whether the restart worked.
    run systemctl restart --no-block "$UNIT"
}

# --- 1. interface / peer present -------------------------------------------
if ! ip link show "$IFACE" >/dev/null 2>&1; then
    restart_unit "interface $IFACE is missing"
    exit 0
fi

peer=$(wg show "$IFACE" peers 2>/dev/null | head -n 1)
if [ -z "${peer:-}" ]; then
    restart_unit "no peer configured on $IFACE"
    exit 0
fi

# --- 2. persistent keepalive present ---------------------------------------
live_ka=$(wg show "$IFACE" persistent-keepalive 2>/dev/null | awk -v p="$peer" '$1 == p {print $2}')
[ -n "${live_ka:-}" ] || live_ka=0
if [ "$live_ka" != "$KEEPALIVE" ]; then
    log "persistent-keepalive is $live_ka, setting $KEEPALIVE"
    run wg set "$IFACE" peer "$peer" persistent-keepalive "$KEEPALIVE"
fi

# --- 3. handshake age -------------------------------------------------------
hs=$(wg show "$IFACE" latest-handshakes 2>/dev/null | awk -v p="$peer" '$1 == p {print $2}')
case "${hs:-}" in ''|*[!0-9]*) hs=0 ;; esac
if [ "$hs" -eq 0 ]; then
    age=999999
else
    age=$((now - hs))
fi

# endpoint currently in use by the kernel, and the cached last-known-good one
live_ep=$(wg show "$IFACE" endpoints 2>/dev/null | awk -v p="$peer" '$1 == p {print $2}')
cache_file="$STATE_DIR/endpoint"
cached_ep=""
[ -f "$cache_file" ] && cached_ep=$(cat "$cache_file" 2>/dev/null || true)

if [ "$age" -lt "$STALE" ]; then
    if [ -n "$live_ep" ]; then
        write_state "$cache_file" "$live_ep"
    fi
    log "healthy: handshake ${age}s ago, endpoint ${live_ep:-unknown}, keepalive ${KEEPALIVE}s"
    exit 0
fi

log "stale: last handshake ${age}s ago"

# Prefer the endpoint from the config file, freshly resolved. Fall back to the
# kernel's current endpoint, then to the cache - important because a DNS server
# from the tunnel config may be reachable only through this very tunnel.
target_ep=""
conf_ep=$(awk -F= 'tolower($1) ~ /^[[:space:]]*endpoint[[:space:]]*$/ { gsub(/[[:space:]]/, "", $2); print $2 }' "$CONF" 2>/dev/null | tail -n 1)
if [ -n "${conf_ep:-}" ]; then
    host=${conf_ep%:*}
    port=${conf_ep##*:}
    case "$host" in
        *[!0-9.]*)
            resolved=$(getent hosts "$host" 2>/dev/null | awk '{print $1; exit}' || true)
            ;;
        *)
            resolved=$host
            ;;
    esac
    if [ -n "${resolved:-}" ]; then
        target_ep="$resolved:$port"
    else
        log "could not resolve $host (tunnel DNS?), falling back"
    fi
fi
[ -n "$target_ep" ] || target_ep=${live_ep:-${cached_ep:-}}

if [ "$age" -ge "$STALE_RESTART" ]; then
    restart_unit "handshake ${age}s old"
    exit 0
fi

if [ -z "$target_ep" ]; then
    log "no endpoint to re-point at; will retry next run"
    exit 0
fi

if [ "$target_ep" = "$live_ep" ]; then
    log "endpoint already $target_ep; forcing a handshake"
fi
log "re-pointing peer at $target_ep"
run wg set "$IFACE" peer "$peer" endpoint "$target_ep"
exit 0
