#!/bin/sh
# panah-watchdog — macOS kill switch + auto-reconnect for the Panah CLI.
#
# Started (root, backgrounded) by `panah up`. While the tunnel is up it just
# watches. When the tunnel dies WITHOUT `panah down` having been run (the
# intentional paths remove the flag first), it:
#   1. kill switch: blackholes the default routes via loopback (fail-closed)
#      — nothing leaves through the normal ISP path anymore, and
#   2. auto-reconnect: retries `panah up <proto>` up to 5 times. Each attempt
#      needs the underlay back, so the blackholes come OFF during the try and
#      go back ON when it fails. A successful `up` spawns its own fresh
#      watchdog which takes over the flag; this one then exits.
#
# Session semantics (Mullvad-style honesty):
# - "up" is checked HONESTLY: lightway counts as up only once its utun exists
#   (an interface carrying an inet 100.64.0.x address); DAITA counts as up
#   only while gotatun — the daemon that IS the tunnel — is alive. The DAITA
#   supervisor exits by design right after connecting, so a live supervisor
#   means "connecting", not "up".
# - a connect that never comes up is NOT punished: the watchdog exits without
#   locking (no session existed). Locking only follows the death of a LIVE
#   session — or a failed RECOVERY connect (PANAH_INHERIT_LOCK=1: a session
#   existed before us, so the chain stays fail-closed).
#
# Design note (honest): two belts. (1) The pf layer (`panah up`'s pf_arm) is
# PRE-ARMED while a session is up — the physical interface may only carry
# the tunnel endpoints, so a sudden death has NO exposure window at all.
# (2) The route blackholes below are POST-ARMED — a ~1-2s window exists
# between tunnel death and the pair going in (the kernel reaps a SIGKILLed
# utun's routes asynchronously; macOS cannot hold duplicate equal-prefix
# routes with deterministic priority). pf closes what the route layer
# cannot. The watchdog closes the route layer within one tick.
#
# State: /var/run/panah/watchdog.active (owner pid) + watchdog.log.
# Settings in /etc/panah/settings: ks=1|0 (kill switch), ar=1|0 (auto-reconnect).
set -u
# survive a terminal hangup — the nohup equivalent, which ALSO works from
# the daemon context (no controlling terminal) where nohup itself aborts
# with "can't detach from console"
trap '' HUP
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
RUN_DIR="/var/run/panah"
ACTIVE="$RUN_DIR/watchdog.active"
LOG="$RUN_DIR/watchdog.log"
SETTINGS="/etc/panah/settings"

setting() {
    v=$(grep -m1 "^$1=" "$SETTINGS" 2>/dev/null | cut -d= -f2)
    [ -n "$v" ] || v="${2:-1}"
    echo "$v"
}

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG" 2>/dev/null || true
}

# --- what is running ---------------------------------------------------------
# Lightway: the CLIENT PROCESS is the session (background daemon). Online =
# its utun carries the tunnel inet — the range is PUSHED BY THE SERVER
# (AuthSuccessWithConfigV4): the server allocates from its ip_pool (this
# VPS: 10.125.0.0/16; older sessions saw 169.254.10.x / 100.64.0.x) — all
# count. pid-alive-without-Online = "starting".
is_lightway_pid_alive() {
    [ -f "$RUN_DIR/lightway.pid" ] && kill -0 "$(cat "$RUN_DIR/lightway.pid")" 2>/dev/null
}
is_lightway_up() {
    is_lightway_pid_alive && ifconfig -a 2>/dev/null | grep -qE 'inet (10\.125\.|100\.64\.0\.|169\.254\.10\.)'
}
# DAITA: the supervisor EXITS BY DESIGN once the tunnel is up; gotatun keeps
# running as the daemon. Supervisor-alive = still connecting.
# over TCP the udp2tcp shim (respawn loop, pid in udp2tcp.pid) is part of
# the tunnel: gotatun without it is a dead session
is_daita_up() {
    pgrep -x gotatun >/dev/null 2>&1 || return 1
    [ ! -f "$RUN_DIR/udp2tcp.pid" ] || kill -0 "$(cat "$RUN_DIR/udp2tcp.pid")" 2>/dev/null
}
is_daita_connecting() { pgrep -f "panah-daita-client.* up$" >/dev/null 2>&1; }

tunnel_up()   { is_lightway_up || is_daita_up; }
tunnel_busy() { tunnel_up || is_lightway_pid_alive || is_daita_connecting; }

# which utun is the LIVE tunnel right now? (hijack-keeper support) — the
# .protocol file picks the signature: DAITA = the interface the live config
# names, carrying a 10.64.0.x address; lightway = any utun carrying one of
# the server-pushed tunnel ranges. Prints the iface name, empty when the
# named tunnel is not actually up.
tunnel_iface() {
    PROTO=lightway
    [ -f "$DIR/.protocol" ] && PROTO=$(cat "$DIR/.protocol")
    if [ "$PROTO" = "daita" ]; then
        is_daita_up || return 1
        # NOTE: the JSON is Apple-style — "interface" : "utun9" (space before
        # the colon) — the pattern tolerates spaces on both sides of it
        IF=$(sed -n 's/.*"interface" *: *"\([^"]*\)".*/\1/p' \
            "$DIR/daita/panah-daita.json" 2>/dev/null | head -1)
        if [ -n "$IF" ] && ifconfig "$IF" >/dev/null 2>&1; then
            echo "$IF"
        else
            # config missing/stale: fall back to the data-range signature
            ifconfig -a 2>/dev/null \
                | grep -B4 'inet 10\.64\.' | grep -oE '^utun[0-9]+' | head -1
        fi
    else
        is_lightway_pid_alive || return 1
        ifconfig -a 2>/dev/null \
            | grep -B4 'inet \(10\.125\.\|100\.64\.0\.\|169\.254\.10\.\)' \
            | grep -oE '^utun[0-9]+' | head -1
    fi
}

# ARM HONESTLY: delete-then-add. A dying tunnel's /1s can still occupy the
# prefixes for a moment (the kernel reaps a SIGKILLed utun's routes
# asynchronously) — a bare add then fails with "File exists", the error is
# swallowed, and the kill switch silently never lands (seen live: the
# post-14:52 arm logged "blackholed" while v4 leaked through en0). Delete
# first (a no-op when absent), add, then VERIFY and log the truth.
blackholes_arm() {
    route -n delete -net 0.0.0.0/1 2>/dev/null >/dev/null || true
    route -n delete -net 128.0.0.0/1 2>/dev/null >/dev/null || true
    route -n delete -inet6 ::/1 2>/dev/null >/dev/null || true
    route -n delete -inet6 8000::/1 2>/dev/null >/dev/null || true
    route -n add -net 0.0.0.0/1 127.0.0.1 2>/dev/null >/dev/null
    route -n add -net 128.0.0.0/1 127.0.0.1 2>/dev/null >/dev/null
    route -n add -inet6 ::/1 ::1 2>/dev/null >/dev/null
    route -n add -inet6 8000::/1 ::1 2>/dev/null >/dev/null
    if route -n get 8.8.8.8 2>/dev/null | grep -q '127\.0\.0\.1'; then
        log "kill switch armed: default routes blackholed (verified)"
    else
        log "kill switch FAILED TO ARM — v4 default path is NOT blackholed"
    fi
}

blackholes_disarm() {
    route -n delete -net 0.0.0.0/1 2>/dev/null >/dev/null || true
    route -n delete -net 128.0.0.0/1 2>/dev/null >/dev/null || true
    route -n delete -inet6 ::/1 2>/dev/null >/dev/null || true
    route -n delete -inet6 8000::/1 2>/dev/null >/dev/null || true
    log "kill switch disarmed"
}

# --- single instance: a newer watchdog overwrites watchdog.active with its
# own pid; one whose pid no longer matches hands the job over and exits.
MYPID=$$
echo "$MYPID" > "$ACTIVE" 2>/dev/null || exit 1

if [ "${PANAH_INHERIT_LOCK:-0}" = "1" ]; then
    # recovery connect after a death: a session existed before us — if THIS
    # connect fails, the chain must stay fail-closed (the CLI starts us with
    # the inherit marker in exactly that situation)
    saw_alive=1
    log "watchdog started (pid $MYPID, inherited lock)"
else
    saw_alive=0
    log "watchdog started (pid $MYPID)"
fi

# first-connect grace: only meaningful for a non-inherited start (a fresh
# session). Covers the whole connect window; extended implicitly by
# tunnel_busy below while anything is still connecting.
grace_until=$(($(date +%s) + 45))

while :; do
    cur=$(cat "$ACTIVE" 2>/dev/null)
    if [ "$cur" != "$MYPID" ]; then
        log "flag gone (intentional stop or newer watchdog) — exiting"
        exit 0
    fi

    if tunnel_up; then
        saw_alive=1
        # alive: no blackholes should exist (clears a stale set from a
        # previous unexpected death that a reconnect healed). A random
        # public IP is the probe — 0.0.0.0/1 swallows it when armed.
        if route -n get 8.8.8.8 2>/dev/null | grep -q '127\.0\.0\.1'; then
            blackholes_disarm
        fi
        # HIJACK KEEPER — the ONLY route re-pinner left in the system (the
        # per-protocol keeper subshells in the CLI were removed: they kept
        # racing protocol switches, re-pinning the DYING protocol's /1 pair
        # right after the new one installed — "File exists" — and then died
        # with their utun, leaving v4 unlocked through en0). One owner, one
        # cadence. The table may lose the pair for real reasons: lightway's
        # client monitor tears ALL vpn routes down on any network change
        # and never re-installs them. Act ONLY when this tunnel is the live
        # one and the default path escapes it.
        TIF=$(tunnel_iface)
        if [ -n "$TIF" ]; then
            if ! route -n get 1.1.1.1 2>/dev/null | grep -q "interface: $TIF"; then
                # mid-tick re-check: an intentional stop or protocol switch
                # may have pulled the flag between the probe and now — a
                # retiring watchdog must NEVER write routes (that exact race
                # is what leaked v4 before). Stand down instead.
                cur=$(cat "$ACTIVE" 2>/dev/null)
                [ "$cur" = "$MYPID" ] || { log "flag gone (intentional stop or newer watchdog) — exiting"; exit 0; }
                # ... and the iface must still exist (same instant)
                ifconfig "$TIF" >/dev/null 2>&1 && {
                    # drop ANY stale /1 pair (whichever iface/gateway it
                    # serves — a leftover from the previous protocol) and
                    # re-pin link-style via THIS tunnel
                    route -n delete -net 0.0.0.0/1 >/dev/null 2>&1 || true
                    route -n delete -net 128.0.0.0/1 >/dev/null 2>&1 || true
                    route -n add -net 0.0.0.0/1 -interface "$TIF" >/dev/null 2>&1 || true
                    route -n add -net 128.0.0.0/1 -interface "$TIF" >/dev/null 2>&1 || true
                    log "hijack re-pinned via $TIF"
                }
            fi
        fi
        sleep 1
        continue
    fi

    if tunnel_busy; then
        # still connecting: "not there yet" is not "dead" — and never counts
        # as a live session either (a failed first connect must not lock)
        sleep 1
        continue
    fi

    if [ "$saw_alive" = 0 ]; then
        if [ "$(date +%s)" -lt "$grace_until" ]; then
            sleep 1
            continue
        fi
        # never came up: the `up` command itself reports the failure.
        # Locking the network over a failed FIRST connect would punish a
        # plain mistake; the kill switch only follows a LIVE session's death.
        log "tunnel never came up — exiting without locking (no session existed)"
        exit 0
    fi

    # a LIVE session (or an inherited recovery) ended without `panah down`
    log "live tunnel died unexpectedly"
    # capture the moment of death (routes, pf, underlay) for the diagnosis
    [ -x "$DIR/panah-diag" ] && ( "$DIR/panah-diag" tunnel-died >> "$RUN_DIR/diag.log" 2>&1 & )
    saw_alive=0

    if [ "$(setting ks 1)" = "1" ]; then
        blackholes_arm
    fi

    if [ "$(setting ar 1)" = "1" ]; then
        PROTO="lightway"
        [ -f "$DIR/.protocol" ] && PROTO=$(cat "$DIR/.protocol")
        ok_reconnect=0
        try=1
        while [ "$try" -le 5 ]; do
            # a newer watchdog (spawned by a SUCCESSFUL reconnecting `up`)
            # takes the flag — the moment we no longer own it, stand down
            cur=$(cat "$ACTIVE" 2>/dev/null)
            [ "$cur" = "$MYPID" ] || { log "newer watchdog took over — exiting"; exit 0; }
            log "auto-reconnect attempt $try ($PROTO)"
            # the attempt needs a working underlay — blackholes come off for
            # the try and go back on when it fails (the pf pre-armed layer
            # `panah up` installed keeps the physical interface closed to
            # everything but the tunnel endpoints through the whole window)
            blackholes_disarm
            # NO nohup here, ever: this watchdog has no controlling terminal
            # and macOS nohup ABORTS outright in that context ("can't detach
            # from console") — every attempt this line ever spawned was
            # stillborn, and the loop below just waited out its seconds for a
            # tunnel nobody was connecting. A plain background subshell that
            # ignores HUP (exec preserves the SIG_IGN disposition) is the
            # same spawn the CLI itself uses.
            ( trap '' HUP
              PANAH_WATCHDOG_RECONNECT=1 exec "$DIR/panah" up "$PROTO" ) >> "$LOG" 2>&1 &
            UPPID=$!
            i=0
            while [ "$i" -lt 60 ]; do
                kill -0 "$UPPID" 2>/dev/null || break
                tunnel_up && break
                sleep 1
                i=$((i+1))
            done
            # judge only after the `up` fully finished: its tail carries the
            # hijack ensure + watchdog_start, and a failing attempt's own
            # teardown must never collide with the next attempt's fresh
            # parent (the server holds the dead session's ephemeral slot for
            # ~80s — the first attempts legitimately fail until it moves)
            n=0
            while kill -0 "$UPPID" 2>/dev/null && [ "$n" -lt 30 ]; do
                sleep 1
                n=$((n+1))
            done
            kill -0 "$UPPID" 2>/dev/null && { kill -9 "$UPPID" 2>/dev/null; sleep 2; }
            if tunnel_up; then
                ok_reconnect=1
                break
            fi
            log "attempt $try failed"
            if [ "$(setting ks 1)" = "1" ]; then
                blackholes_arm
            fi
            try=$((try+1))
            sleep 5
        done
        if [ "$ok_reconnect" = 1 ]; then
            log "auto-reconnect succeeded"
            sleep 2
            continue    # outer loop: the new watchdog owns the flag → we exit
        fi
        log "auto-reconnect failed after $((try-1)) attempts — staying fail-closed (ks=$(setting ks 1)); heal: panah up (retry) or panah down (release)"
        exit 0
    fi

    log "auto-reconnect disabled — staying fail-closed (ks=$(setting ks 1)); heal: panah up (retry) or panah down (release)"
    exit 0
done
