diff --git a/decisions/2026-06-26-benelux-podkop-recovery-watchdog.md b/decisions/2026-06-26-benelux-podkop-recovery-watchdog.md new file mode 100644 index 0000000..b665c00 --- /dev/null +++ b/decisions/2026-06-26-benelux-podkop-recovery-watchdog.md @@ -0,0 +1,58 @@ +--- +date: 2026-06-26 +type: decision +tags: [benelux, podkop, watchdog, monitoring, self-heal, openwrt, amneziawg, failover] +status: stage1-done +--- + +# Бенелюкс — инструмент восстановления обхода (мониторинг + автолечение) + +## Задача +Олег: «инструмент восстановления обхода блокировок, мониторинг + автоисправление, должно работать на 100%, наверное на внешних ресурсах от Бенелюкса». + +## Калибровка «100%» +Буквальные 100% одним туннелём недостижимы (ISP/нода/NetBird могут лечь — удалённо не починить). Реальная цель: **обход никогда не остаётся сломанным незаметно, чинится сам за 1–3 мин, иначе алерт раньше клиента.** Достигается тремя слоями. + +## Решения Олега (AskUserQuestion) +- Наблюдатель — **дома, LXC** (клон antoshka-watch-self, алерт через бота). +- Автофикс — **до ребута включительно**, с гистерезисом. +- Резерв выхода — **второй AWG-сервер** (не VLESS) → Finland-хаб. + +## Архитектура (3 слоя) +1. **Резерв выхода:** awg0 Singapore (primary) + awg1 Finland (secondary). Этап 2. +2. **Лёгкое самолечение на роутере:** podkop `enable_badwan_interface_monitoring=1` (следит за wan, перезагружает sing-box) — уже было. +3. **Внешний сторож (главный):** `benelux-podkop-watchdog.sh` на LXC 137, cron `*/5`, проверки УДАЛЁННО по SSH через NetBird. + +## Ключевой принцип — анти-flapping +Грабли OpenWrt_4/Оливье: собственный watchdog.sh агрессивно рестартовал sing-box и сам создавал обрывы. Поэтому: лечим только после **2 подряд провалов**, cooldown 5 мин между шагами, лимит **2 ребута/сутки**. + +## Что проверяет сторож (изнутри роутера) +- sing-box жив + Clash API (`192.168.1.1:9090`) отвечает +- handshake текущего выхода < 200с + `ping -I awgN 1.1.1.1` (транзит, ловит rp_filter-ловушку) +- FakeIP: youtube → `198.18.x` (заворачивается) +- **анти-утечка:** ozon.ru НЕ `198.18.x` (страж рецидива `russia_outside`) — только алерт, не автофикс +- достижимость: SSH нет → различает «роутер пингуется, SSH моргнул» vs «Бенелюкс лёг» + +## Лестница лечения (с гистерезисом) +0. `podkop restart` +1. флип `podkop.main.interface` awg0→awg1 (если awg1 поднят и здоров), иначе `ifdown/ifup` + restart +2. `reboot` (лимит 2/сутки) +3. сдаёмся → алерт «нужно руками» +Каждое срабатывание + восстановление (✅) → Telegram Олегу (1292155421) через токен бота из `/root/.openclaw/openclaw.json` (тот же тракт, что antoshka-watch-self). + +## Деплой Этапа 1 (2026-06-26) — СДЕЛАНО +- Ключ LXC137 `root@openclaw` добавлен в `/etc/dropbear/authorized_keys` Бенелюкса. +- `/root/benelux-podkop-watchdog.sh` на LXC 137, cron `*/5`. Исходник в vault `snippets/benelux/benelux-podkop-watchdog.sh`. +- **Боевой тест пройден:** `podkop stop` → прогон1 DEGRADED 1/2 (без действий) → прогон2 2/2 → Шаг1 restart → sing-box поднят, FakeIP вернулся → прогон3 OK + ✅-отбой + сброс счётчика. ⚠️/✅ алерты дошли. +- Грабли при написании: busybox `pgrep -x` не работает (→ `pgrep`); `read < нет_файла` течёт ошибкой (→ guard `[ -f ]`). + +## Этап 2 (TODO) — резервный выход Finland + failover +- Через Amnezia Web Panel (LXC 143 `10.0.0.143:5000`, admin/AmnPanel!2026-fi) нарезать пир Бенелюкса на Finland-хаб `151.241.234.241:41624` (AmneziaWG 2.0, subnet `10.8.1.0/24`). +- Поднять `awg1` на роутере (UCI-референс — HomeLab awg2, [[../projects/dttb/openwrt-router]]); awg1 в firewall WAN-зону; per-iface `rp_filter=2`. +- Failover уже заложен в watchdog (Шаг1 флипает на awg1) — активируется автоматически, как только `network.awg1` появится. +- Закрывает TODO из [[2026-06-23-amnezia-web-panel-lxc143]] «failover (AWG сам не переключается)». + +## Опционально (Этап 3) +- Второй независимый сторож на Finland VPS (на случай падения дома) — взаимный догляд ближе к «100%». + +См. [[../snippets/podkop-reference]], [[../claude-memory/benelux]]. diff --git a/snippets/benelux/benelux-podkop-watchdog.sh b/snippets/benelux/benelux-podkop-watchdog.sh new file mode 100644 index 0000000..dfcb690 --- /dev/null +++ b/snippets/benelux/benelux-podkop-watchdog.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# benelux-podkop-watchdog.sh — внешний сторож обхода РКН на Бенелюксе (Cudy TR3000). +# Живёт на LXC 137 (Антошка), cron */5. Проверки делает УДАЛЁННО по SSH через NetBird, +# лечит лестницей с гистерезисом, алертит Олегу через токен бота (как antoshka-watch-self.sh). +# +# Принцип против flapping (грабли OpenWrt_4/Оливье): лечим только после 2 подряд провалов, +# cooldown между шагами, лимит ребутов в сутки. Тупой рестарт по таймеру — хуже чем ничего. +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# ── конфиг ─────────────────────────────────────────────────────────────────── +ROUTER=100.70.207.97 +SSH="ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=8 -o BatchMode=yes root@$ROUTER" +CFG=/root/.openclaw/openclaw.json # источник токена бота +CHAT=1292155421 # Олег @it5870 +HS_MAX=200 # макс. возраст awg-handshake, сек +PRIMARY=awg0 # основной выход (Singapore) +SECONDARY=awg1 # резервный выход (Finland) — если поднят + +STATE=/root/.benelux-wd.state # счётчик подряд-провалов + шаг лестницы +ALERT=/root/.benelux-wd.alert # md5 последнего алерта (антиспам) +REBOOTS=/root/.benelux-wd.reboots # "YYYY-MM-DD count" — лимит ребутов/сутки +COOLDOWN=/run/benelux-wd.cooldown # ts последнего лечащего действия +COOLDOWN_SEC=300 # не лечить чаще раза в 5 мин +REBOOT_CAP=2 # максимум ребутов в сутки + +TOKEN=$(grep -oE '[0-9]{8,}:[A-Za-z0-9_-]{30,}' "$CFG" 2>/dev/null | head -1) +send(){ [ -n "$TOKEN" ] && curl -s --max-time 20 "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + --data-urlencode "chat_id=$CHAT" --data-urlencode "text=$1" >/dev/null 2>&1; } +NOW=$(date +%s); TODAY=$(date +%F) + +issues=""; heals="" + +# ── 0. достижимость роутера ─────────────────────────────────────────────────── +PROBE=$($SSH ' + echo "SINGBOX=$(pgrep sing-box >/dev/null && echo 1 || echo 0)" + echo "IFACE=$(uci -q get podkop.main.interface)" + echo "HS0=$(awg show '"$PRIMARY"' latest-handshakes 2>/dev/null | awk "{print \$2}" | sort -rn | head -1)" + echo "HS1=$(awg show '"$SECONDARY"' latest-handshakes 2>/dev/null | awk "{print \$2}" | sort -rn | head -1)" + echo "AWG1=$(uci -q get network.'"$SECONDARY"' >/dev/null 2>&1 && echo 1 || echo 0)" + echo "PING0=$(ping -I '"$PRIMARY"' -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo 1 || echo 0)" + echo "PING1=$(ping -I '"$SECONDARY"' -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo 1 || echo 0)" + echo "CLASH=$(wget -qO- http://192.168.1.1:9090/version 2>/dev/null | grep -q sing-box && echo 1 || echo 0)" + echo "YT=$(nslookup youtube.com 127.0.0.42 2>/dev/null | grep -c "198.1[89].")" + echo "OZ=$(nslookup ozon.ru 127.0.0.42 2>/dev/null | grep -c "198.1[89].")" +' 2>/dev/null) + +if [ -z "$PROBE" ]; then + # SSH не прошёл — отличаем «роутер лёг» от «SSH/NetBird моргнул» + if ping -c2 -W2 "$ROUTER" >/dev/null 2>&1; then + issues="• Роутер пингуется, но SSH не отвечает (NetBird/dropbear моргнул).\n" + else + issues="• Бенелюкс НЕДОСТУПЕН — не пингуется по NetBird (роутер/WAN/туннель лёг).\n" + fi + # удалённо чинить нечем → только алерт (антиспам ниже) + h=$(printf '%s' "$issues" | md5sum | cut -d' ' -f1) + echo "UNREACHABLE"; printf '%b' "$issues" + [ "$(cat "$ALERT" 2>/dev/null)" = "$h" ] && exit 0 + echo "$h" > "$ALERT"; send "$(printf '⚠️ Бенелюкс-обход:\n%b' "$issues")" + exit 0 +fi + +eval "$PROBE" # SINGBOX IFACE HS0 HS1 AWG1 PING0 PING1 CLASH YT OZ +AGE0=999999; [ -n "$HS0" ] && AGE0=$((NOW - HS0)) +AGE1=999999; [ -n "$HS1" ] && AGE1=$((NOW - HS1)) + +# текущий выход здоров? (handshake свежий + транзит идёт) +cur_ok(){ + case "$IFACE" in + "$PRIMARY") [ "$AGE0" -lt "$HS_MAX" ] && [ "$PING0" = 1 ] ;; + "$SECONDARY") [ "$AGE1" -lt "$HS_MAX" ] && [ "$PING1" = 1 ] ;; + *) return 1 ;; + esac +} + +HEALTHY=1 +[ "$SINGBOX" = 1 ] || { HEALTHY=0; issues="${issues}• sing-box не запущен.\n"; } +[ "$CLASH" = 1 ] || { HEALTHY=0; issues="${issues}• Clash API ($IFACE) не отвечает.\n"; } +cur_ok || { HEALTHY=0; issues="${issues}• Выход $IFACE мёртв (handshake ${AGE0}/${AGE1}s, ping0=$PING0 ping1=$PING1).\n"; } +[ "${YT:-0}" -ge 1 ] || { HEALTHY=0; issues="${issues}• FakeIP не работает (youtube не заворачивается).\n"; } + +# анти-утечка: РФ-сайт НЕ должен фейкапиться (рецидив russia_outside) — алерт, но НЕ автофикс +LEAK="" +[ "${OZ:-0}" -ge 1 ] && LEAK="• ⚠️ Утечка: ozon.ru уходит в туннель (вернулся russia_outside?). Проверь списки podkop.\n" + +# ── здоров ──────────────────────────────────────────────────────────────────── +if [ "$HEALTHY" = 1 ] && [ -z "$LEAK" ]; then + echo "OK: Бенелюкс-обход здоров (выход $IFACE, hs ${AGE0}s)." + rm -f "$STATE" + if [ -f "$ALERT" ]; then send "✅ Бенелюкс-обход снова в норме (выход $IFACE)."; rm -f "$ALERT"; fi + exit 0 +fi + +# ── гистерезис: считаем подряд-провалы ──────────────────────────────────────── +FAILS=0; STEP=0; [ -f "$STATE" ] && read -r FAILS STEP < "$STATE"; FAILS=${FAILS:-0}; STEP=${STEP:-0} +FAILS=$((FAILS + 1)) + +# утечка списков — только сигнал, не повод лечить сервис +if [ -n "$LEAK" ] && [ "$HEALTHY" = 1 ]; then + echo "$FAILS $STEP" > "$STATE" + h=$(printf '%s' "$LEAK" | md5sum | cut -d' ' -f1) + [ "$(cat "$ALERT" 2>/dev/null)" = "$h" ] && exit 0 + echo "$h" > "$ALERT"; send "$(printf '⚠️ Бенелюкс-обход:\n%b' "$LEAK")" + exit 0 +fi + +if [ "$FAILS" -lt 2 ]; then + echo "$FAILS $STEP" > "$STATE" + echo "DEGRADED (провал $FAILS/2, жду подтверждения перед лечением):"; printf '%b' "$issues" + exit 0 +fi + +# cooldown между лечащими действиями +if [ -f "$COOLDOWN" ] && [ $((NOW - $(cat "$COOLDOWN"))) -lt "$COOLDOWN_SEC" ]; then + echo "cooldown активен — жду"; echo "$FAILS $STEP" > "$STATE"; exit 0 +fi + +# ── лестница лечения ────────────────────────────────────────────────────────── +do_heal(){ echo "$NOW" > "$COOLDOWN"; } + +case "$STEP" in + 0) # мягкий рестарт podkop + $SSH '/etc/init.d/podkop restart' >/dev/null 2>&1 + heals="🔧 Шаг1: перезапустил podkop.\n"; STEP=1; do_heal ;; + 1) # переподнять интерфейс / переключить на резерв + if [ "$AWG1" = 1 ] && [ "$IFACE" = "$PRIMARY" ] && [ "$AGE1" -lt "$HS_MAX" ] && [ "$PING1" = 1 ]; then + $SSH "uci set podkop.main.interface=$SECONDARY; uci commit podkop; /etc/init.d/podkop restart" >/dev/null 2>&1 + heals="🔧 Шаг2: основной выход ($PRIMARY/Singapore) мёртв → переключил на резерв $SECONDARY (Finland).\n" + else + $SSH "ifdown $IFACE; sleep 2; ifup $IFACE; sleep 3; /etc/init.d/podkop restart" >/dev/null 2>&1 + heals="🔧 Шаг2: переподнял интерфейс $IFACE + рестарт podkop.\n" + fi + STEP=2; do_heal ;; + 2) # ребут (с лимитом в сутки) + RDAY=""; RCNT=0; [ -f "$REBOOTS" ] && read -r RDAY RCNT < "$REBOOTS" + [ "$RDAY" != "$TODAY" ] && { RDAY=$TODAY; RCNT=0; } + if [ "${RCNT:-0}" -lt "$REBOOT_CAP" ]; then + $SSH 'reboot' >/dev/null 2>&1 + echo "$TODAY $((RCNT + 1))" > "$REBOOTS" + heals="🔧 Шаг3: рестарты не помогли — перезагружаю роутер (ребут $((RCNT+1))/$REBOOT_CAP сегодня).\n" + STEP=3; do_heal + else + issues="${issues}• Лимит ребутов на сегодня исчерпан ($REBOOT_CAP).\n" + STEP=3 + fi ;; + *) # сдаёмся — нужно руками + issues="${issues}• Автолечение не помогло (пройдены все шаги). Нужно вмешательство.\n" ;; +esac + +echo "$FAILS $STEP" > "$STATE" + +# ── алерт (антиспам по содержимому) ─────────────────────────────────────────── +echo "ISSUES:"; printf '%b%b' "$issues" "$heals" +h=$(printf '%s%s' "$issues" "$heals" | md5sum | cut -d' ' -f1) +[ "$(cat "$ALERT" 2>/dev/null)" = "$h" ] && exit 0 +echo "$h" > "$ALERT" +send "$(printf '⚠️ Бенелюкс-обход — сбой:\n%b\n%b' "$issues" "$heals")"