From 941a61de359b465064ef6a1ee0ba45a0ed8ac80c Mon Sep 17 00:00:00 2001 From: Karen Khachatryan Date: Sat, 26 Sep 2026 22:41:51 +0300 Subject: [PATCH] Replace Watchcat and normalize direct IPv4 lists - Remove the Watchcat configuration and package dependency - Add generated ping-reboot watchdog settings with boot grace, retry interval, failure threshold, and ping timeout - Document the custom connectivity watchdog behavior and manual check mode - Accept both CIDR prefixes and individual IPv4 addresses from IPinfo Lite - Normalize individual IPv4 addresses to explicit /32 prefixes - Apply direct-list normalization consistently on routers and AWG servers - Document that generated direct lists always use explicit IPv4 CIDR notation --- README.md | 29 ++++- routers/example/files/etc/config/watchcat | 9 -- routers/example/files/etc/init.d/ping-reboot | 16 +++ .../example/files/etc/scripts/ping-reboot.sh | 109 ++++++++++++++++++ .../files/etc/scripts/update-ipsets.sh | 7 +- servers/example/etc/awg-server.sh | 7 +- tools/default.py | 8 +- tools/generate_runtime.py | 5 + tools/validate_runtime_files.py | 5 + 9 files changed, 174 insertions(+), 21 deletions(-) delete mode 100644 routers/example/files/etc/config/watchcat create mode 100755 routers/example/files/etc/init.d/ping-reboot create mode 100755 routers/example/files/etc/scripts/ping-reboot.sh diff --git a/README.md b/README.md index 89e0526..1401a8a 100644 --- a/README.md +++ b/README.md @@ -815,7 +815,7 @@ Direct lists собираются из нескольких источников - публичные `listen_ip` и `exit_ip` из `config.json` - дополнительные CIDR prefixes из `EXIT_DIRECT_STATIC_IPSETS` -Динамическая часть задается странами и ASN. Страны берутся из IPinfo Lite через `Alice39s/ipinfo-csv-lite`: daily release содержит готовые CIDR, country code, continent и ASN. Для `DIRECT_COUNTRIES` выбираются только нужные IPv4 CIDR. ASN lists по-прежнему берутся из `ipverse/as-ip-blocks`. +Динамическая часть задается странами и ASN. Страны берутся из IPinfo Lite через `Alice39s/ipinfo-csv-lite`: daily release содержит готовые CIDR, country code, continent и ASN. Для `DIRECT_COUNTRIES` выбираются нужные IPv4 prefixes; одиночные IPv4 записи источника нормализуются в `/32`. ASN lists по-прежнему берутся из `ipverse/as-ip-blocks`. Итоговый `direct.txt` всегда хранит IPv4 в явной CIDR-нотации. На роутерах и exit серверах эти настройки лежат в runtime env: @@ -824,7 +824,7 @@ DIRECT_COUNTRIES='ru cn by' DIRECT_ASNS='32590' ``` -`update-ipsets.sh` читает `/etc/ipsets/direct-static.txt`, скачивает latest `ipinfo-lite.csv.gz` во временный файл, проверяет gzip и через `grep` + `cut` выбирает готовые IPv4 CIDR для всех `DIRECT_COUNTRIES`, затем добавляет ASN lists. Распакованный CSV в `/tmp` не сохраняется, а gzip удаляется сразу после фильтрации. После этого скрипт атомарно обновляет `/etc/ipsets/direct.txt` и перезагружает firewall только если итоговый список изменился. Python или IPinfo libraries на OpenWrt не нужны. +`update-ipsets.sh` читает `/etc/ipsets/direct-static.txt`, скачивает latest `ipinfo-lite.csv.gz` во временный файл, проверяет gzip и через `grep` + `cut` выбирает IPv4 prefixes для всех `DIRECT_COUNTRIES` (включая одиночные адреса без `/32`), затем добавляет ASN lists. Перед записью `direct.txt` все одиночные IPv4 адреса нормализуются в явный `/32`. Распакованный CSV в `/tmp` не сохраняется, а gzip удаляется сразу после фильтрации. После этого скрипт атомарно обновляет `/etc/ipsets/direct.txt` и перезагружает firewall только если итоговый список изменился. Python или IPinfo libraries на OpenWrt не нужны. Тот же `update-ipsets.sh` запускается один раз локально во время `generate.py` с подмененным `IPSETS_DIR` и `RELOAD_FIREWALL=0`. Поэтому initial `direct-static.txt` и `direct.txt`, которые копируются во все router/server trees, строятся той же runtime реализацией, которая затем работает на OpenWrt. @@ -961,7 +961,6 @@ etc/awg-server.env - init scripts - cron - DoH -- watchcat - network/firewall tails - bootstrap @@ -1045,6 +1044,29 @@ Bootstrap скрипт на OpenWrt при первом запуске обра - для включенных Wi-Fi radio выбирает максимальную поддерживаемую ширину и самый низкий доступный канал - делает `uci commit` +## Connectivity watchdog + +Вместо `watchcat` проект использует простой `/etc/scripts/ping-reboot.sh` с procd-службой `/etc/init.d/ping-reboot`. + +После загрузки роутера watchdog дает системе до 5 минут (`300` секунд uptime) на поднятие сети. Если службу перезапустить позже вручную, новая 5-минутная пауза не начинается: grace считается именно от uptime роутера. + +Параметры watchdog генерируются в `/etc/router-autoinstall.env`: адреса для ping, startup grace, интервал проверки, число неудач и timeout ping. + +После startup grace скрипт раз в 10 секунд проверяет: + +```text +195.208.4.1 +77.88.8.8 +``` + +Достаточно ответа хотя бы от одного адреса. Если три последовательных 10-секундных периода заканчиваются без ответа от обоих адресов, роутер перезагружается. Успешный ping сбрасывает счетчик ошибок. + +Для ручной проверки без reboot loop: + +```sh +/etc/scripts/ping-reboot.sh once +``` + ## DoH и DNS failover Для каждого роутера генератор ведет managed DNS-записи в начале @@ -1419,7 +1441,6 @@ iperf3 jq-full luci luci-app-https-dns-proxy -luci-app-watchcat luci-proto-amneziawg luci-proto-ipip ``` diff --git a/routers/example/files/etc/config/watchcat b/routers/example/files/etc/config/watchcat deleted file mode 100644 index cd6717e..0000000 --- a/routers/example/files/etc/config/watchcat +++ /dev/null @@ -1,9 +0,0 @@ - -config watchcat - option mode 'ping_reboot' - option period '30s' - option pinghosts '195.208.4.1 77.88.8.8' - option addressfamily 'ipv4' - option pingperiod '10s' - option pingsize 'standard' - option forcedelay '10s' diff --git a/routers/example/files/etc/init.d/ping-reboot b/routers/example/files/etc/init.d/ping-reboot new file mode 100755 index 0000000..03ec64a --- /dev/null +++ b/routers/example/files/etc/init.d/ping-reboot @@ -0,0 +1,16 @@ +#!/bin/sh /etc/rc.common + +# shellcheck disable=SC2034 +USE_PROCD=1 +START=99 + +prog_name="ping-reboot.sh" +bin="/etc/scripts/$prog_name" + +start_service() { + procd_open_instance + procd_set_param command "$bin" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} diff --git a/routers/example/files/etc/scripts/ping-reboot.sh b/routers/example/files/etc/scripts/ping-reboot.sh new file mode 100755 index 0000000..58180d7 --- /dev/null +++ b/routers/example/files/etc/scripts/ping-reboot.sh @@ -0,0 +1,109 @@ +#!/bin/sh + +if [ -r /etc/router-autoinstall.env ]; then + # Generated file, loaded once at startup. Restart to apply changes. + # shellcheck disable=SC1091 + . /etc/router-autoinstall.env +fi + +HOSTS="${PING_REBOOT_HOSTS:-}" +BOOT_GRACE="${PING_REBOOT_BOOT_GRACE:-300}" +INTERVAL="${PING_REBOOT_INTERVAL:-10}" +MAX_FAILURES="${PING_REBOOT_MAX_FAILURES:-3}" +TIMEOUT="${PING_REBOOT_TIMEOUT:-2}" + +if [ -z "$HOSTS" ]; then + echo "PING_REBOOT_HOSTS is empty in /etc/router-autoinstall.env" + exit 1 +fi + +router_uptime_seconds() { + awk '{ print int($1) }' /proc/uptime 2>/dev/null +} + +wait_for_boot_grace() { + uptime_s="$(router_uptime_seconds)" + + case "$uptime_s" in + '' | *[!0-9]*) + echo "cannot read router uptime; waiting full ${BOOT_GRACE}s startup grace" + sleep "$BOOT_GRACE" + return 0 + ;; + *) + ;; + esac + + if [ "$uptime_s" -lt "$BOOT_GRACE" ]; then + remaining=$((BOOT_GRACE - uptime_s)) + echo "startup grace: router uptime=${uptime_s}s, waiting ${remaining}s" + sleep "$remaining" + fi +} + +any_host_reachable() { + host="" + + for host in $HOSTS; do + if ping -c 1 -W "$TIMEOUT" "$host" >/dev/null 2>&1; then + return 0 + fi + done + + return 1 +} + +apply_once() { + if any_host_reachable; then + return 0 + fi + + return 1 +} + +case "${1:-run}" in + once) + if apply_once; then + echo "connectivity OK" + exit 0 + fi + + echo "all ping hosts unreachable" + exit 1 + ;; + + run) + wait_for_boot_grace + failures=0 + + echo "started: hosts=[$HOSTS] interval=${INTERVAL}s" + echo "max_failures=$MAX_FAILURES timeout=${TIMEOUT}s" + + while true; do + sleep "$INTERVAL" + + if apply_once; then + if [ "$failures" -gt 0 ]; then + echo "connectivity restored after ${failures} failed check(s)" + fi + failures=0 + continue + fi + + failures=$((failures + 1)) + echo "all ping hosts unreachable: ${failures}/${MAX_FAILURES}" + + if [ "$failures" -ge "$MAX_FAILURES" ]; then + echo "connectivity lost; rebooting router" + sync + reboot + exit 0 + fi + done + ;; + + *) + echo "Usage: $0 [run|once]" >&2 + exit 2 + ;; +esac diff --git a/routers/example/files/etc/scripts/update-ipsets.sh b/routers/example/files/etc/scripts/update-ipsets.sh index 1fe0bea..b147795 100755 --- a/routers/example/files/etc/scripts/update-ipsets.sh +++ b/routers/example/files/etc/scripts/update-ipsets.sh @@ -115,7 +115,7 @@ append_country_lists() { fi gzip -dc "$TMP_IPINFO" \ - | grep -E "^[0-9.]+/[0-9]+,(${country_regex})," \ + | grep -E "^[0-9.]+(/[0-9]+)?,(${country_regex})," \ | cut -d, -f1 >"$TMP_COUNTRIES" if [ ! -s "$TMP_COUNTRIES" ]; then @@ -183,9 +183,10 @@ if [ ! -s "$TMP_DIRECT" ]; then exit 1 fi -LC_ALL=C sort -u "$TMP_DIRECT" >"$TMP_SORTED" || { +sed '/\//! s#$#/32#' "$TMP_DIRECT" \ + | LC_ALL=C sort -u >"$TMP_SORTED" || { rm -f "$TMP_DIRECT" "$TMP_SORTED" "$TMP_IPINFO" "$TMP_COUNTRIES" - logger -t "$TAG" "ERROR: failed to sort direct list" + logger -t "$TAG" "ERROR: failed to normalize/sort direct list" exit 1 } diff --git a/servers/example/etc/awg-server.sh b/servers/example/etc/awg-server.sh index 89fa1b2..a1f8c0a 100755 --- a/servers/example/etc/awg-server.sh +++ b/servers/example/etc/awg-server.sh @@ -404,7 +404,7 @@ append_country_lists() { fi gzip -dc "$TMP_IPINFO" \ - | grep -E "^[0-9.]+/[0-9]+,(${country_regex})," \ + | grep -E "^[0-9.]+(/[0-9]+)?,(${country_regex})," \ | cut -d, -f1 >"$TMP_COUNTRIES" if [ ! -s "$TMP_COUNTRIES" ]; then @@ -459,9 +459,10 @@ build_direct_list() { die "generated direct list is empty" fi - LC_ALL=C sort -u "$TMP_DIRECT" >"$TMP_SORTED" || { + "$SED_BIN" '/\//! s#$#/32#' "$TMP_DIRECT" \ + | LC_ALL=C sort -u >"$TMP_SORTED" || { rm -f "$TMP_DIRECT" "$TMP_SORTED" "$TMP_IPINFO" "$TMP_COUNTRIES" - die "failed to sort $TMP_DIRECT" + die "failed to normalize/sort $TMP_DIRECT" } mv -f "$TMP_SORTED" "$TMP_DIRECT" || { diff --git a/tools/default.py b/tools/default.py index 271b2c7..10fa7dd 100755 --- a/tools/default.py +++ b/tools/default.py @@ -163,7 +163,6 @@ SYNC_COPY_DIRS = { SYNC_COPY_FILES = { Path("files/etc/config/https-dns-proxy"), - Path("files/etc/config/watchcat"), } SYNC_MERGE_FILES = { @@ -355,7 +354,6 @@ ROUTER_REQUIRED_PACKAGES = [ "iperf3", "jq-full", "luci-app-https-dns-proxy", - "luci-app-watchcat", "luci-proto-amneziawg", "luci-proto-ipip", "luci", @@ -420,6 +418,12 @@ CHECK_DOH_RESOLV = "/tmp/resolv.conf.d/resolv.conf.auto" CHECK_DOH_RESOLV_WAIT_MAX = 300 CHECK_DOH_PROVIDER_DOMAINS = ["ru", "xn--p1ai"] +PING_REBOOT_HOSTS = ["195.208.4.1", "77.88.8.8"] +PING_REBOOT_BOOT_GRACE = 300 +PING_REBOOT_INTERVAL = 10 +PING_REBOOT_MAX_FAILURES = 5 +PING_REBOOT_TIMEOUT = 2 + ROUTING_MODE_WAN = "wan" ROUTING_MODE_SPLIT = "split" ROUTING_MODE_EXIT = "exit" diff --git a/tools/generate_runtime.py b/tools/generate_runtime.py index df0a817..c907b22 100755 --- a/tools/generate_runtime.py +++ b/tools/generate_runtime.py @@ -141,6 +141,11 @@ def build_runtime_env(cfg: ConfigData, router_name: str | None = None) -> str: "CHECK_DOH_RESOLV": CHECK_DOH_RESOLV, "CHECK_DOH_RESOLV_WAIT_MAX": str(CHECK_DOH_RESOLV_WAIT_MAX), "CHECK_DOH_PROVIDER_DOMAINS": " ".join(CHECK_DOH_PROVIDER_DOMAINS), + "PING_REBOOT_HOSTS": " ".join(PING_REBOOT_HOSTS), + "PING_REBOOT_BOOT_GRACE": str(PING_REBOOT_BOOT_GRACE), + "PING_REBOOT_INTERVAL": str(PING_REBOOT_INTERVAL), + "PING_REBOOT_MAX_FAILURES": str(PING_REBOOT_MAX_FAILURES), + "PING_REBOOT_TIMEOUT": str(PING_REBOOT_TIMEOUT), "EXIT_ROUTE_TABLE": str(EXIT_POLICY_BASE), "EXIT_ROUTE_INTERVAL": str(EXIT_ROUTE_INTERVAL), "EXIT_ROUTE_TARGETS": " ".join(target_names), diff --git a/tools/validate_runtime_files.py b/tools/validate_runtime_files.py index 6282bc6..3a03ffa 100755 --- a/tools/validate_runtime_files.py +++ b/tools/validate_runtime_files.py @@ -373,6 +373,11 @@ def expected_runtime_env_values( "CHECK_DOH_RESOLV": CHECK_DOH_RESOLV, "CHECK_DOH_RESOLV_WAIT_MAX": str(CHECK_DOH_RESOLV_WAIT_MAX), "CHECK_DOH_PROVIDER_DOMAINS": " ".join(CHECK_DOH_PROVIDER_DOMAINS), + "PING_REBOOT_HOSTS": " ".join(PING_REBOOT_HOSTS), + "PING_REBOOT_BOOT_GRACE": str(PING_REBOOT_BOOT_GRACE), + "PING_REBOOT_INTERVAL": str(PING_REBOOT_INTERVAL), + "PING_REBOOT_MAX_FAILURES": str(PING_REBOOT_MAX_FAILURES), + "PING_REBOOT_TIMEOUT": str(PING_REBOOT_TIMEOUT), "EXIT_ROUTE_TABLE": str(EXIT_POLICY_BASE), "EXIT_ROUTE_INTERVAL": str(EXIT_ROUTE_INTERVAL), "EXIT_ROUTE_TARGETS": " ".join(target_names),