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
This commit is contained in:
Karen Khachatryan 2026-09-26 22:41:51 +03:00
parent 87bf78b12c
commit 941a61de35
9 changed files with 174 additions and 21 deletions

View file

@ -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
```

View file

@ -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'

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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" || {

View file

@ -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"

View file

@ -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),

View file

@ -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),