9 Commits

Author SHA1 Message Date
Бобырев Андрей
8dc9fda65b release: 1.4.7 — failover на Keenetic, cron, таймаут pull
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:41:40 +03:00
Бобырев Андрей
8bfc84f315 phobos-health: переключение сервера больше не убивает туннель
Пир добавлялся с зашитым 127.0.0.1:13255, а обфускатор на части роутеров
слушает другой порт (51820) — после любого failover туннель был мёртв.
Порт теперь из wg-obfuscator.conf; пир через ndmc по шагам, allow-ips
0.0.0.0/0 и перезапуск интерфейса (ядро иначе не берёт allow-ips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f934c5b3b0344264586a65b97b2b80b315c2e0c1)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
381e97a564 Keenetic: health/pull по cron реально запускаются
Задачи писались в /opt/etc/crontab, который демоны cron на Keenetic не читают,
а Entware-пакет cron (vixie) задачи не выполнял. Теперь busybox crond
и crontab пользователя root; cron ставится, если нет S10cron.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cbc1bd712f4ac0854d00b18d349d0a9a7d41cc8b)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
eefe3fc2af phobos-pull: таймаут wget 8 с — мёртвый туннель вешал pull навсегда
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c2d367ad527c3b14ba9b5ba421e74ae7a0cb7d33)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
15011c2756 phobos-client.sh: лимит GitHub API не валит сборку пакета 2026-11-01 15:18:23 +03:00
Бобырев Андрей
29790aaf4d release: 1.4.6 — router-config accepts server_api_key as pull token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 04:43:06 +03:00
Бобырев Андрей
884df15f42 release: 1.4.5 — subnet at install, beta/dev from private Gitea repo by token
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:48:28 +03:00
Бобырев Андрей
eb2b2768cd install: take subnet from existing wg0.conf on reinstall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:00:20 +03:00
Бобырев Андрей
147ef3a42d feat: ask VPN client subnet at install (SERVER_WG_IPV4_NETWORK)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:46:49 +03:00
13 changed files with 438 additions and 138 deletions

View File

@@ -19,6 +19,22 @@ phobos-update beta # или: phobos-update dev
---
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
## 1.4.6 — роутеры снова получают смену адреса
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
## 1.4.5 — подсеть при установке, beta/dev по токену
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).

View File

@@ -99,6 +99,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|-------------------|-----------------|----------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
| `PANEL_PORT` | случайный | Порт веб-панели |
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |

View File

@@ -1 +1 @@
1.4.4
1.4.7

5
app.py
View File

@@ -3449,8 +3449,9 @@ def router_config(client_id):
st = load_settings()
token = request.args.get("token", "")
acc = st.get("router_access", {}).get(client_id, {})
expected = acc.get("pull_token", "") or st.get("server_api_key", "")
if not expected or token != expected:
# packages ship server_api_key as pull_token (phobos-client.sh), so accept both
expected = {t for t in (acc.get("pull_token", ""), st.get("server_api_key", "")) if t}
if not token or token not in expected:
return ("forbidden", 403)
conf = generate_failover_conf_for_client(client_id)
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})

View File

@@ -1,28 +1,41 @@
#!/bin/sh
# ============================================================
# Phobos Health Monitor — Auto-heal + Failover for routers
# Runs via cron every 60 seconds on Keenetic/Entware
#
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.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"
CONF="$PHOBOS_DIR/failover.conf"
STATE="$PHOBOS_DIR/state"
LOG="$PHOBOS_DIR/health.log"
WG_CONF="$PHOBOS_DIR/wg0.conf"
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
LOCKFILE="/tmp/phobos-health.lock"
WG_IF="Wireguard3"
MAX_LOG_LINES=200
HANDSHAKE_WARN=180
HANDSHAKE_RESTART_OBF=300
HANDSHAKE_RESTART_WG=600
HANDSHAKE_FAILOVER=900
# 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 "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_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"
# Trim log
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
@@ -38,33 +51,35 @@ fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# Create state dir
mkdir -p "$STATE"
# ── Detect WG interface ──
detect_wg_interface() {
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
}
# ── Read failover config ──
if [ ! -f "$CONF" ]; then
log "ERROR: no failover.conf"
exit 1
fi
# Parse servers: SERVER_1=ip:port1,port2,port3
# Parse current 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")
RESTART_COUNT=$(cat "$STATE/restart_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
# Load server list
SERVER_COUNT=0
idx=1
while true; do
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
if [ -z "$val" ]; then
break
fi
[ -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-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
@@ -74,22 +89,43 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
exit 1
fi
# ── Get current handshake age ──
get_handshake_age() {
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
echo "9999"
return
fi
now=$(date +%s)
age=$((now - hs))
echo "$age"
# 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")
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
detect_wg_interface || {
log "WARN: detect failed, using default $WG_IF"
}
# ── Get port by index from comma-separated list ──
# ── 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
server_idx=$1; port_idx=$2
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
}
@@ -100,6 +136,78 @@ get_port_count() {
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
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
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' "$PHOBOS_DIR/wg-obfuscator.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
@@ -107,6 +215,7 @@ switch_endpoint() {
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
@@ -116,7 +225,10 @@ switch_endpoint() {
log "SWITCH → server $server_idx ($host:$port)"
# Update obfuscator config target
# 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
@@ -124,25 +236,26 @@ switch_endpoint() {
fi
fi
# Restart obfuscator
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
# 3. Restart obfuscator
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
# Save state
# 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"
echo "0" > "$STATE/restart_count"
}
# ── Try next port on current server ──
try_next_port() {
port_count=$(get_port_count "$CURRENT_SERVER")
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
if [ "$next_idx" -eq 0 ]; then
return 1
fi
[ "$next_idx" -eq 0 ] && return 1
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
switch_endpoint "$CURRENT_SERVER" "$next_idx"
@@ -152,96 +265,203 @@ try_next_port() {
# ── Try next server ──
try_next_server() {
next=$((CURRENT_SERVER + 1))
if [ "$next" -gt "$SERVER_COUNT" ]; then
next=1
fi
if [ "$next" -eq "$CURRENT_SERVER" ]; then
log "WARN: only one server, can't failover"
return 1
fi
[ "$next" -gt "$SERVER_COUNT" ] && next=1
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
log "FAILOVER → server $next"
switch_endpoint "$next" "0"
return 0
}
# ── Check if primary is back (every 5 min when on secondary) ──
# ── Check if primary is back ──
check_primary() {
if [ "$CURRENT_SERVER" -eq 1 ]; then
return
fi
[ "$CURRENT_SERVER" -eq 1 ] && return
now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS))
if [ "$elapsed" -lt 300 ]; then
return
fi
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
echo "$now" > "$STATE/primary_check_ts"
eval "host=\$SERVER_1_HOST"
port=$(get_port 1 0)
# Quick UDP probe — send a byte, see if port responds
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
log "PRIMARY alive, switching back"
# 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
log "PRIMARY ($ok_url http=200) alive, switching back"
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
}
# ── Main logic ──
AGE=$(get_handshake_age)
# ── 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
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")
# Tunnel healthy = fresh handshake
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
# All good, reset counters
if [ "$RESTART_COUNT" -gt 0 ]; then
log "OK: connection restored (age=${AGE}s)"
echo "0" > "$STATE/restart_count"
if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count"
fi
check_primary
exit 0
fi
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
# 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"
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
# Restart obfuscator only
RESTART_COUNT=$((RESTART_COUNT + 1))
echo "$RESTART_COUNT" > "$STATE/restart_count"
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
exit 0
fi
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
# Restart WireGuard + obfuscator
RESTART_COUNT=$((RESTART_COUNT + 1))
echo "$RESTART_COUNT" > "$STATE/restart_count"
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
wg-quick down wg0 2>/dev/null
killall wg-obfuscator 2>/dev/null
sleep 2
wg-quick up wg0 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
exit 0
fi
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
# Try next port
log "ACTION: try port hop"
if ! try_next_port; then
log "ACTION: all ports exhausted, try next server"
try_next_server
# Stage 1 (fail 1): Restart obfuscator
if [ "$FAIL_COUNT" -eq 1 ]; then
log "ACTION: restart obfuscator"
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
exit 0
fi
# Full failover
log "ACTION: failover (handshake ${AGE}s)"
if ! try_next_server; then
# Only one server, cycle ports
try_next_port
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
if [ "$FAIL_COUNT" -eq 2 ]; then
if try_next_port; then
exit 0
fi
# only one port → fall through to server failover
log "single port, escalate to failover"
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

View File

@@ -29,7 +29,34 @@ PANEL_SCHEME="${PANEL_SCHEME:-http}"
TG_TOKEN="${TG_TOKEN:-}"
TG_CHAT="${TG_CHAT:-}"
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
import ipaddress, re
for ln in open("/etc/wireguard/wg0.conf"):
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
if m:
print(ipaddress.ip_interface(m.group(1)).network); break
PY
)" || SERVER_WG_IPV4_NETWORK=""
fi
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
fi
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
try:
net = ipaddress.ip_network(sys.argv[1].strip(), strict=False)
except ValueError:
sys.exit("ERROR: bad subnet " + sys.argv[1])
if net.version != 4 or not net.is_private or net.prefixlen > 29:
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
print(net)
PY
)" || exit 1
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"

View File

@@ -456,27 +456,20 @@ deploy_health_monitor() {
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
# Install cron if not present (Entware)
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
log " Установка cron через opkg..."
opkg update >/dev/null 2>&1
opkg install cron >/dev/null 2>&1
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
fi
local cron_file="/opt/etc/crontab"
# Create crontab file if missing
if [ ! -f "$cron_file" ]; then
mkdir -p /opt/etc
touch "$cron_file"
fi
if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then
echo "$cron_line" >> "$cron_file"
log " Cron (health) добавлен в $cron_file"
fi
if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then
echo "$pull_line" >> "$cron_file"
log " Cron (pull) добавлен в $cron_file"
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
local I=/opt/etc/init.d/S10cron
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
$I stop >/dev/null 2>&1
printf '#!/bin/sh\n\nENABLED=yes\nPROCS=crond\nARGS="-c /opt/var/spool/cron/crontabs -b"\nPREARGS=""\nDESC=$PROCS\nPATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n. /opt/etc/init.d/rc.func\n' > $I
chmod +x $I
fi
mkdir -p /opt/var/spool/cron/crontabs
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
log " Cron добавлен через crontab (health + pull)"
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -

View File

@@ -220,7 +220,7 @@ action_package() {
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
# Fetch missing router-arch binaries from ClusterM on-demand
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
for _arch in aarch64 mipsel mips armv7 x86_64; do

View File

@@ -71,7 +71,7 @@ fetch_config() {
if command -v curl >/dev/null; then
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
}
# One fetch + compare + apply pass. Returns 0 always (best-effort).

View File

@@ -189,9 +189,18 @@ switch_wg_peer() {
# Add the target peer if it is not already present
if ! echo "$present" | grep -q "^${new_key}$"; then
log "WG PEER add: $new_key"
curl -s -X POST "http://localhost:79/rci/" \
-H "Content-Type: application/json" \
-d "{\"interface\":{\"${WG_IF}\":{\"wireguard\":{\"peer\":{\"key\":\"${new_key}\",\"comment\":\"Phobos VPS Server\",\"endpoint\":{\"address\":\"127.0.0.1:13255\"},\"keepalive-interval\":{\"interval\":25},\"allow-ips\":[{\"address\":\"0.0.0.0\",\"mask\":\"0.0.0.0\"},{\"address\":\"::\",\"mask\":\"0\"}]}}}}}" >/dev/null 2>&1
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
lport=$(grep 'source-lport' "$PHOBOS_DIR/wg-obfuscator.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

View File

@@ -71,7 +71,7 @@ fetch_config() {
if command -v curl >/dev/null; then
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
}
# One fetch + compare + apply pass. Returns 0 always (best-effort).

View File

@@ -18,7 +18,23 @@ MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
MAIN_PROTO="${MAIN_PROTO:-http}"
MAIN_PORT="${MAIN_PORT:-8443}"
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
fi
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
try:
net = ipaddress.ip_network(sys.argv[1].strip(), strict=False)
except ValueError:
sys.exit("ERROR: bad subnet " + sys.argv[1])
if net.version != 4 or not net.is_private or net.prefixlen > 29:
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
print(net)
PY
)" || exit 1
PHOBOS_DIR="/opt/Phobos"
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys

View File

@@ -42,9 +42,17 @@ restart_panel_service() {
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
is_private() { return 1; }
PCA_GIT_DEV="${PCA_GIT_DEV:-https://git.bobyrev.com/PCA/PCA_Phobos-dev}"
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
local ref="$1" rp="$2" out="$3" kind=branch
case "$ref" in v[0-9]*) kind=tag;; esac
# beta/dev live in the private Gitea repo PCA_Phobos-dev: read-only Gitea token in $PANEL_DIR/.gh_token or GH_TOKEN
case "$ref" in beta|dev)
if [ -n "$GH_TOKEN" ]; then
curl -fsSL -m25 -H "Authorization: token $GH_TOKEN" "$PCA_GIT_DEV/raw/branch/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
fi;;
esac
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
}
@@ -241,6 +249,15 @@ case "${1:-}" in
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
exit 1
fi
case "$ref" in beta|dev)
if [ -z "$GH_TOKEN" ]; then
echo "Каналы beta и dev — по токену подписчика (read-only токен Gitea)."
echo "Вставь токен в панели: Настройки → Версия и обновления → «токен подписчика», или:"
echo " GH_TOKEN=ваш_токен phobos-update $ref"
echo "Стабильная (без токена): phobos-update stable"
exit 1
fi;;
esac
if ! ref_exists "$ref"; then
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"