Files
PCA_Phobos/server/phobos-health.sh
Andrei Leibzon 1f3eced228 release 1.5.0: one panel in all channels; Apple apps (iOS, new macOS tab) only in beta/dev
- stable gets the beta panel and server features (new design, presets, firewall,
  monthly traffic, router failover over all ports/addresses + TCP, altip, HR tab,
  Windows tab, update banner); ios/ macos/ windows/ sources stay out of stable
- menu item "Update to N" under Settings when a new version is out
- mobile: top menu no longer stretches to 200px
- urllib User-Agent: Cloudflare in front of git.bobyrev.com returns 403 to Python-urllib
- docs: GitHub adleibzon-rgb as source, password/uninstall/devices sections,
  new screenshots, PORTS.md TCP fallback and second IP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:12:13 +03:00

585 lines
23 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/sh
# ============================================================
# Phobos Health Monitor v2 — Keenetic Edition
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
# Runs via cron every 60 seconds
# ============================================================
PHOBOS_DIR="/opt/etc/Phobos"
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
# главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
STATE="$ACC_DIR/state"
LOG="$ACC_DIR/health.log"
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
done
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
WG_IF="Wireguard3"
MAX_LOG_LINES=200
# Thresholds (seconds)
HANDSHAKE_WARN=150
HANDSHAKE_PORT_HOP=300
HANDSHAKE_SERVER_SWITCH=600
PRIMARY_CHECK_INTERVAL=300
# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable,
# so reachability is tested with curl against the panel's obfuscator-health
# endpoint on the primary VPS. It returns http 200 ONLY when all
# wg-obfuscator-* services are active — the bare panel port stays up even
# when the tunnel path is dead, so we must NOT switch back on panel liveness
# alone (that caused premature switchback to a dead primary).
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$ACC_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health"
# Connectivity check targets (logged for context only)
CHECK_HOST_1="8.8.8.8"
CHECK_HOST_2="1.1.1.1"
log() {
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
}
# Prevent concurrent runs
if [ -f "$LOCKFILE" ]; then
pid=$(cat "$LOCKFILE" 2>/dev/null)
if kill -0 "$pid" 2>/dev/null; then
exit 0
fi
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
mkdir -p "$STATE"
# ── Detect WG interface ──
detect_wg_interface() {
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
# HydraRoute Neo names the interface by the bare client id (09.33)
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
WG_IF="Wireguard${i}"
return 0
fi
done
[ -n "$PHOBOS_ACC" ] && return 1
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
if echo "$desc" | grep -qi phobos; then
WG_IF="Wireguard${i}"
return 0
fi
done
return 1
}
# ── Restart own obfuscator (init script whose ARGS name our config) ──
restart_obf() {
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
if [ -n "$sc" ]; then
"$sc" restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
}
# ── Read failover config ──
if [ ! -f "$CONF" ]; then
log "ERROR: no failover.conf"
exit 1
fi
SERVER_COUNT=0
idx=1
while true; do
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
[ -z "$val" ] && break
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
if [ "$SERVER_COUNT" -eq 0 ]; then
log "ERROR: no servers in failover.conf"
exit 1
fi
# Read state
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
UDPTCP_BIN=/opt/bin/udptcp
UDPTCP_LPORT=13256
# ── Detect Phobos WG interface ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
detect_wg_interface || {
log "ERROR: own WG interface not found, skip"
exit 0
}
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
# such route → pull went to WAN and hung, 09.33) ──
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
fi
# ── Get handshake age via ndmc ──
# A peer switch can leave the removed peer's stale handshake listed in ndmc
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
# positive, sane) handshake across all peers — that is the live tunnel.
get_handshake_age() {
hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \
| grep "last-handshake" \
| awk '{v=$2} v>0 && v<86400 {print v}' \
| sort -n | head -1)
if [ -z "$hs" ]; then
echo "9999"
else
echo "$hs"
fi
}
# ── Check real connectivity ──
check_connectivity() {
ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0
ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0
return 1
}
# ── Get port by index ──
get_port() {
server_idx=$1; port_idx=$2
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
}
get_port_count() {
server_idx=$1
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | wc -l
}
# ── Keys currently on the interface (includes the interface's OWN key) ──
# WG public keys are 43 base64 chars + '='. The interface's own public-key is
# also matched here — callers must only act on KNOWN server WGKEYs so the
# local key is never touched.
list_iface_keys() {
ndmc -c "show interface $WG_IF" 2>/dev/null \
| grep -oE '[A-Za-z0-9+/]{43}=' | sort -u
}
# Count how many KNOWN server WGKEYs are currently attached as peers.
count_server_peers() {
present=$(list_iface_keys)
n=0
seen=" "
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
# one server may have several addresses = same key in several entries
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
seen="$seen$wk "
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
n=$((n + 1))
fi
i=$((i + 1))
done
echo "$n"
}
# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ──
# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and
# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make
# egress routing ambiguous. We hard-purge every OTHER known server key via the
# ndmc CLI (reliable) — never the interface's own key — then ensure new_key.
switch_wg_peer() {
new_key=$1
if [ -z "$new_key" ]; then
log "WARN: no WGKEY for target server, skip peer switch"
return 1
fi
present=$(list_iface_keys)
# Purge any OTHER known server key that is attached
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then
log "WG PEER purge: $wk"
ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1
fi
i=$((i + 1))
done
# Add the target peer if it is not already present
if ! echo "$present" | grep -q "^${new_key}$"; then
log "WG PEER add: $new_key"
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
lport=${lport:-13255}
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
fi
# Persist config
ndmc -c "system configuration save" >/dev/null 2>&1
return 0
}
# ── Switch to specific server:port ──
switch_endpoint() {
server_idx=$1
port_idx=$2
eval "host=\$SERVER_${server_idx}_HOST"
eval "obf_key=\$SERVER_${server_idx}_KEY"
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
port=$(get_port "$server_idx" "$port_idx")
if [ -z "$host" ] || [ -z "$port" ]; then
log "ERROR: invalid server $server_idx port_idx $port_idx"
return 1
fi
log "SWITCH → server $server_idx ($host:$port)"
stop_tcp
# 1. Switch WG peer key if different server
switch_wg_peer "$wg_key"
# 2. Update obfuscator config
if [ -f "$OBF_CONF" ]; then
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
if [ -n "$obf_key" ]; then
sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
fi
fi
# 3. Restart obfuscator
restart_obf
# 4. Save state (fail_count is managed by the caller, NOT reset here —
# resetting on a port-hop would prevent escalation to server failover)
echo "$server_idx" > "$STATE/current_server"
echo "$port_idx" > "$STATE/current_port_idx"
}
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
stop_tcp() {
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
killall udptcp 2>/dev/null
TCP_MODE=0
echo 0 > "$STATE/tcp_mode"
}
# start_tcp N — tunnel through server N's TCP port; 1 = started
start_tcp() {
n=$1
eval "host=\$SERVER_${n}_HOST"
eval "tport=\$SERVER_${n}_TCP"
eval "obf_key=\$SERVER_${n}_KEY"
eval "wg_key=\$SERVER_${n}_WGKEY"
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
killall udptcp 2>/dev/null
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
switch_wg_peer "$wg_key"
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
restart_obf
TCP_MODE=$n
echo "$n" > "$STATE/tcp_mode"
echo "$n" > "$STATE/current_server"
log "TCP MODE → server $n (${host}:${tport} over TCP)"
return 0
}
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
try_tcp_after() {
i=$(( $1 + 1 ))
while [ "$i" -le "$SERVER_COUNT" ]; do
start_tcp "$i" && return 0
i=$((i + 1))
done
return 1
}
# ── Try next port on current server ──
try_next_port() {
port_count=$(get_port_count "$CURRENT_SERVER")
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
[ "$next_idx" -eq 0 ] && return 1
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
switch_endpoint "$CURRENT_SERVER" "$next_idx"
return 0
}
# ── Try next server ──
try_next_server() {
next=$((CURRENT_SERVER + 1))
if [ "$next" -gt "$SERVER_COUNT" ]; then
next=1
fi
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
log "FAILOVER → server $next"
switch_endpoint "$next" "0"
return 0
}
# ── Check if primary is back ──
check_primary() {
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS))
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
echo "$now" > "$STATE/primary_check_ts"
eval "host=\$SERVER_1_HOST"
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
# obfuscator path is actually up. Anything else (000 no-response, 503
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
ok_url=""
last_code=""
for url in "${PRIMARY_PROBE_SCHEME}://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" "http://${host}:8444${PRIMARY_PROBE_PATH}"; do
code=$(curl -k -s -m 4 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
last_code="$code"
if [ "$code" = "200" ]; then
ok_url="$url"
break
fi
done
if [ -n "$ok_url" ]; then
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
switch_endpoint 1 0
echo "0" > "$STATE/fail_count"
else
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
fi
}
# ── LAN routing self-heal ──────────────────────
# A router REBOOT silently drops the per-device `ip hotspot host <mac> policy`
# binding and can reset the WG interface security-level, so LAN clients leak to
# WAN (no VPN). This:
# 1) ensures WG_IF security-level = public (needed for masquerade),
# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only),
# 3) RE-APPLIES any learned binding that is currently missing.
# Append-only learning means a reboot (which clears live bindings) never erases
# the record, so the next run restores them. ndmc show running-config is heavy,
# so the caller gates this to run only every few minutes.
heal_lan_routing() {
LANHOSTS="$STATE/lan-hosts"
rc=$(ndmc -c "show running-config" 2>/dev/null)
[ -z "$rc" ] && return 0
# 1) WG interface must be public
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
ndmc -c "system configuration save" >/dev/null 2>&1
log "HEAL: $WG_IF security-level -> public"
fi
# 2) learn live bindings (append-only) into lan-hosts
touch "$LANHOSTS"
echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do
grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS"
done
# 3) re-apply any learned binding that is missing live
changed=0
while read -r mac pol; do
[ -z "$mac" ] && continue
case "$mac" in \#*) continue;; esac
# политику удалили — не воскрешаем привязку к ней
echo "$rc" | grep -qx "ip policy $pol" || continue
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
log "HEAL: re-bound host $mac -> $pol"
changed=1
fi
done < "$LANHOSTS"
[ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1
}
# ══════════════════════════════════════════════
# Explicit apply hook (used by phobos-pull.sh after a config change).
# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N
# immediately, skipping the failure-escalation logic. Reuses
# switch_endpoint so the WG-peer/obfuscator/state changes stay identical
# to a normal failover. Resets fail_count so the new server starts clean.
# ══════════════════════════════════════════════
if [ "$1" = "apply-server" ]; then
idx="${2:-1}"
eval "ahost=\$SERVER_${idx}_HOST"
if [ -z "$ahost" ]; then
log "APPLY: server $idx not in conf, ignore"
exit 1
fi
log "APPLY: pull requested server $idx ($ahost)"
switch_endpoint "$idx" "0"
echo "0" > "$STATE/fail_count"
echo "$idx" > "$STATE/current_server"
exit 0
fi
# ══════════════════════════════════════════════
# Pre-check: fix desync (obfuscator targeting wrong server)
# ══════════════════════════════════════════════
if [ -f "$OBF_CONF" ]; then
cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //')
eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST"
if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then
echo "$cur_target" | grep -q "$expected_host" || {
log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync."
switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX"
sleep 5
}
fi
fi
# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ──
HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0)
now_heal=$(date +%s)
if [ $((now_heal - HEAL_TS)) -ge 300 ]; then
echo "$now_heal" > "$STATE/heal_ts"
heal_lan_routing
fi
# ── Peer hygiene: keep exactly ONE WG peer = current server's key ──
# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause
# ambiguous egress routing. Self-heal here every run (cheap when already clean).
eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY"
if [ -n "$cur_wgkey" ]; then
peer_n=$(count_server_peers)
if [ "$peer_n" -gt 1 ]; then
log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}"
switch_wg_peer "$cur_wgkey"
fi
fi
# ══════════════════════════════════════════════
# Main logic
# Tunnel health = WireGuard handshake age (authoritative: a fresh
# handshake only happens through the full obfuscator→server→WG path).
# WAN ping is logged for context ONLY — it does NOT gate decisions,
# because the router's default route is not the tunnel, so WAN can be
# up while the tunnel is dead (and a WAN blip must not cause failover).
# ══════════════════════════════════════════════
AGE=$(get_handshake_age)
WAN=$(check_connectivity && echo "yes" || echo "no")
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
if [ -f "$STATE/trial" ]; then
read t_srv t_port t_tcp t_ts < "$STATE/trial"
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
log "TRIAL ok: primary works"
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
rm -f "$STATE/trial"
log "TRIAL failed: back to server $t_srv"
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
else switch_endpoint "$t_srv" "$t_port"; fi
echo 0 > "$STATE/fail_count"
exit 0
else
exit 0 # give the trial time
fi
fi
# Tunnel healthy = fresh handshake
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count"
fi
echo 0 > "$STATE/udp_cycles"
check_primary
exit 0
fi
# Stale handshake = tunnel down → escalate.
# fail_count is MONOTONIC: it climbs across stages and is only reset by the
# OK branch (real recovery) or after a successful failover to a NEW server
# (so the new server gets a fresh restart→port-hop→failover cycle).
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$STATE/fail_count"
log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT"
# Stage 1 (fail 1): Restart obfuscator
if [ "$FAIL_COUNT" -eq 1 ]; then
log "ACTION: restart obfuscator"
restart_obf
exit 0
fi
# TCP mode went stale → back to the UDP round (next server)
if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
log "TCP stale on server $TCP_MODE"
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
stop_tcp
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
fi
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
if [ "$FAIL_COUNT" -ge 2 ]; then
if try_next_port; then
exit 0
fi
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
fi
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
fi
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
# gets its own restart→port-hop→failover cycle next ticks.
log "ACTION: server failover (fail $FAIL_COUNT)"
if try_next_server; then
echo "0" > "$STATE/fail_count"
else
# Only one server — cycle back to port 0 and restart escalation
echo "0" > "$STATE/fail_count"
switch_endpoint "$CURRENT_SERVER" "0"
fi