release: 1.3.26 — Watchdog proxy restart protection, WAN monitor debounce, and subscription bootstrap resilience

- Fix #52: Watchdog proxy restart on single URL failure:
  * Validate failure against secondary independent probe URL before declaring proxy down
  * Skip restart when direct WAN is also degraded or down (!direct_ok)
  * Implement 120s cooldown between automated proxy restarts
  * Fix Telegram notification to accurately state 'Перезапуск sing-box' instead of 'Перезапуск роутера'
- Fix #51: Telegram bot spamming WAN up/down notifications:
  * Filter hotplug events strictly by badwan_monitored_interfaces (defaults to wan)
  * Add atomic directory lock with debounce delay and real ubus state query
  * Prevent repeated notifications when interface state has not transitioned
- Feat: 2-VPN deadlock elimination & self-download subscription proxy:
  * Isolate unbootstrapped dependent subscription sections on initial boot (deferred_sections)
  * Allow self-download proxy target for subscriptions with explicit UI hint '(текущее правило при активности)'
  * Enable automatic local proxy fallback for subscription refresh when direct WAN download fails while sing-box is active
  * Add regression test in tests/subscription_bootstrap_deadlock.sh
- Fix: Parental control quota enforcement:
  * Correct route rule filtering when daily quota minutes are exhausted without a fixed time interval
- i18n & Version bump:
  * Bump fe-app-tachyon to 1.3.26 and update Russian PO/POT translations
This commit is contained in:
Dushnilin
2026-09-12 16:50:04 +03:00
parent afd7e270ce
commit 2019ddd12a
25 changed files with 391 additions and 74 deletions
+8 -2
View File
@@ -118,7 +118,10 @@ if grep -q '"dial_detour"' "$output_v14"; then
fail "sing-box 1.14+ must NEVER contain 'dial_detour' in http_clients (causes fatal unknown field crash)"
fi
if command -v sing-box >/dev/null 2>&1; then
sing-box check -c "$output_v14" || fail "sing-box 1.14+ config check failed"
INSTALLED_SB_VER="$(sing-box version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1 || echo '0.0')"
if [ "$(printf '%s\n1.14\n' "$INSTALLED_SB_VER" | sort -V | head -n1)" = "1.14" ]; then
sing-box check -c "$output_v14" || fail "sing-box 1.14+ config check failed"
fi
fi
output_detour_v14="$WORK_DIR/out_detour_v14.json"
@@ -133,7 +136,10 @@ fi
grep -q '"detour": "first_proxy-out"' "$output_detour_v14" || \
fail "sing-box 1.14+ http_clients must configure 'detour' field when download_lists_via_proxy is enabled"
if command -v sing-box >/dev/null 2>&1; then
sing-box check -c "$output_detour_v14" || fail "sing-box 1.14+ detour config check failed"
INSTALLED_SB_VER="$(sing-box version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1 || echo '0.0')"
if [ "$(printf '%s\n1.14\n' "$INSTALLED_SB_VER" | sort -V | head -n1)" = "1.14" ]; then
sing-box check -c "$output_detour_v14" || fail "sing-box 1.14+ detour config check failed"
fi
fi
# 4. Verify zapret section with community_lists does NOT use zapret-out as download_detour
+1 -1
View File
@@ -135,7 +135,7 @@ grep -Fq '"success": true' "$WORK_DIR/result.json" ||
fail "snapshot-restore must report success"
for _ in $(seq 1 30); do
grep -Fxq 'restart' "$WORK_DIR/bin.log" 2>/dev/null && break
sleep 0.1
sleep 1
done
grep -Fxq 'restart' "$WORK_DIR/bin.log" ||
fail "snapshot-restore must restart the service through the backend entrypoint"
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LIB_DIR="$ROOT_DIR/tachyon/files/usr/lib"
VALIDATOR="$LIB_DIR/config/validator.uc"
GENERATOR="$LIB_DIR/singbox/generator.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1"
local actual="$2"
local message="$3"
if [ "$expected" != "$actual" ]; then
printf 'FAIL: %s: expected "%s", got "%s"\n' "$message" "$expected" "$actual" >&2
exit 1
fi
}
# 1. Test validator accepts a section downloading through itself
printf 'Testing validator allows section downloading through itself...\n'
cat >"$WORK_DIR/self_download.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"dns_server": ["77.88.8.8"],
"bootstrap_dns_server": ["77.88.8.8"]
},
"section": [
{
".name": "vpn1",
".type": "section",
"enabled": "1",
"action": "proxy",
"subscription_urls": [ "https://example.com/sub" ],
"subscription_url_settings": "{\"https://example.com/sub\":{\"download_via_proxy_section\":\"vpn1\"}}"
}
]
}
JSON
TACHYON_LIB="$LIB_DIR" ucode -L "$LIB_DIR" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/self_download.json" "{}" >"$WORK_DIR/val.out" 2>"$WORK_DIR/val.err"
printf 'Validator successfully accepted self-download configuration.\n'
# 2. Test connections.uc includes self-target in subscription_download_targets
printf 'Testing connections.uc subscription_download_targets includes self-target...\n'
ucode -L "$LIB_DIR" -e '
let connections = require("config.connections");
let sections = [
{
".name": "vpn1",
".type": "section",
"enabled": "1",
"action": "proxy",
"subscription_urls": [ "https://example.com/sub" ],
"subscription_url_settings": "{\"https://example.com/sub\":{\"download_via_proxy_enabled\":\"1\",\"download_via_proxy_section\":\"vpn1\"}}"
}
];
let targets = connections.subscription_download_targets(sections);
for (let t in targets)
print(t, "\n");
' > "$WORK_DIR/targets.out"
assert_eq "vpn1" "$(cat "$WORK_DIR/targets.out")" "subscription_download_targets should include vpn1"
# 3. Test singbox config generator with deferred section
printf 'Testing singbox config generator skips deferred section...\n'
mkdir -p "$WORK_DIR/runtime"
cat > "$WORK_DIR/fixture.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1",
"dns_server": "1.1.1.1"
},
"sections": [
{
".name": "vpn1",
".type": "section",
"enabled": "1",
"action": "proxy",
"selector_proxy_links": [ "socks5://127.0.0.1:1080#node1" ]
},
{
".name": "vpn2",
".type": "section",
"enabled": "1",
"action": "proxy",
"subscription_urls": [ "https://example.com/sub2" ],
"subscription_url_settings": "{\"https://example.com/sub2\":{\"download_via_proxy_enabled\":\"1\",\"download_via_proxy_section\":\"vpn1\"}}"
}
]
}
JSON
# A: Generating config WITHOUT deferring vpn2 must fail (vpn2 has 0 outbounds)
set +e
mkdir -p "$WORK_DIR/config_fail.json.section-cache" "$WORK_DIR/config_fail.json.rulesets"
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
ucode -L "$LIB_DIR" "$GENERATOR" generate-config-fixture \
"$WORK_DIR/fixture.json" "$WORK_DIR/config_fail.json" "127.0.0.1" "0" "1" "" >"$WORK_DIR/gen_fail.out" 2>"$WORK_DIR/gen_fail.err"
STATUS_FAIL=$?
set -e
if [ "$STATUS_FAIL" -eq 0 ]; then
fail "generator should have failed when vpn2 has no outbounds and is not deferred"
fi
if ! grep -Fq "connection section has no usable outbounds" "$WORK_DIR/gen_fail.err"; then
printf 'gen_fail.err:\n%s\ngen_fail.out:\n%s\n' "$(cat "$WORK_DIR/gen_fail.err")" "$(cat "$WORK_DIR/gen_fail.out")" >&2
fail "expected 'connection section has no usable outbounds' in error output"
fi
# B: Generating config WITH deferred_sections='vpn2' must SUCCEED!
mkdir -p "$WORK_DIR/config_pass.json.section-cache" "$WORK_DIR/config_pass.json.rulesets"
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
ucode -L "$LIB_DIR" "$GENERATOR" generate-config-fixture \
"$WORK_DIR/fixture.json" "$WORK_DIR/config_pass.json" "127.0.0.1" "0" "1" "vpn2" >"$WORK_DIR/gen_pass.out" 2>"$WORK_DIR/gen_pass.err"
if [ ! -s "$WORK_DIR/config_pass.json" ]; then
fail "generator failed to produce config when vpn2 is deferred"
fi
# Verify generated config has outbounds for vpn1 and NOT broken references to vpn2
node - "$WORK_DIR/config_pass.json" <<'NODE'
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const tags = (cfg.outbounds || []).map(o => o.tag);
console.log("outbound tags:", JSON.stringify(tags));
if (!tags.includes('vpn1-out')) {
console.error("Generated config must include vpn1-out outbound");
process.exit(1);
}
if (tags.includes('vpn2-out')) {
console.error("Generated config must NOT include deferred vpn2-out outbound");
process.exit(1);
}
const inbounds = (cfg.inbounds || []).map(i => i.tag);
console.log("inbound tags:", JSON.stringify(inbounds));
if (!inbounds.includes('service-subscription-vpn1-in')) {
console.error("Generated config must include service-subscription-vpn1-in inbound for subscription download");
process.exit(1);
}
console.log("CONFIG_VERIFIED_OK");
NODE
printf 'subscription_bootstrap_deadlock tests passed successfully\n'
+19 -11
View File
@@ -81,10 +81,19 @@ echo "$@" >> "${TEST_LOG_FILE}"
EOF
chmod +x "$BIN_DIR/logger"
# Mock tachyon init.d script
cat > "$BIN_DIR/initd_tachyon" << 'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$BIN_DIR/initd_tachyon"
# Mock tachyon cli (for send_telegram_notification)
cat > "$BIN_DIR/tachyon" << 'EOF'
#!/bin/sh
echo "$@" >> "${TEST_TELEGRAM_LOG}"
if [ "$1" = "telegram" ]; then
echo "$@" >> "${TEST_TELEGRAM_LOG}"
fi
exit 0
EOF
chmod +x "$BIN_DIR/tachyon"
@@ -95,12 +104,14 @@ export TEST_LOG_FILE="$TMP_DIR/logger.log"
export TEST_TELEGRAM_LOG="$TMP_DIR/telegram.log"
mkdir -p "$TEST_UCI_DIR"
# Patch monitor script temporarily to use our isolated /var/run/tachyon dir and fast debounce (0.05s instead of 2s)
# Patch monitor script temporarily to use our isolated /var/run/tachyon dir and fast debounce (sleep 0 instead of 2s)
TEST_MONITOR="$TMP_DIR/monitor_under_test.sh"
# shellcheck disable=SC2016
sed -e "s|/var/run/tachyon|${VAR_RUN}|g" \
-e 's|sleep "\$DELAY_SEC"|sleep 0.05|g' \
-e 's|sleep 2|sleep 0.05|g' \
-e "s|/usr/bin/tachyon|${BIN_DIR}/tachyon|g" \
-e "s|/etc/init.d/tachyon|${BIN_DIR}/initd_tachyon|g" \
-e 's|sleep "\$DELAY_SEC"|sleep 0|g' \
-e 's|sleep 2|sleep 0|g' \
"$MONITOR_SCRIPT" > "$TEST_MONITOR"
chmod +x "$TEST_MONITOR"
@@ -111,14 +122,12 @@ echo "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "down" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 1 failed: notification sent when enable_badwan=0"
# Test 2: Telegram disabled (telegram_enabled = 0)
echo "1" > "$TEST_UCI_DIR/enable_badwan"
echo "0" > "$TEST_UCI_DIR/telegram_enabled"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 2 failed: notification sent when telegram.enabled=0"
# Test 3: Unmonitored interface (wan6 triggered, only wan is monitored)
@@ -127,7 +136,6 @@ echo "1" > "$TEST_UCI_DIR/telegram_enabled"
echo "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "down" > "$TEST_UCI_DIR/ubus_state_wan6"
ACTION="ifdown" INTERFACE="wan6" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 3 failed: notification sent for unmonitored interface wan6"
# Test 4: Monitored interface drops (wan ifdown, initial up -> down transition)
@@ -139,7 +147,7 @@ echo "down" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
# Wait for background verification
sleep 0.3
sleep 1
[ -f "$TEST_TELEGRAM_LOG" ] || fail "Test 4 failed: no notification sent on UP -> DOWN transition"
grep -Fq "Интерфейс *wan* упал!" "$TEST_TELEGRAM_LOG" || fail "Test 4 failed: expected drop message not found in log"
@@ -148,7 +156,7 @@ grep -Fq "Интерфейс *wan* упал!" "$TEST_TELEGRAM_LOG" || fail "Test
# Test 5: Duplicate hotplug ifdown while state is already down (NO repeated spam)
rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
sleep 1
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 5 failed: duplicate notification sent on repeated ifdown without state change"
@@ -157,7 +165,7 @@ rm -f "$TEST_TELEGRAM_LOG"
echo "up" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
sleep 1
[ -f "$TEST_TELEGRAM_LOG" ] || fail "Test 6 failed: no notification sent on DOWN -> UP recovery"
grep -Fq "Интерфейс *wan* поднялся!" "$TEST_TELEGRAM_LOG" || fail "Test 6 failed: expected recovery message not found in log"
@@ -166,7 +174,7 @@ grep -Fq "Интерфейс *wan* поднялся!" "$TEST_TELEGRAM_LOG" || fa
# Test 7: Duplicate hotplug ifup while state is already up (NO duplicate message)
rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.3
sleep 1
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 7 failed: duplicate notification sent on repeated ifup without state change"
+2 -2
View File
@@ -11,8 +11,8 @@ fail() {
# BusyBox wget in OpenWrt 25.12+ no longer supports -t (retries). Tachyon
# handles retries explicitly in a while loop, so the flag is unnecessary and
# breaks downloads on recent OpenWrt releases.
if grep -rn --exclude='*.json' --fixed-strings 'wget' "$ROOT_DIR/tachyon/files" | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >/dev/null 2>&1; then
grep -rn --exclude='*.json' --fixed-strings 'wget' "$ROOT_DIR/tachyon/files" | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >&2
if find "$ROOT_DIR/tachyon/files" -type f ! -name '*.json' -exec grep -Hn -F 'wget' {} + 2>/dev/null | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >/dev/null 2>&1; then
find "$ROOT_DIR/tachyon/files" -type f ! -name '*.json' -exec grep -Hn -F 'wget' {} + | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >&2
fail "wget -t is incompatible with OpenWrt 25.12+ BusyBox; use explicit retry loops instead"
fi