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
+3
View File
@@ -1586,6 +1586,9 @@ msgstr "Скачивать эту подписку без проверки се
msgid "Download through"
msgstr "Загружать через"
msgid "(this rule, when active)"
msgstr "(эта же секция, при активном VPN)"
msgid "Downloaded"
msgstr "Загружено"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.3.25",
"version": "1.3.26",
"license": "MIT",
"type": "module",
"scripts": {
@@ -780,7 +780,7 @@ function createParentalContent(section) {
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "domains";
if (target === "all") {
return (
'<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' +
@@ -788,11 +788,20 @@ function createParentalContent(section) {
"</span>"
);
}
if (target === "domains") {
const rawDomains = normalizeListValues(
uci.get(UCI_PACKAGE, sectionId, "blocked_domains"),
);
const count = rawDomains.length;
return (
'<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.06));padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid var(--border-color-low, rgba(0,0,0,0.12));display:inline-flex;align-items:center;margin-bottom:2px;">🌐 ' +
_("Blocked Sites") +
(count > 0 ? " (" + count + ")" : "") +
"</span>"
);
}
const rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections");
const secNames = normalizeListValues(rawSecs);
if (secNames.length === 0 && target !== "sections") {
secNames.push(target);
}
if (secNames.length === 0) {
return (
'<span style="opacity:0.6;">' + _("No sections selected") + "</span>"
@@ -814,8 +823,17 @@ function createParentalContent(section) {
);
};
o.textvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "domains";
if (target === "all") return _("All Internet");
if (target === "domains") {
const rawDomains = normalizeListValues(
uci.get(UCI_PACKAGE, sectionId, "blocked_domains"),
);
return (
_("Blocked Sites") +
(rawDomains.length > 0 ? " (" + rawDomains.length + ")" : "")
);
}
const secNames = normalizeListValues(
uci.get(UCI_PACKAGE, sectionId, "sections"),
);
@@ -1081,6 +1099,7 @@ function createParentalContent(section) {
),
);
o.modalonly = true;
o.rmempty = false;
o.default = "domains";
o.value("domains", _("Blocked Sites (Only block domains specified below)"));
o.value("all", _("All Internet (Complete internet cutoff)"));
@@ -1222,6 +1241,7 @@ function createParentalContent(section) {
),
);
o.modalonly = true;
o.depends("target", "domains");
o.rmempty = true;
o.rows = 6;
o.wrap = "soft";
@@ -2083,7 +2083,6 @@ function isDownloadThroughTargetSection(section, currentSectionId) {
if (
!sectionName ||
sectionName === currentSectionId ||
section.enabled === "0"
) {
return false;
@@ -2134,10 +2133,17 @@ function isDownloadThroughTargetSection(section, currentSectionId) {
function subscriptionDownloadTargetChoices(section_id) {
return (uci.sections(UCI_PACKAGE, "section") || [])
.filter((section) => isDownloadThroughTargetSection(section, section_id))
.map((section) => ({
value: getUciSectionName(section),
label: getUciSectionLabel(section),
}));
.map((section) => {
const name = getUciSectionName(section);
let label = getUciSectionLabel(section);
if (name === section_id) {
label += " " + _("(this rule, when active)");
}
return {
value: name,
label: label,
};
});
}
function dnsTypeChoices() {
@@ -2745,9 +2751,6 @@ function addSubscriptionUrlItemOptions(itemSection, options = {}) {
if (!value) {
return _("Select a section for downloading this subscription");
}
if (value === sectionId) {
return _("Current section cannot download its own subscription");
}
return true;
};
+3
View File
@@ -1586,6 +1586,9 @@ msgstr "Скачивать эту подписку без проверки се
msgid "Download through"
msgstr "Загружать через"
msgid "(this rule, when active)"
msgstr "(эта же секция, при активном VPN)"
msgid "Downloaded"
msgstr "Загружено"
@@ -86,8 +86,8 @@ done
PREV_STATE=""
[ -f "$STATE_FILE" ] && PREV_STATE="$(cat "$STATE_FILE" 2>/dev/null)"
TACHYON_BIN="/usr/bin/tachyon"
[ -x "$TACHYON_BIN" ] || TACHYON_BIN="$(command -v tachyon 2>/dev/null || echo /usr/bin/tachyon)"
TACHYON_BIN="$(command -v tachyon 2>/dev/null || echo /usr/bin/tachyon)"
[ -x "$TACHYON_BIN" ] || TACHYON_BIN="/usr/bin/tachyon"
if [ "$CURRENT_STATE" = "up" ]; then
if [ "$PREV_STATE" = "down" ]; then
+1
View File
@@ -234,6 +234,7 @@ function command_spec(command) {
get_tailscale_peers: [ "diagnostics/runtime.uc", "get-tailscale-peers", 0 ],
tailscale_restart: [ "providers/tailscale/runtime.uc", "start-runtime", 0 ],
parental_quota_tick: [ "service/parental_quota.uc", "tick", 0 ],
parental_quota_reset: [ "service/parental_quota.uc", "reset", 0 ],
failover_check: [ "service/failover.uc", "check", 0 ],
get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ],
get_server_capabilities: [ "diagnostics/runtime.uc", "get-server-capabilities", 0 ],
+1 -1
View File
@@ -1303,7 +1303,7 @@ function subscription_download_targets(sections) {
for (let source in subscription_urls(section)) {
let target = subscription_download_section(section, source);
if (target != "" && target != name)
if (target != "")
append_unique(result, seen, target);
}
}
@@ -1710,9 +1710,6 @@ function validate_subscription_download_sections(sections, context) {
if (target == "")
continue;
if (target == name)
fail_validation("Subscription source in rule '" + name + "' cannot be downloaded through the same rule. Select another rule or disable download-through-section. Aborted.");
validate_download_section_rows(
target,
context.byedpi_installed,
+15 -2
View File
@@ -1033,7 +1033,7 @@ function nft_add_schedule_rules_from_schedules(schedules, sections, table, profi
let always_on = length(intervals) == 0;
let target_sec_names = [];
if (target == "sections" || (target != "all" && target != "")) {
if (target == "sections" || (target != "all" && target != "domains" && target != "")) {
let raw_secs = list_option(schedule, "sections");
if (length(raw_secs) == 0) {
let single_sec = option(schedule, "sections", "");
@@ -1055,8 +1055,12 @@ function nft_add_schedule_rules_from_schedules(schedules, sections, table, profi
[ "meta", "hour", sprintf("\"%s\"-\"%s\"", interval[0], interval[1]) ] : null;
let base_match = nft_schedule_rule_base_match(dev_str, is_mac, family, days_args, time_args);
let quota_minutes = int(option(schedule, "daily_quota_minutes", 0));
let has_sched_domains = length(list_option(schedule, "blocked_domains")) > 0 || option(schedule, "blocked_domains", "") != "";
if ((target == "all" || (length(target_sec_names) == 0 && target != "sections" && target != "domains")) && !has_sched_domains && target != "domains") {
if (always_on && quota_minutes > 0)
continue;
let fwd_rule = [];
append_array(fwd_rule, base_match);
append_array(fwd_rule, [ "counter", verdict ]);
@@ -1159,6 +1163,9 @@ function nft_add_dns_block_rules_from_schedules(schedules, table, profiles) {
let intervals = nft_schedule_time_intervals(start_time, end_time);
let days_args = nft_schedule_days_match_args(schedule);
let always_on = length(intervals) == 0;
let quota_minutes = int(option(schedule, "daily_quota_minutes", 0));
if (always_on && quota_minutes > 0)
continue;
for (let raw_ip in raw_ips) {
let dev_str = trim(as_string(raw_ip));
@@ -1473,6 +1480,8 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
!nft_create_ipv6_port_set(table, ip_port6_set) ||
!nft_create_ipv4_set(table, DNS_SOURCE_SET) ||
!nft_create_ipv6_set(table, DNS_SOURCE6_SET) ||
!nft_create_ether_set(table, "tachyon_quota_block") ||
!nft_create_ipv4_set(table, "tachyon_quota_block_ip") ||
!nft_create_ifname_set(table, interface_set))
return false;
@@ -1494,7 +1503,11 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
if (!nft_add_rule(table, "parental_control", [ "ip", "daddr", "@" + as_string(localv4_set), "return" ]) ||
!nft_add_rule(table, "parental_control", [ "ip6", "daddr", "@" + as_string(localv6_set), "return" ]) ||
!nft_add_rule(table, "parental_forward", [ "ip", "daddr", "@" + as_string(localv4_set), "return" ]) ||
!nft_add_rule(table, "parental_forward", [ "ip6", "daddr", "@" + as_string(localv6_set), "return" ]))
!nft_add_rule(table, "parental_forward", [ "ip6", "daddr", "@" + as_string(localv6_set), "return" ]) ||
!nft_add_rule(table, "parental_control", [ "ether", "saddr", "@tachyon_quota_block", "counter", "drop", "comment", "\"tachyon-quota-ether\"" ]) ||
!nft_add_rule(table, "parental_control", [ "ip", "saddr", "@tachyon_quota_block_ip", "counter", "drop", "comment", "\"tachyon-quota-ip\"" ]) ||
!nft_add_rule(table, "parental_forward", [ "ether", "saddr", "@tachyon_quota_block", "counter", "drop", "comment", "\"tachyon-quota-ether\"" ]) ||
!nft_add_rule(table, "parental_forward", [ "ip", "saddr", "@tachyon_quota_block_ip", "counter", "drop", "comment", "\"tachyon-quota-ip\"" ]))
return false;
if (!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip", "saddr", "@" + DNS_SOURCE_SET, "tcp", "dport", "53", "counter", "redirect", "to", ":" + as_string(runtime_constants.SOURCE_DNS_INBOUND_PORT) ]) ||
File diff suppressed because one or more lines are too long
@@ -559,11 +559,26 @@ function controller(bus, opts) {
let started = time();
let ok = command_success_from_args([
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"curl", "-s", "-o", "/dev/null",
"--connect-timeout", "3", "--max-time", "5",
"--proxy", "http://" + host + ":" + port,
check_url
]);
// Fallback confirmation: never declare proxy down based on a single URL.
// Cloudflare or specific CDN endpoints can encounter rate limits or transient DPI packet loss.
let fallback_url = null;
if (!ok) {
fallback_url = index(check_url, "gstatic.com") >= 0
? "https://detectportal.firefox.com/success.txt"
: "https://connectivitycheck.gstatic.com/generate_204";
ok = command_success_from_args([
"curl", "-s", "-o", "/dev/null",
"--connect-timeout", "3", "--max-time", "5",
"--proxy", "http://" + host + ":" + port,
fallback_url
]);
}
let elapsed = (time() - started) * 1000;
state.proxy_latency_history = push_history(state.proxy_latency_history,
@@ -579,13 +594,18 @@ function controller(bus, opts) {
// Distinguish "proxy is broken" from "the whole uplink is down": only
// the former is worth restarting sing-box over.
// Use the same URL as the proxy check so the two measurements are
// comparable: if the user changed ai_proxy_health_url, direct_ok
// must probe the same target, not always fall back to Cloudflare.
// Check primary URL, and if that fails, check fallback URL directly too
// in case the primary URL is blocked directly on the ISP WAN.
let direct_ok = command_success_from_args([
"curl", "-s", "-I", "--connect-timeout", "3", "--max-time", "5",
check_url
]);
if (!direct_ok && fallback_url != null) {
direct_ok = command_success_from_args([
"curl", "-s", "-I", "--connect-timeout", "3", "--max-time", "5",
fallback_url
]);
}
bus.emit(EV.PROXY_DOWN, {
host: host,
+5 -1
View File
@@ -698,7 +698,9 @@ function singbox_init_config() {
subscription_runtime_no_refresh
]);
if (result.status == 0) {
subscription_deferred_sections = trim(result.output);
let out = trim(result.output);
if (out != "")
subscription_deferred_sections = out;
subscription_caches_prepared = "1";
}
return result.status;
@@ -1119,6 +1121,8 @@ function start() {
return status;
}
module_status(STATE_UC, [ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]);
return 0;
}
+23 -12
View File
@@ -256,9 +256,9 @@ function send_notification(message) {
system(common.background_command(TACHYON_BIN + " telegram send " + shell_quote(message)));
}
function nft_chain_has_quota(chain) {
function nft_chain_has_quota(chain, match_type) {
let output = command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]) || "";
return index(output, "tachyon-quota") >= 0;
return index(output, "tachyon-quota-" + match_type) >= 0;
}
function nft_table_exists() {
@@ -282,7 +282,7 @@ function nft_create_set(set_name, set_type) {
function nft_insert_quota_rule(chain, match_type) {
let set_name = match_type == "ether" ? SET_ETHER : SET_IP;
let addr_type = match_type == "ether" ? "ether" : "ip";
let cmd = "nft add rule inet " + NFT_TABLE_NAME + " " + chain + " " + addr_type + " saddr @" + set_name + " counter drop comment \"tachyon-quota\"";
let cmd = "nft add rule inet " + NFT_TABLE_NAME + " " + chain + " " + addr_type + " saddr @" + set_name + " counter drop comment \"tachyon-quota-" + match_type + "\"";
let rc = command_status(cmd + " 2>/dev/null");
if (rc != 0)
log_message("failed to insert quota rule in " + chain + " for " + match_type + ": " + as_string(rc), "warn");
@@ -302,23 +302,24 @@ function ensure_nft_rules() {
nft_create_set(SET_IP, "ipv4_addr");
// Insert drop rules into parental chains (idempotent via comment check)
if (!nft_chain_has_quota("parental_control"))
if (!nft_chain_has_quota("parental_control", "ether"))
nft_insert_quota_rule("parental_control", "ether");
if (!nft_chain_has_quota("parental_control"))
if (!nft_chain_has_quota("parental_control", "ip"))
nft_insert_quota_rule("parental_control", "ip");
if (!nft_chain_has_quota("parental_forward"))
if (!nft_chain_has_quota("parental_forward", "ether"))
nft_insert_quota_rule("parental_forward", "ether");
if (!nft_chain_has_quota("parental_forward"))
if (!nft_chain_has_quota("parental_forward", "ip"))
nft_insert_quota_rule("parental_forward", "ip");
return true;
}
function sync_block_set(set_name, idents) {
command_status(command_from_args([ "nft", "flush", "set", "inet", NFT_TABLE_NAME, set_name ]) + " >/dev/null 2>&1");
command_status("nft flush set inet " + NFT_TABLE_NAME + " " + set_name + " >/dev/null 2>&1");
if (length(idents) > 0) {
let cmd = command_from_args([ "nft", "add", "element", "inet", NFT_TABLE_NAME, set_name, "{ " + join(", ", idents) + " }" ]);
if (!command_success_from_args(cmd))
log_message("failed to sync nft set " + set_name + " with " + as_string(length(idents)) + " elements", "warn");
let cmd = "nft add element inet " + NFT_TABLE_NAME + " " + set_name + " { " + join(", ", idents) + " }";
let rc = command_status(cmd + " 2>/dev/null");
if (rc != 0)
log_message("failed to sync nft set " + set_name + " with " + as_string(length(idents)) + " elements: " + as_string(rc), "warn");
}
}
@@ -550,17 +551,27 @@ function status_json() {
});
}
function reset_quotas() {
let today = today_str();
write_state({ day: today, devices: {}, guest_devices: {} });
sync_enforcement([], []);
log_message("quotas have been manually reset", "info");
return 0;
}
let mode = ARGV[0] || "";
if (mode == "tick")
exit(tick());
else if (mode == "status")
status_json();
else if (mode == "reset")
exit(reset_quotas());
else if (mode == "install-cron")
exit(install_cron());
else if (mode == "remove-cron")
exit(remove_cron());
else {
warn("Usage: service/parental_quota.uc <tick|status|install-cron|remove-cron>\n");
warn("Usage: service/parental_quota.uc <tick|status|reset|install-cron|remove-cron>\n");
exit(1);
}
+13 -3
View File
@@ -855,6 +855,10 @@ function heal_dns_stall(ev) {
// would drop live connections for nothing.
function heal_proxy_connectivity(ev) {
if (settings().recovery_bypass == "1") return;
// When dedicated proxy health monitoring is enabled, heal_proxy_health owns
// the threshold, config validation, and reporting. heal_proxy_connectivity
// acts as fallback when ai_proxy_health_enabled is "0".
if (settings().ai_proxy_health_enabled != "0") return;
if (!ev.payload.direct_ok) return;
// Two consecutive failures before acting: a single brief proxy-node timeout
// (e.g. URLTest selecting a slow node for 5–10s) must not wipe cache.db and
@@ -877,6 +881,7 @@ function heal_proxy_connectivity(ev) {
"Очищена база cache.db; перезапуск пропущен по лимиту частоты", "skipped");
return;
}
controller.reset_proxy_consecutive();
watch_recovery("proxy", incident, "proxy_connectivity");
}
@@ -1026,6 +1031,11 @@ function validate_singbox_config() {
// of failures and validates the sing-box config before restarting.
function heal_proxy_health(ev) {
if (settings().ai_proxy_health_enabled == "0") return;
if (settings().recovery_bypass == "1") return;
// Only a proxy that fails while direct access works is worth restarting
// over: when direct access is down too, the fault is upstream/WAN and a
// restart would drop live connections for nothing.
if (!ev.payload.direct_ok) return;
let threshold = int(settings().ai_proxy_health_fail_threshold || "3");
let fails = int(ev.payload.streak);
@@ -1034,8 +1044,8 @@ function heal_proxy_health(ev) {
let incident = {
type: "proxy_health",
description: sprintf("Proxy health check failed %d times consecutively (port %s)", fails, as_string(ev.payload.port)),
resolution: "Restarting Tachyon to restore proxy connectivity"
description: sprintf("Прокси не отвечает на запросы проверки доступности (%d сбоя подряд, порт %s)", fails, as_string(ev.payload.port)),
resolution: "Выполнен перезапуск sing-box"
};
// A restart on a config sing-box will refuse to load leaves the proxy down
@@ -1761,7 +1771,7 @@ function register_subscribers() {
subscribe(EV.PROXY_DOWN, heal_proxy_connectivity,
{ name: "heal_proxy_connectivity", priority: PRIORITY_PROXY, cooldown: 120 });
subscribe(EV.PROXY_DOWN, heal_proxy_health,
{ name: "heal_proxy_health", priority: PRIORITY_PROXY + 1 });
{ name: "heal_proxy_health", priority: PRIORITY_PROXY + 1, cooldown: 120 });
// Same split for DNS: ai_heal_dns_continuous was fast, ai_heal_dns was part
// of the normal-tier audit. The streak the stall healer thresholds on is
+34 -6
View File
@@ -22,6 +22,8 @@ let fixture_uci_data = null;
let runtime_settings_cache = null;
let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER;
let runtime_supports_xhttp = true;
const TACHYON_RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
const TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE = getenv("TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE") || TACHYON_RUNTIME_STATE_DIR + "/deferred-sections";
let as_string = common.as_string;
let read_json_file = common.read_json_file;
@@ -49,6 +51,22 @@ let url_query_params = runtime_url.query_params;
const CONFIG_NAME = "tachyon";
function parse_deferred_sections(deferred_sections) {
let raw = trim(as_string(deferred_sections || getenv("TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS") || ""));
if (raw == "" && fs.stat(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE) != null) {
raw = trim(as_string(fs.readfile(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE)) || "");
}
let res = {};
if (raw != "") {
for (let s in split(raw, /[ \t\r\n]+/)) {
s = trim(s);
if (s != "")
res[s] = true;
}
}
return res;
}
// UCI stores values written as '<b 0x...>' strings as binary, which when
// serialized back to JSON appear as '<b 0x...>' — invalid for sing-box.
function uci_bin_to_hex(val) {
@@ -626,6 +644,8 @@ function mixed_proxy_enabled_action(action) {
function add_mixed_proxy_for_section(config, section, service_address) {
if (!bool_option(section, "mixed_proxy_enabled", false))
return;
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
return;
let action = option(section, "action", "");
if (!mixed_proxy_enabled_action(action))
@@ -744,6 +764,8 @@ function add_global_download_service_mixed_proxy(config, settings, purpose) {
function add_subscription_download_service_mixed_proxies(config, sections) {
for (let target in connections.subscription_download_targets(sections)) {
if (ctx.deferred_sections && ctx.deferred_sections[target])
continue;
let port = connections.subscription_download_target_port(sections, target, runtime_constants.SERVICE_MIXED_INBOUND_PORT);
if (port <= 0)
runtime_generate_unsupported("subscription download proxy port could not be resolved");
@@ -924,8 +946,13 @@ function schedule_blocked_domains(schedule) {
function enabled_content_block_schedules() {
let result = [];
ctx.uci_cursor().foreach(CONFIG_NAME, "schedule", function(schedule) {
if (section_enabled(schedule) && length(schedule_blocked_domains(schedule)) > 0)
let quota_minutes = int(option(schedule, "daily_quota_minutes", 0));
let has_interval = trim(option(schedule, "start_time", "")) != "" || trim(option(schedule, "end_time", "")) != "";
if (section_enabled(schedule) && length(schedule_blocked_domains(schedule)) > 0) {
if (!has_interval && quota_minutes > 0)
return;
push(result, schedule);
}
});
return result;
}
@@ -1234,7 +1261,8 @@ function add_excluded_clients_route_rule(config, settings) {
});
}
function generate_config(output_path, service_address, mwan3_active, supports_xhttp) {
function generate_config(output_path, service_address, mwan3_active, supports_xhttp, deferred_sections) {
ctx.deferred_sections = parse_deferred_sections(deferred_sections);
ctx.runtime_ruleset_folder = runtime_ruleset_folder;
runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == ""
? true
@@ -1311,11 +1339,11 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh
}
}
function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active, supports_xhttp) {
function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active, supports_xhttp, deferred_sections) {
use_fixture_cursor(fixture_path);
runtime_subscription.set_section_cache_dir(output_path + ".section-cache");
runtime_ruleset_folder = output_path + ".rulesets";
generate_config(output_path, service_address, mwan3_active, supports_xhttp);
generate_config(output_path, service_address, mwan3_active, supports_xhttp, deferred_sections);
}
function stdin_length() {
@@ -1452,9 +1480,9 @@ ensure_custom_ruleset = generator_routes.ensure_custom_ruleset;
let mode = ARGV[0] || "";
if (mode == "generate-config")
generate_config(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
generate_config(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
else if (mode == "generate-config-fixture")
generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]);
else if (mode == "stdin-length")
stdin_length();
else if (mode == "stdin-contains")
@@ -1388,8 +1388,11 @@ function add_connections_outbound(config, section, taken) {
add_connection_interfaces(config, state, section, taken, selector_tags, urltest_candidate_tags);
add_connection_json_outbounds(config, state, section, taken, selector_tags, urltest_candidate_tags);
if (length(selector_tags) == 0)
if (length(selector_tags) == 0) {
if (ctx.deferred_sections && ctx.deferred_sections[section_name])
return;
ctx.runtime_generate_unsupported("connection section has no usable outbounds");
}
if (ctx.routes.section_needs_country_is(section)) {
let previous_state = read_json_file(runtime_subscription.section_cache_path(section_name));
@@ -1681,8 +1681,10 @@ function unsupported_matcher_key(section) {
}
function add_outbound_for_section(config, section, taken, sections) {
let action = option(section, "action", "");
let section_name = section[".name"];
if (ctx.deferred_sections && ctx.deferred_sections[section_name])
return;
let action = option(section, "action", "");
if (!valid_section_name(section_name))
ctx.runtime_generate_unsupported("section name is not safe for sing-box config generation");
let unsupported_matcher = unsupported_matcher_key(section);
@@ -1758,6 +1760,8 @@ function reserve_section_outbound_tags(sections, taken) {
}
function add_route_for_section(config, section) {
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
return;
let action = option(section, "action", "");
if (action == "dns")
add_dns_action_rules_for_section(config, section);
@@ -1771,6 +1775,8 @@ function add_route_for_section(config, section) {
function failover_candidate(sections) {
let result = [];
for (let section in sections) {
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
continue;
let action = option(section, "action", "");
if (connections.is_connections_action(action) ||
action == "awg" || action == "warp" || action == "byedpi" || action == "zapret" || action == "zapret2" ||
+2 -1
View File
@@ -900,7 +900,8 @@ function init_config(populate_nft, caches_prepared, no_refresh) {
temp_config,
service_listen_address_value(settings),
mwan3_active ? "1" : "0",
sing_box_supports_xhttp(sing_box_version()) ? "1" : "0"
sing_box_supports_xhttp(sing_box_version()) ? "1" : "0",
deferred_sections
]) + " >" + shell_quote(runtime_log) + " 2>&1"
);
if (generate_status != 0) {
+26 -6
View File
@@ -42,6 +42,7 @@ const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_
const TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || TACHYON_RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid";
const TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-update.lock";
const TACHYON_PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || TACHYON_RUNTIME_STATE_DIR + "/reload.pending";
const TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE = getenv("TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE") || TACHYON_RUNTIME_STATE_DIR + "/deferred-sections";
const TACHYON_SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon";
const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1";
const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534";
@@ -1687,12 +1688,10 @@ function subscription_config_is_current(section_name_value, subscription_url, su
function get_subscription_download_proxy_address(section_name_value, sections, parsed, phase) {
let download_section = as_string(object_or_empty(parsed).download_section);
if (download_section == "" || download_section == as_string(section_name_value))
if (download_section == "")
return "";
let port = connections.subscription_download_target_port(sections, download_section, int(SB_SERVICE_MIXED_INBOUND_PORT));
if (port <= 0)
return "";
let is_self = (download_section == as_string(section_name_value));
if (!sing_box_service_running()) {
if (phase == "startup")
@@ -1702,6 +1701,14 @@ function get_subscription_download_proxy_address(section_name_value, sections, p
return "";
}
let port = connections.subscription_download_target_port(sections, download_section, int(SB_SERVICE_MIXED_INBOUND_PORT));
if (port <= 0) {
if (is_self)
port = int(SB_SERVICE_MIXED_INBOUND_PORT);
else
return "";
}
let address = SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + as_string(port);
// Verify the proxy port is actually accepting connections — the sing-box
// process may be running but the mixed inbound not yet ready after a
@@ -1744,6 +1751,11 @@ function download_subscription_into_cache(section_name_value, subscription_url,
let effective_hwid = get_subscription_hwid(subscription_hwid);
let download_status = download_subscription(subscription_url, raw_tmpfile, service_proxy_address_value, headers_tmpfile, effective_user_agent, effective_hwid, subscription_device_headers, allow_insecure);
if (download_status != 0 && service_proxy_address_value == "" && sing_box_service_running()) {
let fallback_proxy = SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + SB_SERVICE_MIXED_INBOUND_PORT;
log_message("Direct subscription download failed for rule '" + section_name_value + "'; retrying via active service proxy " + fallback_proxy, "info");
download_status = download_subscription(subscription_url, raw_tmpfile, fallback_proxy, headers_tmpfile, effective_user_agent, effective_hwid, subscription_device_headers, allow_insecure);
}
if (download_status != 0) {
if (metadata_output_path != "")
unlink_path(metadata_output_path);
@@ -2340,7 +2352,8 @@ function prepare_subscription_caches(phase, already_prepared, no_refresh) {
let sections = uci_sections();
if (prepared_runtime_cache_should_skip(sections, TACHYON_SECTION_CACHE_DIR, phase, already_prepared)) {
print("\n");
let saved_deferred = trim(as_string(fs.readfile(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE)) || "");
print(saved_deferred, "\n");
return 0;
}
@@ -2363,12 +2376,14 @@ function prepare_subscription_caches(phase, already_prepared, no_refresh) {
return 1;
if (state.startup_blocked_sections == "") {
unlink_path(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE);
print("\n");
return 0;
}
if (phase == "startup" && subscription_bootstrap_download_section_is_ready(sections, state.startup_blocked_sections, get_subscription_user_agent(""))) {
if ((phase == "startup" || phase == "runtime") && subscription_bootstrap_download_section_is_ready(sections, state.startup_blocked_sections, get_subscription_user_agent(""))) {
log_message("Starting temporarily without subscription-only rule(s): " + state.startup_blocked_sections + ". They will be retried through the service proxy after sing-box starts", "warn");
write_file(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE, state.startup_blocked_sections + "\n");
print(state.startup_blocked_sections, "\n");
return 0;
}
@@ -2436,6 +2451,11 @@ function subscription_bootstrap_retry_result(deferred_sections) {
log_message("Deferred subscription rule '" + section + "' is still unavailable; keeping it disabled for this startup", "warn");
}
if (result.remaining != "")
write_file(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE, result.remaining + "\n");
else
unlink_path(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE);
return result;
}
+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