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" msgid "Download through"
msgstr "Загружать через" msgstr "Загружать через"
msgid "(this rule, when active)"
msgstr "(эта же секция, при активном VPN)"
msgid "Downloaded" msgid "Downloaded"
msgstr "Загружено" msgstr "Загружено"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.3.25", "version": "1.3.26",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -780,7 +780,7 @@ function createParentalContent(section) {
o.rawhtml = true; o.rawhtml = true;
o.modalonly = false; o.modalonly = false;
o.cfgvalue = function (sectionId) { 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") { if (target === "all") {
return ( return (
'<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' + '<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' +
@@ -788,11 +788,20 @@ function createParentalContent(section) {
"</span>" "</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 rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections");
const secNames = normalizeListValues(rawSecs); const secNames = normalizeListValues(rawSecs);
if (secNames.length === 0 && target !== "sections") {
secNames.push(target);
}
if (secNames.length === 0) { if (secNames.length === 0) {
return ( return (
'<span style="opacity:0.6;">' + _("No sections selected") + "</span>" '<span style="opacity:0.6;">' + _("No sections selected") + "</span>"
@@ -814,8 +823,17 @@ function createParentalContent(section) {
); );
}; };
o.textvalue = function (sectionId) { 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 === "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( const secNames = normalizeListValues(
uci.get(UCI_PACKAGE, sectionId, "sections"), uci.get(UCI_PACKAGE, sectionId, "sections"),
); );
@@ -1081,6 +1099,7 @@ function createParentalContent(section) {
), ),
); );
o.modalonly = true; o.modalonly = true;
o.rmempty = false;
o.default = "domains"; o.default = "domains";
o.value("domains", _("Blocked Sites (Only block domains specified below)")); o.value("domains", _("Blocked Sites (Only block domains specified below)"));
o.value("all", _("All Internet (Complete internet cutoff)")); o.value("all", _("All Internet (Complete internet cutoff)"));
@@ -1222,6 +1241,7 @@ function createParentalContent(section) {
), ),
); );
o.modalonly = true; o.modalonly = true;
o.depends("target", "domains");
o.rmempty = true; o.rmempty = true;
o.rows = 6; o.rows = 6;
o.wrap = "soft"; o.wrap = "soft";
@@ -2083,7 +2083,6 @@ function isDownloadThroughTargetSection(section, currentSectionId) {
if ( if (
!sectionName || !sectionName ||
sectionName === currentSectionId ||
section.enabled === "0" section.enabled === "0"
) { ) {
return false; return false;
@@ -2134,10 +2133,17 @@ function isDownloadThroughTargetSection(section, currentSectionId) {
function subscriptionDownloadTargetChoices(section_id) { function subscriptionDownloadTargetChoices(section_id) {
return (uci.sections(UCI_PACKAGE, "section") || []) return (uci.sections(UCI_PACKAGE, "section") || [])
.filter((section) => isDownloadThroughTargetSection(section, section_id)) .filter((section) => isDownloadThroughTargetSection(section, section_id))
.map((section) => ({ .map((section) => {
value: getUciSectionName(section), const name = getUciSectionName(section);
label: getUciSectionLabel(section), let label = getUciSectionLabel(section);
})); if (name === section_id) {
label += " " + _("(this rule, when active)");
}
return {
value: name,
label: label,
};
});
} }
function dnsTypeChoices() { function dnsTypeChoices() {
@@ -2745,9 +2751,6 @@ function addSubscriptionUrlItemOptions(itemSection, options = {}) {
if (!value) { if (!value) {
return _("Select a section for downloading this subscription"); return _("Select a section for downloading this subscription");
} }
if (value === sectionId) {
return _("Current section cannot download its own subscription");
}
return true; return true;
}; };
+3
View File
@@ -1586,6 +1586,9 @@ msgstr "Скачивать эту подписку без проверки се
msgid "Download through" msgid "Download through"
msgstr "Загружать через" msgstr "Загружать через"
msgid "(this rule, when active)"
msgstr "(эта же секция, при активном VPN)"
msgid "Downloaded" msgid "Downloaded"
msgstr "Загружено" msgstr "Загружено"
@@ -86,8 +86,8 @@ done
PREV_STATE="" PREV_STATE=""
[ -f "$STATE_FILE" ] && PREV_STATE="$(cat "$STATE_FILE" 2>/dev/null)" [ -f "$STATE_FILE" ] && PREV_STATE="$(cat "$STATE_FILE" 2>/dev/null)"
TACHYON_BIN="/usr/bin/tachyon" TACHYON_BIN="$(command -v tachyon 2>/dev/null || echo /usr/bin/tachyon)"
[ -x "$TACHYON_BIN" ] || 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 [ "$CURRENT_STATE" = "up" ]; then
if [ "$PREV_STATE" = "down" ]; 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 ], get_tailscale_peers: [ "diagnostics/runtime.uc", "get-tailscale-peers", 0 ],
tailscale_restart: [ "providers/tailscale/runtime.uc", "start-runtime", 0 ], tailscale_restart: [ "providers/tailscale/runtime.uc", "start-runtime", 0 ],
parental_quota_tick: [ "service/parental_quota.uc", "tick", 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 ], failover_check: [ "service/failover.uc", "check", 0 ],
get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ], get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ],
get_server_capabilities: [ "diagnostics/runtime.uc", "get-server-capabilities", 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)) { for (let source in subscription_urls(section)) {
let target = subscription_download_section(section, source); let target = subscription_download_section(section, source);
if (target != "" && target != name) if (target != "")
append_unique(result, seen, target); append_unique(result, seen, target);
} }
} }
@@ -1710,9 +1710,6 @@ function validate_subscription_download_sections(sections, context) {
if (target == "") if (target == "")
continue; 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( validate_download_section_rows(
target, target,
context.byedpi_installed, 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 always_on = length(intervals) == 0;
let target_sec_names = []; 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"); let raw_secs = list_option(schedule, "sections");
if (length(raw_secs) == 0) { if (length(raw_secs) == 0) {
let single_sec = option(schedule, "sections", ""); 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; [ "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 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", "") != ""; 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 ((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 = []; let fwd_rule = [];
append_array(fwd_rule, base_match); append_array(fwd_rule, base_match);
append_array(fwd_rule, [ "counter", verdict ]); 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 intervals = nft_schedule_time_intervals(start_time, end_time);
let days_args = nft_schedule_days_match_args(schedule); let days_args = nft_schedule_days_match_args(schedule);
let always_on = length(intervals) == 0; 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) { for (let raw_ip in raw_ips) {
let dev_str = trim(as_string(raw_ip)); 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_ipv6_port_set(table, ip_port6_set) ||
!nft_create_ipv4_set(table, DNS_SOURCE_SET) || !nft_create_ipv4_set(table, DNS_SOURCE_SET) ||
!nft_create_ipv6_set(table, DNS_SOURCE6_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)) !nft_create_ifname_set(table, interface_set))
return false; 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" ]) || 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_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", [ "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; 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) ]) || 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 started = time();
let ok = command_success_from_args([ 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", "--connect-timeout", "3", "--max-time", "5",
"--proxy", "http://" + host + ":" + port, "--proxy", "http://" + host + ":" + port,
check_url 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; let elapsed = (time() - started) * 1000;
state.proxy_latency_history = push_history(state.proxy_latency_history, 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 // Distinguish "proxy is broken" from "the whole uplink is down": only
// the former is worth restarting sing-box over. // the former is worth restarting sing-box over.
// Use the same URL as the proxy check so the two measurements are // Check primary URL, and if that fails, check fallback URL directly too
// comparable: if the user changed ai_proxy_health_url, direct_ok // in case the primary URL is blocked directly on the ISP WAN.
// must probe the same target, not always fall back to Cloudflare.
let direct_ok = command_success_from_args([ let direct_ok = command_success_from_args([
"curl", "-s", "-I", "--connect-timeout", "3", "--max-time", "5", "curl", "-s", "-I", "--connect-timeout", "3", "--max-time", "5",
check_url 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, { bus.emit(EV.PROXY_DOWN, {
host: host, host: host,
+5 -1
View File
@@ -698,7 +698,9 @@ function singbox_init_config() {
subscription_runtime_no_refresh subscription_runtime_no_refresh
]); ]);
if (result.status == 0) { 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"; subscription_caches_prepared = "1";
} }
return result.status; return result.status;
@@ -1119,6 +1121,8 @@ function start() {
return status; return status;
} }
module_status(STATE_UC, [ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]);
return 0; 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))); 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 ]) || ""; 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() { function nft_table_exists() {
@@ -282,7 +282,7 @@ function nft_create_set(set_name, set_type) {
function nft_insert_quota_rule(chain, match_type) { function nft_insert_quota_rule(chain, match_type) {
let set_name = match_type == "ether" ? SET_ETHER : SET_IP; let set_name = match_type == "ether" ? SET_ETHER : SET_IP;
let addr_type = match_type == "ether" ? "ether" : "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"); let rc = command_status(cmd + " 2>/dev/null");
if (rc != 0) if (rc != 0)
log_message("failed to insert quota rule in " + chain + " for " + match_type + ": " + as_string(rc), "warn"); 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"); nft_create_set(SET_IP, "ipv4_addr");
// Insert drop rules into parental chains (idempotent via comment check) // 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"); 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"); 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"); 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"); nft_insert_quota_rule("parental_forward", "ip");
return true; return true;
} }
function sync_block_set(set_name, idents) { 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) { if (length(idents) > 0) {
let cmd = command_from_args([ "nft", "add", "element", "inet", NFT_TABLE_NAME, set_name, "{ " + join(", ", idents) + " }" ]); let cmd = "nft add element inet " + NFT_TABLE_NAME + " " + set_name + " { " + join(", ", idents) + " }";
if (!command_success_from_args(cmd)) let rc = command_status(cmd + " 2>/dev/null");
log_message("failed to sync nft set " + set_name + " with " + as_string(length(idents)) + " elements", "warn"); 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] || ""; let mode = ARGV[0] || "";
if (mode == "tick") if (mode == "tick")
exit(tick()); exit(tick());
else if (mode == "status") else if (mode == "status")
status_json(); status_json();
else if (mode == "reset")
exit(reset_quotas());
else if (mode == "install-cron") else if (mode == "install-cron")
exit(install_cron()); exit(install_cron());
else if (mode == "remove-cron") else if (mode == "remove-cron")
exit(remove_cron()); exit(remove_cron());
else { 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); exit(1);
} }
+13 -3
View File
@@ -855,6 +855,10 @@ function heal_dns_stall(ev) {
// would drop live connections for nothing. // would drop live connections for nothing.
function heal_proxy_connectivity(ev) { function heal_proxy_connectivity(ev) {
if (settings().recovery_bypass == "1") return; 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; if (!ev.payload.direct_ok) return;
// Two consecutive failures before acting: a single brief proxy-node timeout // 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 // (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"); "Очищена база cache.db; перезапуск пропущен по лимиту частоты", "skipped");
return; return;
} }
controller.reset_proxy_consecutive();
watch_recovery("proxy", incident, "proxy_connectivity"); watch_recovery("proxy", incident, "proxy_connectivity");
} }
@@ -1026,6 +1031,11 @@ function validate_singbox_config() {
// of failures and validates the sing-box config before restarting. // of failures and validates the sing-box config before restarting.
function heal_proxy_health(ev) { function heal_proxy_health(ev) {
if (settings().ai_proxy_health_enabled == "0") return; 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 threshold = int(settings().ai_proxy_health_fail_threshold || "3");
let fails = int(ev.payload.streak); let fails = int(ev.payload.streak);
@@ -1034,8 +1044,8 @@ function heal_proxy_health(ev) {
let incident = { let incident = {
type: "proxy_health", type: "proxy_health",
description: sprintf("Proxy health check failed %d times consecutively (port %s)", fails, as_string(ev.payload.port)), description: sprintf("Прокси не отвечает на запросы проверки доступности (%d сбоя подряд, порт %s)", fails, as_string(ev.payload.port)),
resolution: "Restarting Tachyon to restore proxy connectivity" resolution: "Выполнен перезапуск sing-box"
}; };
// A restart on a config sing-box will refuse to load leaves the proxy down // 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, subscribe(EV.PROXY_DOWN, heal_proxy_connectivity,
{ name: "heal_proxy_connectivity", priority: PRIORITY_PROXY, cooldown: 120 }); { name: "heal_proxy_connectivity", priority: PRIORITY_PROXY, cooldown: 120 });
subscribe(EV.PROXY_DOWN, heal_proxy_health, 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 // 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 // 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_settings_cache = null;
let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER; let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER;
let runtime_supports_xhttp = true; 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 as_string = common.as_string;
let read_json_file = common.read_json_file; let read_json_file = common.read_json_file;
@@ -49,6 +51,22 @@ let url_query_params = runtime_url.query_params;
const CONFIG_NAME = "tachyon"; 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 // UCI stores values written as '<b 0x...>' strings as binary, which when
// serialized back to JSON appear as '<b 0x...>' — invalid for sing-box. // serialized back to JSON appear as '<b 0x...>' — invalid for sing-box.
function uci_bin_to_hex(val) { 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) { function add_mixed_proxy_for_section(config, section, service_address) {
if (!bool_option(section, "mixed_proxy_enabled", false)) if (!bool_option(section, "mixed_proxy_enabled", false))
return; return;
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
return;
let action = option(section, "action", ""); let action = option(section, "action", "");
if (!mixed_proxy_enabled_action(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) { function add_subscription_download_service_mixed_proxies(config, sections) {
for (let target in connections.subscription_download_targets(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); let port = connections.subscription_download_target_port(sections, target, runtime_constants.SERVICE_MIXED_INBOUND_PORT);
if (port <= 0) if (port <= 0)
runtime_generate_unsupported("subscription download proxy port could not be resolved"); 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() { function enabled_content_block_schedules() {
let result = []; let result = [];
ctx.uci_cursor().foreach(CONFIG_NAME, "schedule", function(schedule) { 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); push(result, schedule);
}
}); });
return result; 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; ctx.runtime_ruleset_folder = runtime_ruleset_folder;
runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == "" runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == ""
? true ? 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); use_fixture_cursor(fixture_path);
runtime_subscription.set_section_cache_dir(output_path + ".section-cache"); runtime_subscription.set_section_cache_dir(output_path + ".section-cache");
runtime_ruleset_folder = output_path + ".rulesets"; 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() { function stdin_length() {
@@ -1452,9 +1480,9 @@ ensure_custom_ruleset = generator_routes.ensure_custom_ruleset;
let mode = ARGV[0] || ""; let mode = ARGV[0] || "";
if (mode == "generate-config") 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") 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") else if (mode == "stdin-length")
stdin_length(); stdin_length();
else if (mode == "stdin-contains") 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_interfaces(config, state, section, taken, selector_tags, urltest_candidate_tags);
add_connection_json_outbounds(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"); ctx.runtime_generate_unsupported("connection section has no usable outbounds");
}
if (ctx.routes.section_needs_country_is(section)) { if (ctx.routes.section_needs_country_is(section)) {
let previous_state = read_json_file(runtime_subscription.section_cache_path(section_name)); 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) { function add_outbound_for_section(config, section, taken, sections) {
let action = option(section, "action", "");
let section_name = section[".name"]; 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)) if (!valid_section_name(section_name))
ctx.runtime_generate_unsupported("section name is not safe for sing-box config generation"); ctx.runtime_generate_unsupported("section name is not safe for sing-box config generation");
let unsupported_matcher = unsupported_matcher_key(section); 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) { function add_route_for_section(config, section) {
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
return;
let action = option(section, "action", ""); let action = option(section, "action", "");
if (action == "dns") if (action == "dns")
add_dns_action_rules_for_section(config, section); add_dns_action_rules_for_section(config, section);
@@ -1771,6 +1775,8 @@ function add_route_for_section(config, section) {
function failover_candidate(sections) { function failover_candidate(sections) {
let result = []; let result = [];
for (let section in sections) { for (let section in sections) {
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
continue;
let action = option(section, "action", ""); let action = option(section, "action", "");
if (connections.is_connections_action(action) || if (connections.is_connections_action(action) ||
action == "awg" || action == "warp" || action == "byedpi" || action == "zapret" || action == "zapret2" || 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, temp_config,
service_listen_address_value(settings), service_listen_address_value(settings),
mwan3_active ? "1" : "0", 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" ]) + " >" + shell_quote(runtime_log) + " 2>&1"
); );
if (generate_status != 0) { 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_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_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_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 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_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1";
const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; 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) { function get_subscription_download_proxy_address(section_name_value, sections, parsed, phase) {
let download_section = as_string(object_or_empty(parsed).download_section); 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 ""; return "";
let port = connections.subscription_download_target_port(sections, download_section, int(SB_SERVICE_MIXED_INBOUND_PORT)); let is_self = (download_section == as_string(section_name_value));
if (port <= 0)
return "";
if (!sing_box_service_running()) { if (!sing_box_service_running()) {
if (phase == "startup") if (phase == "startup")
@@ -1702,6 +1701,14 @@ function get_subscription_download_proxy_address(section_name_value, sections, p
return ""; 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); let address = SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + as_string(port);
// Verify the proxy port is actually accepting connections — the sing-box // Verify the proxy port is actually accepting connections — the sing-box
// process may be running but the mixed inbound not yet ready after a // 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 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); 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 (download_status != 0) {
if (metadata_output_path != "") if (metadata_output_path != "")
unlink_path(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(); let sections = uci_sections();
if (prepared_runtime_cache_should_skip(sections, TACHYON_SECTION_CACHE_DIR, phase, already_prepared)) { 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; return 0;
} }
@@ -2363,12 +2376,14 @@ function prepare_subscription_caches(phase, already_prepared, no_refresh) {
return 1; return 1;
if (state.startup_blocked_sections == "") { if (state.startup_blocked_sections == "") {
unlink_path(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE);
print("\n"); print("\n");
return 0; 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"); 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"); print(state.startup_blocked_sections, "\n");
return 0; 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"); 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; 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)" fail "sing-box 1.14+ must NEVER contain 'dial_detour' in http_clients (causes fatal unknown field crash)"
fi fi
if command -v sing-box >/dev/null 2>&1; then 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 fi
output_detour_v14="$WORK_DIR/out_detour_v14.json" output_detour_v14="$WORK_DIR/out_detour_v14.json"
@@ -133,7 +136,10 @@ fi
grep -q '"detour": "first_proxy-out"' "$output_detour_v14" || \ 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" 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 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 fi
# 4. Verify zapret section with community_lists does NOT use zapret-out as download_detour # 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" fail "snapshot-restore must report success"
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
grep -Fxq 'restart' "$WORK_DIR/bin.log" 2>/dev/null && break grep -Fxq 'restart' "$WORK_DIR/bin.log" 2>/dev/null && break
sleep 0.1 sleep 1
done done
grep -Fxq 'restart' "$WORK_DIR/bin.log" || grep -Fxq 'restart' "$WORK_DIR/bin.log" ||
fail "snapshot-restore must restart the service through the backend entrypoint" 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 EOF
chmod +x "$BIN_DIR/logger" 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) # Mock tachyon cli (for send_telegram_notification)
cat > "$BIN_DIR/tachyon" << 'EOF' cat > "$BIN_DIR/tachyon" << 'EOF'
#!/bin/sh #!/bin/sh
echo "$@" >> "${TEST_TELEGRAM_LOG}" if [ "$1" = "telegram" ]; then
echo "$@" >> "${TEST_TELEGRAM_LOG}"
fi
exit 0 exit 0
EOF EOF
chmod +x "$BIN_DIR/tachyon" 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" export TEST_TELEGRAM_LOG="$TMP_DIR/telegram.log"
mkdir -p "$TEST_UCI_DIR" 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" TEST_MONITOR="$TMP_DIR/monitor_under_test.sh"
# shellcheck disable=SC2016 # shellcheck disable=SC2016
sed -e "s|/var/run/tachyon|${VAR_RUN}|g" \ sed -e "s|/var/run/tachyon|${VAR_RUN}|g" \
-e 's|sleep "\$DELAY_SEC"|sleep 0.05|g' \ -e "s|/usr/bin/tachyon|${BIN_DIR}/tachyon|g" \
-e 's|sleep 2|sleep 0.05|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" "$MONITOR_SCRIPT" > "$TEST_MONITOR"
chmod +x "$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" echo "down" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR" ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 1 failed: notification sent when enable_badwan=0" [ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 1 failed: notification sent when enable_badwan=0"
# Test 2: Telegram disabled (telegram_enabled = 0) # Test 2: Telegram disabled (telegram_enabled = 0)
echo "1" > "$TEST_UCI_DIR/enable_badwan" echo "1" > "$TEST_UCI_DIR/enable_badwan"
echo "0" > "$TEST_UCI_DIR/telegram_enabled" echo "0" > "$TEST_UCI_DIR/telegram_enabled"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR" ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 2 failed: notification sent when telegram.enabled=0" [ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 2 failed: notification sent when telegram.enabled=0"
# Test 3: Unmonitored interface (wan6 triggered, only wan is monitored) # 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 "wan" > "$TEST_UCI_DIR/monitored_ifaces"
echo "down" > "$TEST_UCI_DIR/ubus_state_wan6" echo "down" > "$TEST_UCI_DIR/ubus_state_wan6"
ACTION="ifdown" INTERFACE="wan6" sh "$TEST_MONITOR" ACTION="ifdown" INTERFACE="wan6" sh "$TEST_MONITOR"
sleep 0.2
[ ! -f "$TEST_TELEGRAM_LOG" ] || fail "Test 3 failed: notification sent for unmonitored interface wan6" [ ! -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) # 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" ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR"
# Wait for background verification # Wait for background verification
sleep 0.3 sleep 1
[ -f "$TEST_TELEGRAM_LOG" ] || fail "Test 4 failed: no notification sent on UP -> DOWN transition" [ -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" 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) # Test 5: Duplicate hotplug ifdown while state is already down (NO repeated spam)
rm -f "$TEST_TELEGRAM_LOG" rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifdown" INTERFACE="wan" sh "$TEST_MONITOR" 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" [ ! -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" echo "up" > "$TEST_UCI_DIR/ubus_state_wan"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR" 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" [ -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" 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) # Test 7: Duplicate hotplug ifup while state is already up (NO duplicate message)
rm -f "$TEST_TELEGRAM_LOG" rm -f "$TEST_TELEGRAM_LOG"
ACTION="ifup" INTERFACE="wan" sh "$TEST_MONITOR" 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" [ ! -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 # 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 # handles retries explicitly in a while loop, so the flag is unnecessary and
# breaks downloads on recent OpenWrt releases. # 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 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
grep -rn --exclude='*.json' --fixed-strings 'wget' "$ROOT_DIR/tachyon/files" | grep -E -- '(^|[^-a-z])(-t[^a-z]|-t")' >&2 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" fail "wget -t is incompatible with OpenWrt 25.12+ BusyBox; use explicit retry loops instead"
fi fi