#!/bin/sh
# panah — macOS CLI for both protocols.
#
#   sudo ./panah up [lightway|daita]   connect (default: last used, else lightway)
#   sudo ./panah down                 disconnect whichever is running
#   ./panah status                    what is running + exit IP + ks/ar/dns
#   sudo ./panah ks on|off            kill switch (default: on)
#   sudo ./panah ar on|off            auto-reconnect (default: on)
#   sudo ./panah dns off|ads|mal|both DNS blocker choice for DAITA
#   sudo ./panah tr tcp|udp           DAITA transport (default: tcp)
#   [sudo] ./panah diag               diagnostics snapshot -> logs/diag-latest.txt
#
# DAITA notes:
# - the supervisor configures routing itself on macOS (utun address, endpoint
#   pin, split default route) — no manual route steps needed;
#   daita/panah-routes.sh stays as a recovery tool.
# - the DAITA handshake takes ~1s extra (Classic McEliece keygen) — that is
#   the post-quantum price, on purpose.
#
# DNS:
# - both protocols point every network service at the DNS resolver running
#   on your own Panah VPS (unbound): Lightway -> 100.64.0.1, DAITA -> the
#   resolver matching the dns blocker choice (10.64.0.1 clean / .2 ads /
#   .3 malware / .4 both)
# - Lightway's resolver is PUSHED BY THE SERVER (protocol design) — the dns
#   choice here only affects DAITA; for Lightway the admin switches it in
#   the panel.
# - previous per-service DNS settings are saved to the state file and
#   restored on down/switch — no manual networksetup steps, no DNS leak.
#
# Kill switch + auto-reconnect (panah-watchdog):
# - `up` starts a root watchdog; an unexpected tunnel death blackholes the
#   default routes within ~1s and (if ar=on) retries the connection. An
#   intentional `down` disarms everything first.
set -e
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROTO_FILE="$DIR/.protocol"
# Private paths, never /tmp: the runtime DAITA config carries the device
# PRIVATE KEY and must not be readable by other local users (macOS /tmp is
# world-readable). /etc/panah is 0700; the pid/state dir 0755 so an
# unprivileged `./panah status` can still read the pidfile.
RUNTIME_DIR="/etc/panah"
RUN_DIR="/var/run/panah"
RUNTIME_CFG="$RUNTIME_DIR/panah-daita-runtime.json"
LIGHTWAY_PIDFILE="$RUN_DIR/lightway.pid"
DNS_STATE="$RUN_DIR/dns.state"
DAITA_CFG="$DIR/daita/panah-daita.json"
SETTINGS="$RUNTIME_DIR/settings"
WATCHDOG="$DIR/panah-watchdog"
WATCHDOG_ACTIVE="$RUN_DIR/watchdog.active"
# DAITA over TCP: WireGuard UDP rides a local udp2tcp shim to the server's
# tcp2udp (panah-tcp2udp.service, TCP 5001 -> 127.0.0.1:51820). Networks that
# drop WireGuard UDP still pass plain TCP. The UDP endpoint stays as-is on
# the server, so every other client (Android) is untouched.
UDP2TCP="$DIR/bin/udp2tcp"
UDP2TCP_PIDFILE="$RUN_DIR/udp2tcp.pid"
UDP2TCP_PINFILE="$RUN_DIR/udp2tcp.pin"
UDP2TCP_LISTEN="127.0.0.1:51821"
DAITA_TCP_PORT=5001

ensure_dirs() {
    if [ "$(id -u)" = 0 ]; then
        mkdir -p "$RUNTIME_DIR" "$RUN_DIR"
        chmod 700 "$RUNTIME_DIR"
        chmod 755 "$RUN_DIR"
    fi
    # persisted settings (ks/ar/dnsblock) — defaults: both protections on
    if [ "$(id -u)" = 0 ] && [ ! -f "$SETTINGS" ]; then
        printf 'ks=1\nar=1\ndnsblock=off\ndtr=tcp\n' > "$SETTINGS"
        chmod 600 "$SETTINGS"
    fi
}

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

# resolver address for the user's DNS-blocker choice (DAITA, per device)
daita_dns_for() {
    case "$(setting dnsblock off)" in
    ads)  echo "10.64.0.2" ;;
    mal)  echo "10.64.0.3" ;;
    both) echo "10.64.0.4" ;;
    *)    echo "10.64.0.1" ;;
    esac
}

# --- Panah brand — «نیمه‌شب و ولت» ANSI colors (see brand/TOKENS.txt) --------
if [ -t 1 ]; then
    V=$(printf '\033[38;2;197;249;75m')    # volt   #C5F94B — connected / ok
    A=$(printf '\033[38;2;255;181;71m')   # amber  #FFB547 — connecting
    C=$(printf '\033[38;2;255;138;138m')  # coral  #FF8A8A — off / error
    T=$(printf '\033[38;2;238;242;247m')  # text   #EEF2F7
    M=$(printf '\033[38;2;154;164;181m')  # muted  #9AA4B5
    R=$(printf '\033[0m')
else
    V=; A=; C=; T=; M=; R=
fi

last_protocol() {
    [ -f "$PROTO_FILE" ] && cat "$PROTO_FILE" || echo lightway
}

is_lightway_running() {
    [ -f "$LIGHTWAY_PIDFILE" ] && kill -0 "$(cat "$LIGHTWAY_PIDFILE")" 2>/dev/null
}

is_daita_running() {
    pgrep -f "panah-daita-client.* up$" >/dev/null 2>&1 || \
    pgrep -x gotatun >/dev/null 2>&1
}

# HONEST liveness (the watchdog and the connect paths use these):
# - the DAITA supervisor EXITS BY DESIGN once the tunnel is up (gotatun keeps
#   running as the daemon that IS the tunnel) — so gotatun-alive = up,
#   supervisor-alive = still connecting;
# - lightway's client process is the session, but "Online" is only proven by
#   its utun existing. The tunnel range is PUSHED BY THE SERVER in
#   AuthSuccessWithConfigV4 — the server allocates each session from its
#   ip_pool (this VPS: 10.125.0.0/16; older sessions saw 169.254.10.x and
#   100.64.0.x) — all of these are accepted.
#   Over TCP the shim is part of the tunnel: gotatun without it is dead.
is_daita_up() { pgrep -x gotatun >/dev/null 2>&1 && udp2tcp_ok; }
is_daita_connecting() { pgrep -f "panah-daita-client.* up$" >/dev/null 2>&1; }

# --- "routed" marker ---------------------------------------------------------
# The ONE signal that means connected: written only after the default route
# is proven to ride the tunnel. The GUI shows "connected" on it — not on the
# client's own Online (that came before routes/DNS, and a later failure left
# the app saying connected while traffic went around the VPN).
ROUTED="$RUN_DIR/routed"
mark_routed() {
    _if=$1
    if [ "$(route -n get 1.1.1.1 2>/dev/null | awk '/interface:/{print $2; exit}')" = "$_if" ] \
        && [ "$(route -n get 142.250.185.78 2>/dev/null | awk '/interface:/{print $2; exit}')" = "$_if" ]; then
        echo "$_if" > "$ROUTED"; chmod 644 "$ROUTED"
        echo "${V}[panah]${R} ${T}ROUTED: all IPv4 traffic rides $_if (verified)${R}"
        return 0
    fi
    rm -f "$ROUTED"
    echo "${C}[panah]${R} NOT ROUTED: default traffic still goes via $(route -n get 1.1.1.1 2>/dev/null | awk '/interface:/{print $2; exit}') — not $_if" >&2
    return 1
}
unmark_routed() { rm -f "$ROUTED"; }

# --- diagnostics ---------------------------------------------------------------
# diag_bg <tag> [delay] — a full panah-diag snapshot in the background (it
# takes ~30s of probes; a connect must not wait for it). Result: logs/ and a
# one-line summary in $RUN_DIR/diag.log.
diag_bg() {
    [ -x "$DIR/panah-diag" ] || return 0
    ( trap '' HUP; sleep "${2:-0}"; "$DIR/panah-diag" "$1" >> "$RUN_DIR/diag.log" 2>&1 ) \
        > /dev/null 2>&1 < /dev/null &
    echo "[panah] diagnostics snapshot '$1' scheduled -> $DIR/logs/diag-latest.txt"
}

# --- DAITA over TCP (udp2tcp shim) --------------------------------------------
# no pidfile = UDP mode = nothing to check
udp2tcp_ok() {
    [ ! -f "$UDP2TCP_PIDFILE" ] || kill -0 "$(cat "$UDP2TCP_PIDFILE")" 2>/dev/null
}
# udp2tcp_start <server_host> — pin the server to the physical gateway (the
# shim's TCP must never loop into the tunnel), then run the shim under a
# respawn loop: a dropped TCP session ends udp2tcp, the loop reopens it and
# gotatun's next packet (keepalive / handshake retry) rides the new one.
udp2tcp_start() {
    _host=$1
    udp2tcp_stop
    _gw=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2; exit}')
    case "$_gw" in ''|*:*) ;; *)
        route -n delete -host "$_host" >/dev/null 2>&1 || true
        route -n add -host "$_host" "$_gw" >/dev/null 2>&1 || true
        echo "$_host" > "$UDP2TCP_PINFILE"
        ;;
    esac
    (
        trap '' HUP
        trap 'kill "$_c" 2>/dev/null; exit 0' TERM
        while :; do
            "$UDP2TCP" --udp-listen "$UDP2TCP_LISTEN" \
                --tcp-forward "$_host:$DAITA_TCP_PORT" --nodelay &
            _c=$!
            wait "$_c"
            sleep 1
        done
    ) >> "$RUN_DIR/udp2tcp.log" 2>&1 < /dev/null &
    echo $! > "$UDP2TCP_PIDFILE"
    sleep 0.5
    echo "[panah] DAITA over TCP: $UDP2TCP_LISTEN -> $_host:$DAITA_TCP_PORT (pinned via ${_gw:-?})"
}
udp2tcp_stop() {
    if [ -f "$UDP2TCP_PIDFILE" ]; then
        kill "$(cat "$UDP2TCP_PIDFILE")" 2>/dev/null || true
        rm -f "$UDP2TCP_PIDFILE"
    fi
    pkill -f "^$UDP2TCP " 2>/dev/null || true
    if [ -f "$UDP2TCP_PINFILE" ]; then
        route -n delete -host "$(cat "$UDP2TCP_PINFILE")" >/dev/null 2>&1 || true
        rm -f "$UDP2TCP_PINFILE"
    fi
}

is_lightway_online() {
    is_lightway_running && ifconfig -a 2>/dev/null | grep -qE 'inet (10\.125\.|100\.64\.0\.|169\.254\.10\.)'
}

# --- kill switch blackholes (loopback /1 + /0-prefix pairs) -----------------
# Is the v4 default path currently blackholed? Ask a random public IP:
# 8.8.8.8 falls inside 0.0.0.0/1, so when the blackholes are in, its route
# lookup answers via loopback (a format-stable, version-proof test —
# `route get` with a bare /1 prefix is not guaranteed to be accepted).
blackholes_armed() {
    route -n get 8.8.8.8 2>/dev/null | grep -q '127\.0\.0\.1'
}
# SURGICAL clear: delete ONLY true blackholes (loopback gateway) — never a
# live tunnel's own /1 hijack, which uses the very same prefixes. The old
# unconditional delete was the root cause of the DAITA v4 leak:
# watchdog_start ran this "defensively" right AFTER the supervisor had
# installed the fresh hijack, wiped it, and (with no keeper in the old
# design) v4 silently escaped through en0 while v6 stayed tunneled.
# Probe the actual path a packet would take: a loopback answer means an
# armed blackhole; an en0/utun answer means the pair belongs to somebody
# (tunnel hijack) and must survive. A false positive can only try to
# delete a pair that is not there — harmless.
blackholes_clear() {
    if route -n get 8.8.8.8 2>/dev/null | grep -q '127\.0\.0\.1'; then
        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
    fi
    if route -n get -inet6 2606:4700:4700::1111 2>/dev/null | grep -q '::1'; then
        route -n delete -inet6 ::/1 2>/dev/null >/dev/null || true
        route -n delete -inet6 8000::/1 2>/dev/null >/dev/null || true
    fi
}
# a connect is a RECOVERY (a session existed before: the watchdog's reconnect
# invocation, or a hand `up` while the blackholes are armed) — its failure
# must re-lock, unlike a failed FIRST connect.
is_recovery() {
    [ -n "${PANAH_WATCHDOG_RECONNECT:-}" ] || blackholes_armed
}

# ---- pf pre-armed kill switch -----------------------------------------------
# The route-based kill switch is POST-ARMED: between a sudden tunnel death
# and the watchdog's 1s tick + arm, the default falls back to the physical
# interface (measured live: 7 straight seconds of en0 plus a real-IP exit
# through a whole failed reconnect cycle). macOS route tables cannot hold a
# pre-armed backup pair (equal prefixes, no deterministic priority), so the
# PRE-ARMED layer is pf(4): while a session is up, the physical interface
# may carry ONLY the tunnel endpoints (+ DHCP renewals). A sudden death then
# has NO exposure window at all — pf was already armed. Never clobbers a
# foreign ruleset; if one exists, we stay route-based and say so.
PF_ANCHOR="panah_ks"
pf_arm() {
    PW_IF=$(netstat -rn -f inet 2>/dev/null | awk '/^default/{print $NF; exit}')
    [ -z "$PW_IF" ] && { echo "[panah] pf kill switch: no physical default route — not arming" >&2; return 1; }
    # stock macOS pf.conf is nothing but com.apple/* anchors — that is not
    # "foreign". A ruleset with anything else (no com.apple, no us) is
    # somebody's own: never touch it.
    if pfctl -sr 2>/dev/null | grep -v 'com\.apple' | grep -v "$PF_ANCHOR" | grep -q .; then
        echo "[panah] pf kill switch: foreign pf ruleset present — staying route-based" >&2
        return 1
    fi
    # remember whether WE switch pf on, so disarm can restore honestly
    pfctl -s info 2>/dev/null | grep -q 'Status: Enabled' || touch /var/run/panah/pf.enabled-by-panah 2>/dev/null
    # compose ADDITIVELY: the system's pf.conf untouched + our anchor riding
    # along (pf's implicit default for unmatched packets is pass, so the
    # system behaves exactly as before — only our anchor decides blocks)
    { cat /etc/pf.conf 2>/dev/null; printf 'anchor "%s"\n' "$PF_ANCHOR"; } > /var/run/panah/pf-base.conf
    # the session's own tunnel interface: at arm time the default route
    # already rides it (the hijack was installed just before us)
    TUN_IF=$(route -n get 1.1.1.1 2>/dev/null | awk '/interface:/{print $2}')
    if [ -n "$TUN_IF" ] && [ "$TUN_IF" != "$PW_IF" ]; then
        # ONE-PATH-OUT: while a session lives, the tunnel is the only exit.
        # The physical interface may carry only the tunnel endpoints (+ DHCP);
        # everything else — INCLUDING the OS system utuns (iCloud Private
        # Relay and friends carry their own v4/v6 defaults) — is blocked, so
        # nothing can bypass Panah through an OS-level tunnel either.
        # flags any everywhere: pf's default S/SA filter starves the
        # mid-stream packets of flows established before the arm (lightway
        # died of this ~47s after every arm until "flags any" fixed it).
        cat > /var/run/panah/pf-ks.conf <<EOF
pass out quick on lo0 inet all flags any keep state
pass out quick on lo0 inet6 all flags any keep state
pass out quick on $TUN_IF inet all flags any keep state
pass out quick on $TUN_IF inet6 all flags any keep state
pass out quick on $PW_IF inet to { 2.31.21.198, 91.98.108.87, 2.31.73.228, 10.64.0.1 } flags any keep state
pass out quick on $PW_IF inet proto udp to any port 67 flags any keep state
block drop out quick all
EOF
    else
        # fallback (tunnel iface not identifiable — should not happen mid-up):
        # close at least the physical interface
        cat > /var/run/panah/pf-ks.conf <<EOF
pass out quick on $PW_IF inet to { 2.31.21.198, 91.98.108.87, 2.31.73.228, 10.64.0.1 } flags any keep state
pass out quick on $PW_IF inet proto udp to any port 67 flags any keep state
block drop out quick on $PW_IF inet
block drop out quick on $PW_IF inet6
EOF
    fi
    pfctl -f /var/run/panah/pf-base.conf >/dev/null 2>&1 || true
    pfctl -a "$PF_ANCHOR" -f /var/run/panah/pf-ks.conf >/dev/null 2>&1 || true
    pfctl -E >/dev/null 2>&1 || true
    if pfctl -s info 2>/dev/null | grep -q 'Status: Enabled'; then
        echo "[panah] pf kill switch ARMED on $PW_IF (pre-armed: sudden death has no exposure window)"
    else
        echo "[panah] pf kill switch FAILED to arm — route-based layer still active" >&2
    fi
}
pf_disarm() {
    pfctl -a "$PF_ANCHOR" -F all >/dev/null 2>&1 || true
    if [ -f /var/run/panah/pf.enabled-by-panah ]; then
        pfctl -d >/dev/null 2>&1 || true
        rm -f /var/run/panah/pf.enabled-by-panah
    fi
    echo "[panah] pf kill switch disarmed"
}
# a HAND recovery: the user typed `up` while the network was locked. Unlike
# the watchdog-driven attempts (marker set), a failure here must spawn a new
# inherited watchdog to keep the chain fail-closed — the watchdog-driven
# failure path must NOT (it would eat the calling watchdog's retry budget:
# every failed attempt would hand the flag to a fresh generation and the
# budget would never run out).
hand_recovery() {
    [ -z "${PANAH_WATCHDOG_RECONNECT:-}" ] && blackholes_armed
}

# --- DNS on our own server ---------------------------------------------------
# networksetup works per network SERVICE (Wi-Fi, Ethernet, ...); we set every
# enabled one so nothing falls back to the router/ISP resolver while tunnelled.
dns_services() {
    networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | grep -v '^\*'
}

dns_set() {
    DNS_IP=$1
    if [ -f "$DNS_STATE" ]; then
        # a protocol switch (or reconnect) with a session already up: the
        # state file holds the TRUE pre-tunnel originals — keep those, but
        # the LIVE resolvers must follow the NEW protocol's server. (The old
        # "kept" behavior left DAITA stuck on lightway's 100.64.0.1.)
        dns_apply "$DNS_IP"
        echo "[panah] DNS -> $DNS_IP (switched — your own server, through the tunnel)"
        return 0
    fi
    : > "$DNS_STATE"
    dns_services | while IFS= read -r svc; do
        [ -z "$svc" ] && continue
        cur=$(networksetup -getdnsservers "$svc" 2>/dev/null || true)
        printf '%s\n' "$svc|$cur" >> "$DNS_STATE"
        networksetup -setdnsservers "$svc" "$DNS_IP" >/dev/null 2>&1 || true
    done
    echo "[panah] DNS -> $DNS_IP (your own server, through the tunnel)"
}

dns_restore() {
    if [ ! -f "$DNS_STATE" ]; then return 0; fi
    while IFS='|' read -r svc cur; do
        [ -z "$svc" ] && continue
        if printf '%s' "$cur" | grep -qi "aren't any"; then
            networksetup -setdnsservers "$svc" "Empty" >/dev/null 2>&1 || true
        else
            networksetup -setdnsservers "$svc" "$cur" >/dev/null 2>&1 || true
        fi
    done < "$DNS_STATE"
    rm -f "$DNS_STATE"
    echo "[panah] DNS restored to previous settings."
}

# live resolver switch (dns blocker choice) while the DAITA tunnel is up:
# rewrites every service WITHOUT touching the saved original settings —
# `down` still restores the true pre-tunnel state.
dns_apply() {
    DNS_IP=$1
    dns_services | while IFS= read -r svc; do
        [ -z "$svc" ] && continue
        networksetup -setdnsservers "$svc" "$DNS_IP" >/dev/null 2>&1 || true
    done
}

# --- kill switch / watchdog control ------------------------------------------
watchdog_stop() {
    # INTENTIONAL stop: remove the flag first so the watchdog never arms,
    # then drop any blackholes it may have left behind.
    rm -f "$WATCHDOG_ACTIVE"
    blackholes_clear
}

# watchdog_start [inherit] — spawn the watchdog. "inherit" makes the new
# watchdog treat a failed connect as a death (recovery semantics): a session
# existed before, so the chain stays fail-closed.
watchdog_start() {
    # stale blackholes from an earlier unexpected death must not shadow the
    # fresh tunnel — the watchdog also clears them defensively.
    blackholes_clear
    # plain background spawn — NO nohup: in the daemon context there is no
    # controlling terminal and nohup aborts outright ("can't detach from
    # console: No such process" — watchdog-spawn.log). The watchdog itself
    # ignores HUP (trap in its header), which is the nohup guarantee for
    # the interactive case.
    if [ "${1:-}" = "inherit" ]; then
        PANAH_INHERIT_LOCK=1 "$WATCHDOG" >> "$RUN_DIR/watchdog-spawn.log" 2>&1 &
    else
        PANAH_INHERIT_LOCK=0 "$WATCHDOG" >> "$RUN_DIR/watchdog-spawn.log" 2>&1 &
    fi
    sleep 1
    if pgrep -f panah-watchdog >/dev/null 2>&1; then
        echo "[panah] watchdog running (pid $(pgrep -f panah-watchdog | head -1))"
    else
        echo "[panah] WATCHDOG FAILED TO START — see $RUN_DIR/watchdog-spawn.log"
    fi
    echo "[panah] watchdog: kill switch $( [ "$(setting ks 1)" = 1 ] && echo ON || echo OFF ), auto-reconnect $( [ "$(setting ar 1)" = 1 ] && echo ON || echo OFF )"
}

# the live Lightway utun: the interface carrying a server-pushed tunnel
# address (same ranges as is_lightway_online); empty until Online
lightway_tun_if() {
    ifconfig -a 2>/dev/null | awk '
        /^[a-z]/ { i = $1; sub(":$", "", i) }
        /inet (10\.125\.|100\.64\.0\.|169\.254\.10\.)/ { print i; exit }'
}

# a Lightway connect that never carried traffic: tear the client down and
# fail honestly (same recovery semantics as a client that did not start)
lightway_fail() {
    stop_lightway
    diag_bg lightway-fail
    if hand_recovery; then
        watchdog_start inherit
        echo "${C}[panah]${R} kill switch stays ON (traffic blocked) — panah up to retry, panah down to release." >&2
    elif [ -n "${PANAH_WATCHDOG_RECONNECT:-}" ]; then
        echo "${C}[panah]${R} connect failed — the watchdog keeps managing retries." >&2
    else
        echo "${C}[panah]${R} Lightway connect failed — nothing changed on this Mac." >&2
    fi
    exit 1
}

lightway_up() {
    echo "${A}[panah]${R} ${T}Lightway (TCP 443, x25519mlkem768) — connecting ...${R}"
    # a recovery connect needs the underlay back (blackholes from an earlier
    # unexpected death would swallow the very connect that should heal it)
    blackholes_clear
    # lightway-client audits the WHOLE ownership chain of its config file;
    # under a user-owned home it aborts as root ("Bad owner (UID ...)").
    # Stage config + ca.crt in the root-owned /etc/panah — every ancestor
    # is root there, so the security check passes.
    mkdir -p /etc/panah
    cp "$DIR/lightway/client_config.yaml" /etc/panah/lightway-client.yaml
    cp "$DIR/lightway/ca.crt" /etc/panah/ca.crt
    chmod 600 /etc/panah/lightway-client.yaml
    (cd /etc/panah && sudo "$DIR/bin/lightway-client" --config-file lightway-client.yaml) &
    echo $! > "$LIGHTWAY_PIDFILE"
    # the client's OWN route installs use address gateways this macOS never
    # delivers — while present they swallow the matched traffic (that is
    # exactly what cut the network during testing). Keep the table clean
    # while the session establishes; OUR deliverable link-style routes come
    # only after the data path is proven below.
    PEER_CFG=$(awk -F': *' '/^tun_peer_ip:/{print $2}' "$DIR/lightway/client_config.yaml" 2>/dev/null)
    # Wait for the session to come ONLINE (its utun carries a server-pushed
    # address) instead of a fixed 4s: on a slow or filtered network the TLS
    # session needs longer, and probing before Online always failed — the
    # client then stayed "connected" with no routes (traffic around the VPN).
    _i=0
    _online=0
    while [ $_i -lt 60 ] && is_lightway_running; do
        route delete -net 0.0.0.0/1 "$PEER_CFG" >/dev/null 2>&1 || true
        route delete -net 128.0.0.0/1 "$PEER_CFG" >/dev/null 2>&1 || true
        route delete -net 0.0.0.0/1 >/dev/null 2>&1 || true
        route delete -net 128.0.0.0/1 >/dev/null 2>&1 || true
        if [ $_online = 1 ]; then break; fi
        # one more clean pass after Online: the client installs its routes
        # right after the address — sweep them before we probe
        [ -n "$(lightway_tun_if)" ] && { _online=1; sleep 1; continue; }
        _i=$((_i+1)); sleep 0.5
    done
    if ! is_lightway_running; then
        echo "${C}[panah]${R} lightway-client did not start — check lightway/client_config.yaml" >&2
        if hand_recovery; then
            watchdog_start inherit
            echo "${C}[panah]${R} kill switch stays ON (traffic blocked) — panah up to retry, panah down to release." >&2
        elif [ -n "${PANAH_WATCHDOG_RECONNECT:-}" ]; then
            echo "${C}[panah]${R} connect failed — the watchdog keeps managing retries." >&2
        fi
        exit 1
    fi
    # --- Mullvad-grade guarantee: PROVE the tunnel carries real traffic
    # BEFORE touching routes or DNS — never switch onto a dead data path.
    # The probe is OUR OWN SERVER's panel (/api/ip): the fetch goes out
    # through the tunnel and the answer can only come back through it — a
    # third-party probe (Cloudflare/Mullvad) could also be answered by the
    # ISP path, this one cannot. The /1 default hijack only follows a
    # successful fetch; on failure the machine stays exactly as it was.
    PEER_IP=$(awk -F': *' '/^tun_peer_ip:/{print $2}' "$DIR/lightway/client_config.yaml" 2>/dev/null)
    SERVER_IP=$(awk -F': *' '/^server:/{print $2}' "$DIR/lightway/client_config.yaml" 2>/dev/null | cut -d: -f1)
    TUN_DNS=$(awk -F': *' '/^tun_dns_ip:/{print $2}' "$DIR/lightway/client_config.yaml" 2>/dev/null)
    # the session's OWN utun (by its server-pushed address) — a route lookup
    # for the configured peer falls back to en0 before Online and probed the
    # wrong interface
    TUN_IF=$(lightway_tun_if)
    if [ -z "$TUN_IF" ]; then
        echo "${C}[panah]${R} Lightway did not come online within 30s — the server is unreachable on this network (TLS to it is blocked or dropped)." >&2
        lightway_fail
    fi
    if [ -n "$TUN_IF" ]; then
        # probe the panel at the server's IN-TUNNEL service address (the
        # tun_dns_ip — nginx and unbound both answer on the server's
        # lightway tun): that address is reachable ONLY through the tunnel,
        # so an "ok" answer is a true data-path proof. The client installs
        # its own /32 route for this address at session start (gateway-style
        # — today PROVEN deliverable: the probe rides exactly that route).
        # Touch nothing unless it is missing: every route write here wakes
        # the client's network-change monitor, which tears ALL vpn routes
        # down (the war that ate the first hijack). (The old probe — the
        # server's PUBLIC IP — additionally looped the client's underlay
        # session into the tunnel: the RST storm in /tmp/panah-tun-diag.txt.)
        PROBE_ROUTE_ADDED=0
        if ! route -n get "$TUN_DNS" 2>/dev/null | grep -q "interface: $TUN_IF"; then
            route add -host "$TUN_DNS" -interface "$TUN_IF" >/dev/null 2>&1 || true
            PROBE_ROUTE_ADDED=1
        fi
        # a few tries: the first in-tunnel TLS right after Online can lose a
        # race with the client's own route churn
        _probe_ok=0
        for _t in 1 2 3; do
            if curl -k -s --max-time 6 "https://$TUN_DNS:8443/api/ip" | grep -q '"ok"[[:space:]]*:[[:space:]]*true'; then
                _probe_ok=1; break
            fi
            sleep 1
        done
        if [ $_probe_ok = 1 ]; then
            echo "${V}[panah]${R} ${T}data path OK — your server answered through the tunnel${R}"
            [ "$PROBE_ROUTE_ADDED" = 1 ] && route delete -host "$TUN_DNS" >/dev/null 2>&1 || true
            # the client installs its OWN /1 pair at session start. If that
            # hijack is present LEAVE IT ALONE (its /32 probe route just
            # delivered) — only install our link-style pair when the table
            # has lost it. Never rewrite a working hijack: the route churn
            # wakes the client's change-monitor and costs us the whole table.
            if route -n get 1.1.1.1 2>/dev/null | grep -q "interface: $TUN_IF"; then
                echo "${V}[panah]${R} ${T}default-route hijack active (the client's own /1 pair)${R}"
            else
                echo "${V}[panah]${R} ${T}installing the default-route hijack (link-style /1 pair)${R}"
                route delete -net 0.0.0.0/1 >/dev/null 2>&1 || true
                route delete -net 128.0.0.0/1 >/dev/null 2>&1 || true
                route add -net 0.0.0.0/1 -interface "$TUN_IF" >/dev/null 2>&1 || true
                route add -net 128.0.0.0/1 -interface "$TUN_IF" >/dev/null 2>&1 || true
            fi
            # DNS only now that the data path is proven: every service points
            # at the tunnel resolver — the server's own unbound on 100.64.0.1
            # (the tun_dns_ip the panel-staged config carries). On a failed
            # probe the machine's DNS is left exactly as it was.
            dns_set "${TUN_DNS:-100.64.0.1}"
            mark_routed "$TUN_IF" || lightway_fail
            # NOTE: no route keeper here anymore. The old detached subshell
            # (sleep 5) RACED protocol switches: in its last breath it
            # re-pinned the dying lightway /1 pair right AFTER the new DAITA
            # tunnel had installed its own — "File exists" swallowed by
            # best-effort install — then died with utun4 and left v4
            # unlocked through en0. The watchdog's single hijack keeper
            # (1s tick, flag-checked before every write) owns this job now.
        else
            echo "${C}[panah]${R} tunnel carries no traffic — default NOT hijacked, DNS untouched (network untouched)"
            route delete -host "$TUN_DNS" >/dev/null 2>&1 || true
            # --- deep diagnosis (root-only tools): do LINK-style routes
            # deliver at all? probe 1.1.1.1 the same way while tcpdumping
            (
                route delete -host 1.1.1.1 >/dev/null 2>&1 || true
                route add -host 1.1.1.1 -interface "$TUN_IF" >/dev/null 2>&1 || true
                tcpdump -i "$TUN_IF" -n > /tmp/panah-tun-diag.txt 2>&1 &
                TP=$!
                sleep 1
                ping -c 3 -t 3 1.1.1.1 >/dev/null 2>&1
                curl -k -s --max-time 4 -o /dev/null https://1.1.1.1/ 2>/dev/null
                sleep 2
                kill "$TP" 2>/dev/null
                route delete -host 1.1.1.1 >/dev/null 2>&1 || true
                chmod 644 /tmp/panah-tun-diag.txt 2>/dev/null || true
            )
            # never report a session that carries nothing as connected
            lightway_fail
        fi
    fi
    # the client process is the session (it keeps retrying auth on its own —
    # pid-alive is the right liveness signal here); the WATCHDOG decides the
    # honest part (Online = its utun exists) and locks only real deaths.
    if is_recovery; then
        watchdog_start inherit
    else
        watchdog_start
    fi
    # the PRE-ARMED layer (see daita_up): pf holds the physical interface
    # closed to everything but the tunnel endpoints while we are up
    if [ "$(setting ks 1)" = "1" ]; then pf_arm; else pf_disarm; fi
    echo "${V}[panah]${R} ${T}Lightway client running (background).${R} ${M}./panah status — check the exit IP.${R}"
    diag_bg lightway-up 15
}

daita_up() {
    if [ ! -f "$DAITA_CFG" ]; then
        echo "${C}[panah]${R} missing $DAITA_CFG — paste the one-time device config from the panel first." >&2
        exit 1
    fi
    # rewrite gotatun_path to the absolute location of the bundled binary,
    # so the user never edits paths by hand — into the private runtime dir
    ensure_dirs
    sed "s#\"gotatun_path\"[^,}]*#\"gotatun_path\": \"$DIR/bin/gotatun\"#" \
        "$DAITA_CFG" > "$RUNTIME_CFG"
    chmod 600 "$RUNTIME_CFG"
    # transport: tcp (default) sends WireGuard through the udp2tcp shim — the
    # runtime copy points gotatun at the shim; the panel's config is untouched
    if [ "$(setting dtr tcp)" = "tcp" ] && [ -x "$UDP2TCP" ]; then
        D_SRV=$(sed -n 's/.*"server_endpoint" *: *"\([^"]*\)".*/\1/p' "$RUNTIME_CFG" | head -1)
        D_HOST=${D_SRV%:*}
        if [ -n "$D_HOST" ]; then
            udp2tcp_start "$D_HOST"
            sed "s#\"server_endpoint\" *: *\"[^\"]*\"#\"server_endpoint\": \"$UDP2TCP_LISTEN\"#" \
                "$RUNTIME_CFG" > "$RUNTIME_CFG.tmp" && mv "$RUNTIME_CFG.tmp" "$RUNTIME_CFG"
            chmod 600 "$RUNTIME_CFG"
        fi
    else
        udp2tcp_stop
        echo "[panah] DAITA over UDP (direct WireGuard)"
    fi
    echo "${A}[panah]${R} ${T}DAITA (WireGuard + ML-KEM-1024 + HQC-256 + Classic McEliece) — connecting ...${R}"
    echo "${M}[panah] expected: parent tunnel -> relay (3 ciphertexts) -> ephemeral identity -> data path${R}"
    # a recovery connect needs the underlay back (see lightway_up)
    blackholes_clear
    dns_set "$(daita_dns_for)"
    sudo "$DIR/bin/panah-daita-client" -c "$RUNTIME_CFG" up || true
    # The supervisor EXITS BY DESIGN once the tunnel is up (gotatun keeps
    # running as the daemon that IS the tunnel) — so its return says nothing
    # about the session. Poll briefly for the REAL tunnel:
    i=0
    while [ "$i" -lt 15 ]; do
        is_daita_up && break
        sleep 1
        i=$((i+1))
    done
    if is_daita_up; then
        # ENSURE the v4 hijack actually landed. The supervisor installs the
        # /1 pair best-effort and SWALLOWS "File exists" — if anything raced
        # us (a stale pair left over from the previous protocol), the table
        # keeps the stale pair and v4 silently leaks through en0 the moment
        # it dies. One honest check; fix + log either way.
        # NOTE: macOS serializes this JSON Apple-style — "interface" : "utun9"
        # (space BEFORE the colon). The pattern tolerates spaces on both sides.
        D_IF=$(sed -n 's/.*"interface" *: *"\([^"]*\)".*/\1/p' "$RUNTIME_CFG" | head -1)
        if [ -n "$D_IF" ] && ifconfig "$D_IF" >/dev/null 2>&1; then
            if route -n get 1.1.1.1 2>/dev/null | grep -q "interface: $D_IF"; then
                echo "${V}[panah]${R} ${T}default-route hijack verified via $D_IF${R}"
            else
                echo "${M}[panah]${R} hijack missing — installing the link-style /1 pair via $D_IF ...${M}"
                route delete -net 0.0.0.0/1 >/dev/null 2>&1 || true
                route delete -net 128.0.0.0/1 >/dev/null 2>&1 || true
                route add -net 0.0.0.0/1 -interface "$D_IF" >/dev/null 2>&1 || true
                route add -net 128.0.0.0/1 -interface "$D_IF" >/dev/null 2>&1 || true
                if route -n get 1.1.1.1 2>/dev/null | grep -q "interface: $D_IF"; then
                    echo "${V}[panah]${R} ${T}default-route hijack installed via $D_IF${R}"
                else
                    echo "${C}[panah]${R} hijack install FAILED via $D_IF — check the route table." >&2
                fi
            fi
            if ! mark_routed "$D_IF"; then
                # a tunnel that carries none of the Mac's traffic is a failed
                # connect, never "connected"
                stop_daita
                diag_bg daita-notrouted
                [ -n "${PANAH_WATCHDOG_RECONNECT:-}" ] || blackholes_armed || dns_restore
                echo "${C}[panah]${R} DAITA connect failed — the tunnel came up but the Mac's traffic could not be routed into it." >&2
                exit 1
            fi
        fi
        watchdog_start
        # the PRE-ARMED layer: pf lets the physical interface carry only
        # the tunnel endpoints while this session lives — a sudden death
        # then has no exposure window at all
        if [ "$(setting ks 1)" = "1" ]; then pf_arm; else pf_disarm; fi
        echo "${V}[panah]${R} ${T}DAITA tunnel is running (gotatun, background).${R} ${M}./panah status — check the exit IP. sudo ./panah down to disconnect.${R}"
        diag_bg daita-up 15
    else
        udp2tcp_stop
        diag_bg daita-fail
        # connect failed — the Rust `up` tears its own half-tunnel down, so
        # nothing is half-up. The lock only stays when a session existed
        # before (recovery) — a failed FIRST connect never locks the network.
        # DNS: keep the in-tunnel resolver for every recovery/retry path (a
        # restore would open a plain-ISP DNS window between attempts; with
        # the blackholes in, queries to 10.64.0.x simply fail = fail-closed).
        # Only a plain failed first connect restores the previous settings.
        if [ -n "${PANAH_WATCHDOG_RECONNECT:-}" ]; then
            echo "${C}[panah]${R} DAITA connect failed — the watchdog keeps managing retries." >&2
        elif blackholes_armed; then
            watchdog_start inherit
            echo "${C}[panah]${R} DAITA connect failed — kill switch stays ON (traffic blocked); panah up to retry, panah down to release." >&2
        else
            dns_restore
            echo "${C}[panah]${R} DAITA connect failed — nothing is locked (no session existed); DNS restored." >&2
        fi
        exit 1
    fi
}

stop_lightway() {
    unmark_routed
    if [ -f "$LIGHTWAY_PIDFILE" ]; then
        sudo kill "$(cat "$LIGHTWAY_PIDFILE")" 2>/dev/null || true
        rm -f "$LIGHTWAY_PIDFILE"
    fi
    sudo pkill -x lightway-client 2>/dev/null || true
    # undo the hijack + any probe host routes from lightway_up — an
    # intentional `down` must never leave the machine blackholed or pinned
    # to a dead utun
    route delete -net 0.0.0.0/1 >/dev/null 2>&1 || true
    route delete -net 128.0.0.0/1 >/dev/null 2>&1 || true
    route delete -host 162.159.36.1 >/dev/null 2>&1 || true
    route delete -host 1.1.1.1 >/dev/null 2>&1 || true
    _SRV=$(awk -F': *' '/^server:/{print $2}' "$DIR/lightway/client_config.yaml" 2>/dev/null | cut -d: -f1)
    route delete -host "$_SRV" >/dev/null 2>&1 || true
    # the staged config holds the session password — never let it linger
    sudo rm -f /etc/panah/lightway-client.yaml
}

stop_daita() {
    unmark_routed
    if [ -f "$RUNTIME_CFG" ]; then
        sudo "$DIR/bin/panah-daita-client" -c "$RUNTIME_CFG" down 2>/dev/null || true
    fi
    sudo pkill -x gotatun 2>/dev/null || true
    udp2tcp_stop
}

case "${1:-}" in
up)
    ensure_dirs
    # ONE up at a time, whoever asks (GUI op, watchdog attempt, CLI):
    # concurrent ups race each other's teardown/recycle steps and murder
    # each other's fresh parents (live: 16:56-16:58 — the relaunching
    # GUI's up vs the watchdog's attempt killed the healed session 14s
    # after it landed). Non-blocking: the caller retries (the watchdog
    # loop) or reports (the GUI). A stale lock from a kill -9 holder is
    # broken after 3 minutes.
    if [ -d /var/run/panah/up.lock ]; then
        LOCK_AGE=$(( $(date +%s) - $(stat -f %m /var/run/panah/up.lock 2>/dev/null || echo 0) ))
        [ "$LOCK_AGE" -gt 180 ] && rmdir /var/run/panah/up.lock 2>/dev/null
    fi
    if mkdir /var/run/panah/up.lock 2>/dev/null; then
        trap 'rmdir /var/run/panah/up.lock 2>/dev/null' EXIT INT TERM
    else
        echo "${A}[panah]${R} another connect is already in flight — not racing it." >&2
        exit 0
    fi
    # Hand-invoked switch (not a watchdog reconnect, not an armed recovery):
    # neutralize any watchdog from the previous session BEFORE tearing the old
    # tunnel down — otherwise it would see that teardown as an unexpected
    # death and lock the network mid-switch.
    if [ -z "${PANAH_WATCHDOG_RECONNECT:-}" ] && ! blackholes_armed; then
        watchdog_stop
    fi
    PROTO="${2:-$(last_protocol)}"
    case "$PROTO" in
    # each up RECYCLES its own protocol's old session first (a graceful
    # server-side teardown + client kills): the 15:21 failure mode was an
    # `up` over a still-live tunnel — the second parent raced the first
    # one's server-held ephemeral slot and RegisterPeerV1 timed out.
    lightway) stop_daita; stop_lightway; echo lightway > "$PROTO_FILE"; lightway_up ;;
    daita)    stop_lightway; stop_daita; echo daita > "$PROTO_FILE"; daita_up ;;
    *) echo "usage: $0 up [lightway|daita]" >&2; exit 2 ;;
    esac
    ;;
down)
    # INTENTIONAL disconnect — release EVERY layer: the pf pre-armed set,
    # the watchdog, the clients, the resolver
    watchdog_stop
    stop_lightway
    stop_daita
    dns_restore
    pf_disarm
    echo "${C}[panah] disconnected.${R}"
    ;;
ks|ar)
    [ "$(id -u)" = 0 ] || { echo "sudo required" >&2; exit 1; }
    ensure_dirs
    KEY="$1"
    VAL="${2:-}"
    case "$VAL" in on|off) ;; *) echo "usage: sudo $0 $KEY on|off" >&2; exit 2 ;; esac
    v=$([ "$VAL" = on ] && echo 1 || echo 0)
    grep -v "^$KEY=" "$SETTINGS" 2>/dev/null > "$SETTINGS.tmp" || true
    printf '%s=%s\n' "$KEY" "$v" >> "$SETTINGS.tmp"
    mv "$SETTINGS.tmp" "$SETTINGS"
    chmod 600 "$SETTINGS"
    # reflect immediately: a running watchdog reads the file every event
    if [ "$KEY" = ks ] && [ "$v" = 0 ]; then
        blackholes_clear
        pf_disarm
    fi
    # ks ON while a tunnel is up: pre-arm pf right now (the route layer
    # only arms on death — pf is the belt that must already be there)
    if [ "$KEY" = ks ] && [ "$v" = 1 ] && { is_daita_up || is_lightway_online; }; then
        pf_arm
    fi
    if [ "$KEY" = ks ]; then
        echo "${V}[panah]${R} kill switch: ${T}$VAL${R}"
    else
        echo "${V}[panah]${R} auto-reconnect: ${T}$VAL${R}"
    fi
    ;;
diag)
    # full snapshot now, in the foreground (sudo adds pf + tunnel counters)
    exec "$DIR/panah-diag" "${2:-manual}"
    ;;
tr)
    [ "$(id -u)" = 0 ] || { echo "sudo required" >&2; exit 1; }
    ensure_dirs
    VAL="${2:-}"
    case "$VAL" in tcp|udp) ;; *) echo "usage: sudo $0 tr tcp|udp" >&2; exit 2 ;; esac
    grep -v "^dtr=" "$SETTINGS" 2>/dev/null > "$SETTINGS.tmp" || true
    printf 'dtr=%s\n' "$VAL" >> "$SETTINGS.tmp"
    mv "$SETTINGS.tmp" "$SETTINGS"
    chmod 600 "$SETTINGS"
    echo "${V}[panah]${R} DAITA transport: ${T}$VAL${R} ${M}(applied on the next DAITA connect)${R}"
    ;;
dns)
    [ "$(id -u)" = 0 ] || { echo "sudo required" >&2; exit 1; }
    ensure_dirs
    VAL="${2:-}"
    case "$VAL" in off|ads|mal|both) ;; *) echo "usage: sudo $0 dns off|ads|mal|both" >&2; exit 2 ;; esac
    grep -v "^dnsblock=" "$SETTINGS" 2>/dev/null > "$SETTINGS.tmp" || true
    printf 'dnsblock=%s\n' "$VAL" >> "$SETTINGS.tmp"
    mv "$SETTINGS.tmp" "$SETTINGS"
    chmod 600 "$SETTINGS"
    if is_daita_running && [ -f "$DNS_STATE" ]; then
        # live DAITA tunnel: switch the resolver right now
        dns_apply "$(daita_dns_for)"
        echo "${V}[panah]${R} DAITA DNS -> $(daita_dns_for) ($(case "$VAL" in off) echo clean;; ads) echo ads+trackers;; mal) echo malware;; both) echo ads+malware;; esac)) — applied now"
    elif is_lightway_running; then
        echo "${A}[panah]${R} Lightway is up: its resolver is PUSHED BY THE SERVER (protocol design) — the admin switches it in the panel. Your choice applies to DAITA."
    else
        echo "${V}[panah]${R} DNS blocker choice saved ($(case "$VAL" in off) echo clean;; ads) echo ads+trackers;; mal) echo malware;; both) echo ads+malware;; esac)) — applied on the next DAITA connect"
    fi
    ;;
status)
    if is_lightway_online; then
        echo "${M}protocol :${R} ${V}LIGHTWAY${R} ${T}(online, pid $(cat "$LIGHTWAY_PIDFILE" 2>/dev/null || echo '?'))${R}"
    elif is_lightway_running; then
        echo "${M}protocol :${R} ${A}LIGHTWAY${R} ${T}(starting — client alive, tunnel not up yet)${R}"
    elif is_daita_up; then
        echo "${M}protocol :${R} ${V}DAITA${R} ${T}(running — gotatun daemon)${R}"
        # root shows the supervisor detail; a regular user gets a passwordless
        # sudo attempt only (no prompt spam from a plain status call)
        sudo -n "$DIR/bin/panah-daita-client" -c "$RUNTIME_CFG" status 2>/dev/null || true
    elif is_daita_connecting; then
        echo "${M}protocol :${R} ${A}DAITA${R} ${T}(connecting ...)${R}"
    else
        echo "${M}protocol :${R} ${C}none${R} ${T}(last used: $(last_protocol))${R}"
    fi
    ks_on=$([ "$(setting ks 1)" = 1 ] && echo on || echo off)
    ar_on=$([ "$(setting ar 1)" = 1 ] && echo on || echo off)
    wd_pid=$(cat "$WATCHDOG_ACTIVE" 2>/dev/null || true)
    if [ -n "$wd_pid" ] && kill -0 "$wd_pid" 2>/dev/null; then wd=watching; else wd=idle; fi
    blocked=$([ "$(id -u)" = 0 ] && { blackholes_armed && echo yes || echo no; } || echo '?')
    db=$(setting dnsblock off)
    echo "${M}kill-sw  :${R} ${T}$ks_on${R} ${M}($wd, blocked=$blocked) · auto-reconnect: $ar_on · dns block: $db · daita: $(setting dtr tcp)${R}"
    echo "${M}dns      :${R} ${T}$(scutil --dns 2>/dev/null | grep -m1 'nameserver\[0\]' || echo 'unknown')${R}"
    # exit IP asked from your OWN panel; through the tunnel the server sees
    # the tunnel address (10.64.x / 100.64.x) — the real exit is always the VPS.
    IP=$(curl -s --max-time 8 https://api.ipify.org | grep -oE '^[0-9.]+$')
    [ -n "$IP" ] || IP="?"
    case "$IP" in
    2.31.21.198|10.64.*|100.64.*|169.254.*)
        echo "${M}exit ip  :${R} ${V}2.31.21.198${R} ${T}— through your Panah server${R}"
        ;;
    *)
        echo "${M}exit ip  :${R} ${T}$IP${R} ${M}— unprotected (2.31.21.198 = your Panah server)${R}"
        ;;
    esac
    ;;
*)
    echo "usage: $0 up [lightway|daita] | down | status" >&2
    exit 2
    ;;
esac
