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:
@@ -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,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user