- 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
2260 lines
97 KiB
Ucode
2260 lines
97 KiB
Ucode
#!/usr/bin/env ucode
|
||
|
||
let fs = require("fs");
|
||
let uci_core = require("core.uci");
|
||
let common = require("core.common");
|
||
let helpers = require("core.helpers");
|
||
let connections = require("config.connections");
|
||
let events = require("core.events");
|
||
let event_controller = require("service.event_controller");
|
||
|
||
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
|
||
const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon";
|
||
const PID_FILE = "/var/run/tachyon_watchdog.pid";
|
||
const WATCHDOG_UC = LIB_DIR + "/service/watchdog.uc";
|
||
const PAUSE_FILE = "/tmp/tachyon_paused_until";
|
||
const SMART_DETECT_SEEN_FILE = "/etc/tachyon/smart_detect_seen.json";
|
||
|
||
|
||
let as_string = common.as_string;
|
||
let shell_quote = common.shell_quote;
|
||
|
||
let proxy_restart_count = 0;
|
||
let proxy_restart_window_start = time();
|
||
const PROXY_RESTART_LOCK = "/var/run/tachyon_proxy_restart.lock";
|
||
let telegram_msg_count = 0;
|
||
let telegram_msg_window = time();
|
||
let telegram_msg_history = {};
|
||
let tailscale_fail_streak = 0;
|
||
let tailscale_last_alert = 0;
|
||
|
||
let command_from_args = common.command_from_args;
|
||
let command_status = common.command_status;
|
||
let command_success_from_args = common.command_success_from_args;
|
||
let command_output_from_args = common.command_output_from_args;
|
||
let is_process_name_running = helpers.is_process_name_running;
|
||
|
||
function command_capture(command) {
|
||
let pipe = fs.popen(command, "r");
|
||
if (!pipe)
|
||
return { status: 1, output: "" };
|
||
let data = pipe.read("all");
|
||
let status = pipe.close();
|
||
if (status > 255) status = int(status / 256);
|
||
return { status, output: data == null ? "" : as_string(data) };
|
||
}
|
||
|
||
// Background spawn for the watchdog. This used to close only the logread pipe,
|
||
// by number, because that was the one descriptor known to leak: each restart
|
||
// inherited its read end and kept orphaned `logread -f` processes alive across
|
||
// watchdog generations. The pipe was never special, though — it was just the
|
||
// leak that got noticed. The shared prologue closes every inherited descriptor,
|
||
// so the logread FD needs no separate handling and this is now a thin alias
|
||
// kept for the call sites.
|
||
function bg_system(cmd) {
|
||
system(common.background_command(cmd));
|
||
}
|
||
|
||
function settings() {
|
||
return common.object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
|
||
}
|
||
|
||
// The empty catch is the point: every caller means "make sure this path is
|
||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||
// so the alternative is a stat() race with no better outcome.
|
||
function remove_file(path) {
|
||
try {
|
||
fs.unlink(as_string(path));
|
||
}
|
||
catch (e) {
|
||
}
|
||
}
|
||
|
||
function log_message(message, level) {
|
||
let priority = 6;
|
||
let lvl = as_string(level || "info");
|
||
if (lvl == "warn" || lvl == "warning") {
|
||
priority = 4;
|
||
} else if (lvl == "err" || lvl == "error" || lvl == "fatal") {
|
||
priority = 3;
|
||
} else if (lvl == "debug") {
|
||
priority = 7;
|
||
}
|
||
|
||
let kmsg = fs.open("/dev/kmsg", "w");
|
||
if (kmsg) {
|
||
kmsg.write(sprintf("<%d>tachyon: [%s] Watchdog: %s\n", priority, lvl, as_string(message)));
|
||
kmsg.close();
|
||
} else {
|
||
command_success_from_args([ "logger", "-t", "tachyon", "[" + lvl + "] Watchdog: " + as_string(message) ]);
|
||
}
|
||
}
|
||
|
||
// Files the watchdog writes to keep its own state: the restart lock, the OOM
|
||
// scale, the keepalive stamp. A failed write here is not cosmetic — the lock
|
||
// not landing means the next tick restarts the proxy again with the rate limit
|
||
// none the wiser, and a missing keepalive makes init.d conclude the watchdog
|
||
// died and restart it. These writes used to be wrapped in an empty catch, so
|
||
// the failure was invisible in every case.
|
||
//
|
||
// The keepalive is written on every uloop tick, so an unconditional log line
|
||
// would flood syslog on a full filesystem — the exact condition where the log
|
||
// matters most. Each path therefore reports its first failure and then stays
|
||
// quiet until it succeeds again, which also makes recovery visible.
|
||
let write_failures = {};
|
||
|
||
function write_state_file(path, value, description) {
|
||
path = as_string(path);
|
||
let ok = false;
|
||
|
||
let parent_m = match(path, /^(.+)\/[^\/]+$/);
|
||
if (parent_m && parent_m[1] != "") {
|
||
try { fs.mkdir(parent_m[1]); } catch (e) {}
|
||
}
|
||
|
||
try {
|
||
ok = fs.writefile(path, as_string(value)) != null;
|
||
}
|
||
catch (e) {
|
||
log_message("Failed to write " + description + " (" + path + "): " + as_string(e),
|
||
write_failures[path] ? "debug" : "err");
|
||
write_failures[path] = true;
|
||
return false;
|
||
}
|
||
|
||
if (!ok) {
|
||
log_message("Failed to write " + description + " (" + path + ")",
|
||
write_failures[path] ? "debug" : "err");
|
||
write_failures[path] = true;
|
||
return false;
|
||
}
|
||
|
||
if (write_failures[path]) {
|
||
log_message("Write to " + description + " (" + path + ") succeeded again", "warn");
|
||
delete write_failures[path];
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
|
||
function send_telegram_notification(message, cooldown_key, cooldown_seconds) {
|
||
let now = time();
|
||
if (cooldown_key != null && cooldown_key != "") {
|
||
let last_sent = int(telegram_msg_history[cooldown_key] || 0);
|
||
let cooldown = cooldown_seconds != null ? int(cooldown_seconds) : 900;
|
||
if (now - last_sent < cooldown) {
|
||
return;
|
||
}
|
||
telegram_msg_history[cooldown_key] = now;
|
||
}
|
||
if (now - telegram_msg_window > 300) {
|
||
telegram_msg_count = 0;
|
||
telegram_msg_window = now;
|
||
}
|
||
if (telegram_msg_count >= 10) return;
|
||
telegram_msg_count++;
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.enabled == "1" && tcfg.bot_token && tcfg.admin_ids) {
|
||
system(common.background_command("/usr/bin/tachyon telegram send " + shell_quote(message)));
|
||
}
|
||
}
|
||
|
||
function process_running(pid, expected_name) {
|
||
if (match(as_string(pid), /^[0-9]+$/) == null)
|
||
return false;
|
||
if (expected_name != null && expected_name != "") {
|
||
return is_process_name_running(pid, expected_name);
|
||
}
|
||
return fs.stat("/proc/" + pid) != null;
|
||
}
|
||
|
||
function stop_runtime() {
|
||
let pid = trim(fs.readfile(PID_FILE) || "");
|
||
if (process_running(pid, "ucode")) {
|
||
command_success_from_args([ "kill", pid ]);
|
||
let wait_limit = 50; // 5 seconds
|
||
while (wait_limit > 0 && process_running(pid, "ucode")) {
|
||
sleep(100);
|
||
wait_limit--;
|
||
}
|
||
if (process_running(pid, "ucode")) {
|
||
command_success_from_args([ "kill", "-9", pid ]);
|
||
}
|
||
}
|
||
remove_file(PID_FILE);
|
||
|
||
// Kill orphaned logread -f processes. These accumulate when watchdog is
|
||
// killed without proper cleanup (e.g. SIGTERM from procd during restart).
|
||
// Anchor with $ to avoid killing system logremote/logfile processes.
|
||
system("pkill -f 'logread -f$' 2>/dev/null; true");
|
||
|
||
// Stop Honeypot listener
|
||
let hp_pid = trim(fs.readfile("/var/run/tachyon_honeypot_listener.pid") || "");
|
||
if (process_running(hp_pid)) {
|
||
command_success_from_args([ "kill", hp_pid ]);
|
||
let wait_limit = 20; // 2 seconds
|
||
while (wait_limit > 0 && process_running(hp_pid)) {
|
||
sleep(100);
|
||
wait_limit--;
|
||
}
|
||
if (process_running(hp_pid)) {
|
||
command_success_from_args([ "kill", "-9", hp_pid ]);
|
||
}
|
||
}
|
||
remove_file("/var/run/tachyon_honeypot_listener.pid");
|
||
remove_file("/tmp/tachyon_honeypot.fifo");
|
||
remove_file(PROXY_RESTART_LOCK);
|
||
|
||
return 0;
|
||
}
|
||
|
||
// ─── Pause auto-resume ────────────────────────────────────────────────────────
|
||
function check_auto_resume_pause() {
|
||
let val = trim(fs.readfile(PAUSE_FILE) || "");
|
||
if (val == "") return false;
|
||
let until = int(val);
|
||
let now = time();
|
||
if (until <= now) {
|
||
remove_file(PAUSE_FILE);
|
||
log_message("Pause expired, auto-resuming Tachyon...", "info");
|
||
command_status("/usr/bin/tachyon start > /dev/null 2>&1");
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.enabled == "1" && tcfg.bot_token && tcfg.admin_ids) {
|
||
send_telegram_notification("▶️ Прокси возобновлён (пауза истекла).");
|
||
}
|
||
return false;
|
||
}
|
||
return true; // still paused, skip normal checks
|
||
}
|
||
|
||
// ─── Smart Detect — self-healing routing ─────────────────────────────────────
|
||
function smart_detect_get_proxy_sections() {
|
||
let c = uci_core.cursor();
|
||
if (!c) return [];
|
||
c.load(CONFIG_NAME);
|
||
let connections = require("config.connections");
|
||
let secs = [];
|
||
c.foreach(CONFIG_NAME, "section", function(s) {
|
||
if (s.enabled != "1") return;
|
||
let act = as_string(s.action || "");
|
||
if (act != "bypass" && act != "block" && act != "dns" && act != "") {
|
||
push(secs, s[".name"]);
|
||
}
|
||
});
|
||
return secs;
|
||
}
|
||
|
||
// Extract a candidate domain from a sing-box log line. Returns null when the
|
||
// line carries no usable hostname. Kept separate from log handling so the
|
||
// pattern can be exercised directly by tests.
|
||
function smart_detect_extract_domain(line) {
|
||
if (line == null) return null;
|
||
let text = as_string(line);
|
||
let m = match(text, /"([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})(:[0-9]+)?"/);
|
||
if (!m) m = match(text, /target[= ]([a-zA-Z0-9][a-zA-Z0-9.-]{1,60}\.[a-zA-Z]{2,})/);
|
||
if (!m || !m[1]) return null;
|
||
let domain = m[1];
|
||
if (length(domain) < 5) return null;
|
||
if (index(domain, "*") >= 0 || index(domain, "?") >= 0) return null;
|
||
if (index(domain, "..") >= 0) return null;
|
||
if (index(domain, "-") == 0 || substr(domain, length(domain) - 1) == "-") return null;
|
||
return domain;
|
||
}
|
||
|
||
// A domain that resolves nowhere is a DNS fault, not a block: probing it would
|
||
// fail directly and via proxy alike, so treat it as unresolvable and skip.
|
||
function smart_detect_domain_resolves(domain) {
|
||
return command_success_from_args([ "nslookup", domain, "127.0.0.1" ]) ||
|
||
command_success_from_args([ "nslookup", domain ]);
|
||
}
|
||
|
||
function smart_detect_add_domain(sec_name, domain) {
|
||
let c = uci_core.cursor();
|
||
if (!c) return false;
|
||
c.load(CONFIG_NAME);
|
||
let sec = c.get_all(CONFIG_NAME, sec_name);
|
||
if (!sec) return false;
|
||
let existing = sec.user_domains;
|
||
if (type(existing) != "array") {
|
||
existing = (existing && trim(as_string(existing)) != "") ? [trim(as_string(existing))] : [];
|
||
}
|
||
for (let d in existing) {
|
||
if (trim(as_string(d)) == domain) return true;
|
||
}
|
||
c.list_add(CONFIG_NAME, sec_name, "user_domains", domain);
|
||
c.commit(CONFIG_NAME);
|
||
command_status("/usr/bin/tachyon reload > /dev/null 2>&1");
|
||
return true;
|
||
}
|
||
|
||
|
||
|
||
|
||
function start_runtime() {
|
||
let cfg = settings();
|
||
stop_runtime();
|
||
|
||
let enable_watchdog = cfg.enable_watchdog != "0";
|
||
if (!enable_watchdog) {
|
||
return 0;
|
||
}
|
||
|
||
let command = common.background_command_with_pid(
|
||
command_from_args([ "ucode", "-L", LIB_DIR, WATCHDOG_UC, "worker" ]),
|
||
">/dev/null", ">" + shell_quote(PID_FILE));
|
||
return command_status(command);
|
||
}
|
||
|
||
function run_zero_rtt_prefetching() {
|
||
let cfg = settings();
|
||
let sections = uci_core.get_all(CONFIG_NAME);
|
||
if (!sections) return;
|
||
|
||
// Safety cap: prefetching thousands of domains on a 128 MB router spawns
|
||
// enough dig/nslookup processes to trigger the OOM killer. The cap is a
|
||
// reasonable upper bound for user-managed lists; ruleset-driven traffic is
|
||
// handled by sing-box's own DNS cache, not by this prefetcher.
|
||
const PREFETCH_DOMAIN_LIMIT = 500;
|
||
|
||
let unique_domains = {};
|
||
for (let k in keys(sections)) {
|
||
let sec = sections[k];
|
||
if (sec.enabled == "0") continue;
|
||
|
||
let list_val = sec.user_domains;
|
||
let list_array = [];
|
||
if (type(list_val) == "array") {
|
||
list_array = list_val;
|
||
} else if (list_val) {
|
||
list_array = split(trim(as_string(list_val)), /\s+/);
|
||
}
|
||
|
||
for (let dom in list_array) {
|
||
dom = trim(dom);
|
||
if (dom != "" && index(dom, "*") < 0 && index(dom, "?") < 0) {
|
||
unique_domains[dom] = true;
|
||
if (length(keys(unique_domains)) >= PREFETCH_DOMAIN_LIMIT) break;
|
||
}
|
||
}
|
||
|
||
if (length(keys(unique_domains)) >= PREFETCH_DOMAIN_LIMIT) break;
|
||
|
||
let text_val = sec.user_domains_text;
|
||
if (text_val) {
|
||
for (let line in split(text_val, "\n")) {
|
||
line = trim(line);
|
||
if (line != "" && index(line, "#") != 0 && index(line, "*") < 0 && index(line, "?") < 0) {
|
||
unique_domains[line] = true;
|
||
if (length(keys(unique_domains)) >= PREFETCH_DOMAIN_LIMIT) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (length(keys(unique_domains)) >= PREFETCH_DOMAIN_LIMIT) break;
|
||
}
|
||
|
||
let domain_list = keys(unique_domains);
|
||
if (length(domain_list) == 0) return;
|
||
|
||
if (length(domain_list) >= PREFETCH_DOMAIN_LIMIT)
|
||
log_message("Zero-RTT Prefetcher: domain list capped at " + PREFETCH_DOMAIN_LIMIT + " to prevent OOM on low-RAM devices", "warn");
|
||
|
||
log_message("Zero-RTT Prefetcher: pre-resolving " + length(domain_list) + " domains in batches...", "info");
|
||
let batch = [];
|
||
for (let i, dom in domain_list) {
|
||
push(batch, shell_quote(dom));
|
||
if (length(batch) >= 15 || i == length(domain_list) - 1) {
|
||
// The `&` used to sit inside batch_cmd, which left the redirections
|
||
// that followed it attached to a null command in this shell instead
|
||
// of to the backgrounded loop. Backgrounding is now the wrapper's
|
||
// job, so the loop gets the redirections it was always meant to have.
|
||
let batch_cmd = "for d in " + join(" ", batch) + "; do dig @127.0.0.1 \"$d\" A >/dev/null 2>&1 || nslookup \"$d\" 127.0.0.1 >/dev/null 2>&1; done";
|
||
system(common.background_command(batch_cmd));
|
||
batch = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
let uloop = null;
|
||
let ubus = null;
|
||
// Optional modules: absent in the Docker test image and on a router without
|
||
// libubus, where the watchdog degrades to the polling path below.
|
||
try { uloop = require("uloop"); } catch (e) {}
|
||
try { ubus = require("ubus"); } catch (e) {}
|
||
|
||
// ─── Event bus and observation controller ─────────────────────────────────────
|
||
// The controller observes and publishes facts; everything below subscribes and
|
||
// repairs. Splitting the two is what removes the detection prologue that used
|
||
// to be copy-pasted into the head of every ai_heal_* function.
|
||
let EV = event_controller.EV;
|
||
let bus = events.bus();
|
||
let controller = event_controller.controller(bus, {
|
||
log: function(message, level) { log_message(message, level); }
|
||
});
|
||
|
||
// A subscriber that throws must not silence the subscribers behind it. This is
|
||
// the isolation safe_call() gave each check in the old loop, now applied once
|
||
// by the bus to every handler.
|
||
bus.on_error(function(name, event_type, err) {
|
||
log_message("Graceful degradation: " + name + " (" + event_type + ") failed: " + as_string(err), "err");
|
||
});
|
||
|
||
// Repair-side pacing. Unlike the observation counters below, these belong to
|
||
// the action: they debounce how often a healer is allowed to act, not how often
|
||
// the world is measured.
|
||
let last_oom_recovery_time = 0;
|
||
let last_restart_time = 0;
|
||
let last_reload_time = 0;
|
||
let pending_smart_domains = {};
|
||
let smart_detect_last_run = 0;
|
||
const PENDING_SMART_DOMAINS_MAX = 500;
|
||
const PENDING_SMART_DOMAINS_TTL = 300;
|
||
let last_fast_check = 0;
|
||
let last_normal_check = 0;
|
||
let last_slow_check = 0;
|
||
|
||
|
||
|
||
// ─── Root-cause suppression ───────────────────────────────────────────────────
|
||
// A dead WAN takes the proxy, DNS and the health check down with it. Each of
|
||
// those has its own subscriber, and each used to conclude independently that a
|
||
// restart was in order — three repairs for one fault, none of which could work
|
||
// until the uplink came back. safe_proxy_restart()'s rate limit throttled the
|
||
// pile-up after the fact, but nothing expressed that they were consequences.
|
||
//
|
||
// The bus already ranks the healers by cause: WAN 10, sing-box 20, proxy 40/41,
|
||
// DNS 50/51. That ordering is reused here rather than inventing a second
|
||
// hierarchy — a healer stands down while a strictly lower number is being
|
||
// repaired.
|
||
//
|
||
// The window is bounded on both ends: the recovery fact clears it, and a
|
||
// deadline clears it regardless, so a root cause that never recovers cannot
|
||
// suppress its consequences forever.
|
||
const SUPPRESSION_DEADLINE = 90;
|
||
|
||
// The causal ranks. Named because they are now read in two places — the
|
||
// subscription priority and the suppression check — and a silent drift between
|
||
// them would either suppress nothing or suppress the cause with its own effect.
|
||
const PRIORITY_WAN = 10;
|
||
const PRIORITY_SINGBOX = 20;
|
||
const PRIORITY_PROXY = 40;
|
||
const PRIORITY_DNS = 50;
|
||
|
||
// Two deadline vars replace the general registry: only WAN (priority 10) and
|
||
// sing-box (priority 20) are ever claimed; proxy (40) and DNS (50) healers only
|
||
// need to know if either is active. A named pair is clearer than a keyed object
|
||
// with a runtime keys() iteration and has the same effect.
|
||
let wan_repair_until = 0;
|
||
let singbox_repair_until = 0;
|
||
|
||
// A skip is logged, never silent: "the watchdog did nothing" and "the watchdog
|
||
// deliberately stood down" look identical in a log that omits this.
|
||
function suppressed_by_root_cause(healer) {
|
||
let now = time();
|
||
if (wan_repair_until > now) {
|
||
log_message(sprintf("%s: standing down, WAN repair is in progress", healer), "info");
|
||
return true;
|
||
}
|
||
if (singbox_repair_until > now) {
|
||
log_message(sprintf("%s: standing down, sing-box restart is in progress", healer), "info");
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ─── Escalation ladder ────────────────────────────────────────────────────────
|
||
// Every diagnosis used to end in the same action: a full `/etc/init.d/tachyon
|
||
// restart`, which tears down nftables, TPROXY and routing and drops every live
|
||
// TCP/RDP session. That is the right hammer for a broken stack and far too much
|
||
// for a sing-box that merely stopped answering — the same class of problem
|
||
// c8052ee6 fixed for DNS.
|
||
//
|
||
// So a reason starts on the light rung: restart the sing-box service alone, the
|
||
// mechanism lifecycle.uc already uses (:799, :929). nftables, TPROXY and routes
|
||
// stay in place, and only connections through the proxy break.
|
||
//
|
||
// `tachyon reload` cannot serve as this rung: it returns immediately when the
|
||
// config hash is unchanged (lifecycle.uc:1220), and during a repair the config
|
||
// is exactly what did not change.
|
||
//
|
||
// A reason escalates to the heavy rung only after the light one demonstrably
|
||
// failed — which is knowable because the recovery watch reports outcomes.
|
||
const ESCALATION_LIGHT = "light";
|
||
const ESCALATION_HEAVY = "heavy";
|
||
|
||
// reason → the rung its NEXT attempt should use.
|
||
let escalation_level = {};
|
||
|
||
function next_escalation(reason) {
|
||
return escalation_level[reason] == ESCALATION_HEAVY ? ESCALATION_HEAVY : ESCALATION_LIGHT;
|
||
}
|
||
|
||
// Called from the outcome path: a repair that did not bring the system back
|
||
// earns the heavier rung next time; one that worked drops the reason back to
|
||
// light so a later, unrelated fault is not met with a full restart.
|
||
function note_escalation_outcome(reason, outcome) {
|
||
if (reason == null || reason == "") return;
|
||
if (outcome == "failed")
|
||
escalation_level[reason] = ESCALATION_HEAVY;
|
||
else if (outcome == "fixed")
|
||
delete escalation_level[reason];
|
||
}
|
||
|
||
// ─── Recovery watch: registration ─────────────────────────────────────────────
|
||
// A repair that goes through safe_proxy_restart() is spawned into the
|
||
// background, so its return value says only that the restart was launched. The
|
||
// only honest evidence that it worked is the system recovering afterwards —
|
||
// which the controller already publishes as PROXY_UP / DNS_UP.
|
||
//
|
||
// A healer therefore registers what it expects to see. settle_recovery(), below
|
||
// ai_heal_report(), turns the arrival of that fact into a `fixed` report and a
|
||
// watch left open past its deadline into a `failed` one.
|
||
let recovery_watches = {};
|
||
|
||
// Two fast ticks plus margin: long enough for a restarted sing-box to answer,
|
||
// short enough that a failure is reported while it is still actionable.
|
||
const RECOVERY_DEADLINE = 45;
|
||
|
||
// `reason` ties the watch back to the escalation ladder: settling decides
|
||
// whether that reason's next attempt stays light or goes heavy.
|
||
function watch_recovery(key, incident, reason) {
|
||
recovery_watches[key] = {
|
||
incident: incident,
|
||
reason: reason,
|
||
deadline: time() + RECOVERY_DEADLINE
|
||
};
|
||
}
|
||
|
||
// The 30s floor is the original restart debounce: procd emits several stop
|
||
// events for one crash, and each would otherwise queue its own restart.
|
||
function heal_singbox_stopped(ev) {
|
||
let now = time();
|
||
if (now - last_restart_time < 30) return;
|
||
last_restart_time = now;
|
||
|
||
let cfg = settings();
|
||
if (cfg.recovery_bypass == "1") return;
|
||
if (check_auto_resume_pause()) return;
|
||
if (controller.check_tachyon_cli_running()) return;
|
||
|
||
let list_update_pid = trim(fs.readfile("/var/run/tachyon_list_update.pid") || "");
|
||
if (process_running(list_update_pid, "ucode")) return;
|
||
|
||
log_message("sing-box is stopped (" + as_string(ev.payload.reason || "health check") + "). Restarting Tachyon...", "warn");
|
||
increment_reconnect_count();
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.notify_crash != "0") {
|
||
send_telegram_notification("⚠️ *Watchdog:* sing-box остановлен. Перезапускаю службы Tachyon...");
|
||
}
|
||
// This healer sends its own notification above and deliberately did not file
|
||
// an incident, so it does not start one now. It does register the watch: a
|
||
// restart that never brings the proxy back is worth a `failed` report, and
|
||
// that report is the only thing here that is new.
|
||
//
|
||
// Pinned to the light rung: sing-box is not running, so there is nothing to
|
||
// escalate over. Starting the service is the whole repair, and tearing down
|
||
// nftables and routing would not make a stopped process start any better.
|
||
if (safe_proxy_restart("singbox_stopped", ESCALATION_LIGHT)) {
|
||
// sing-box coming back is what the proxy and DNS probes are measuring,
|
||
// so their failures during the restart are this repair's own doing.
|
||
singbox_repair_until = time() + SUPPRESSION_DEADLINE;
|
||
watch_recovery("proxy", {
|
||
type: "singbox_stopped",
|
||
description: "sing-box остановлен (" + as_string(ev.payload.reason || "health check") + ")",
|
||
resolution: "Выполнен перезапуск служб Tachyon"
|
||
}, "singbox_stopped");
|
||
}
|
||
}
|
||
|
||
|
||
// ─── AI Watchdog Self-Healing Matrix ──────────────────────────────────────────
|
||
let ai_incidents_count = 0;
|
||
let last_ai_incident = null;
|
||
|
||
function ai_export_status() {
|
||
if (last_ai_incident == null || (time() - last_ai_incident.timestamp >= 300)) controller.note_healthy();
|
||
// Streaks live in the controller state (probe counters). They are the
|
||
// evidence AI Doctor reads to see instability that a point-in-time check
|
||
// cannot: without them the status file always reported zero failures.
|
||
let st = controller.state || {};
|
||
let status_obj = {
|
||
timestamp: time(),
|
||
status: is_healthy ? "healthy" : "repaired",
|
||
ai_active: true,
|
||
incidents_resolved_total: ai_incidents_count,
|
||
last_incident: last_ai_incident,
|
||
wan_fail_streak: int(st.wan_fail_streak || 0),
|
||
proxy_fail_streak: int(st.proxy_consecutive_fails || 0),
|
||
dns_fail_streak: int(st.dns_fail_streak || 0),
|
||
healthy_streak: int(st.healthy_streak || 0),
|
||
active_repairs: (time() < wan_repair_until || time() < singbox_repair_until) ? "active" : "none"
|
||
};
|
||
fs.writefile("/tmp/tachyon_ai_status.json", sprintf("%J\n", status_obj));
|
||
}
|
||
|
||
// `outcome` is what actually happened, not what was attempted:
|
||
//
|
||
// fixed the repair ran and the fault is gone
|
||
// pending the repair ran; the system has not recovered yet
|
||
// failed the repair ran and the fault outlived the deadline
|
||
// skipped the repair did not run (rate limit, lock, guard)
|
||
// warn an observation reported for the record, with no repair
|
||
//
|
||
// Repairs whose effect is asynchronous (anything going through
|
||
// safe_proxy_restart) cannot know their outcome at call time, so they report
|
||
// `pending` and let the recovery watch below settle it.
|
||
function ai_heal_report(event_type, description, resolution, outcome) {
|
||
let status_code = as_string(outcome || "fixed");
|
||
|
||
ai_incidents_count++;
|
||
controller.note_incident();
|
||
last_ai_incident = {
|
||
type: event_type,
|
||
description: description,
|
||
resolution: resolution,
|
||
outcome: status_code,
|
||
timestamp: time()
|
||
};
|
||
|
||
let log_msg = sprintf("🤖 [AI Watchdog] %s. Action taken: %s [%s]",
|
||
description, resolution, status_code);
|
||
log_message(log_msg, status_code == "failed" ? "err" : "warn");
|
||
|
||
// A repair still in flight is not news: notifying on `pending` would send
|
||
// one message on the attempt and a second on the outcome. The settled
|
||
// report carries the whole story, so only that one is sent.
|
||
// Repairs that were skipped (rate limit, lock, guard) perform no action,
|
||
// so notifying on `skipped` would flood notifications on every probe tick.
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (status_code != "pending" && status_code != "skipped" &&
|
||
tcfg.enabled == "1" && tcfg.bot_token && tcfg.admin_ids && tcfg.notify_crash != "0") {
|
||
let icon = status_code == "failed" ? "❌" : "🔧";
|
||
let verdict = status_code == "failed" ? "*Не помогло:*" : "*Авто-решение:*";
|
||
let tg_msg = sprintf("🤖 *[ИИ-Автомеханик Tachyon]*\n⚠️ *Проблема:* %s\n%s %s %s",
|
||
description, icon, verdict, resolution);
|
||
send_telegram_notification(tg_msg, "ai_heal_" + as_string(event_type) + "_" + status_code, 900);
|
||
}
|
||
|
||
ai_export_status();
|
||
}
|
||
|
||
// ─── Recovery watch: settling ─────────────────────────────────────────────────
|
||
// The registration half lives above heal_singbox_stopped, its first caller.
|
||
// Settling has to live here instead, below ai_heal_report — ucode captures a
|
||
// closure's upvalues at creation, so a function declared above ai_heal_report
|
||
// could not call it.
|
||
//
|
||
// Settling reports the outcome the caller observed, reusing the description and
|
||
// resolution the healer already wrote so the two reports read as one story.
|
||
function settle_recovery(key, outcome) {
|
||
let watch = recovery_watches[key];
|
||
if (watch == null) return false;
|
||
delete recovery_watches[key];
|
||
|
||
// Before reporting: feed the outcome back into the ladder, so the next
|
||
// attempt on this reason knows whether the lighter rung was enough.
|
||
note_escalation_outcome(watch.reason, outcome);
|
||
|
||
let incident = watch.incident;
|
||
ai_heal_report(incident.type, incident.description, incident.resolution, outcome);
|
||
return true;
|
||
}
|
||
|
||
function settle_expired_recoveries() {
|
||
let now = time();
|
||
for (let key in keys(recovery_watches)) {
|
||
if (recovery_watches[key].deadline <= now)
|
||
settle_recovery(key, "failed");
|
||
}
|
||
}
|
||
|
||
// Recovery facts are already published by the probes; these subscribers are the
|
||
// only thing that was missing.
|
||
function note_proxy_recovered(ev) {
|
||
// A working proxy means sing-box is back, so its claim is over regardless of
|
||
// whether a watch was open.
|
||
singbox_repair_until = 0;
|
||
settle_recovery("proxy", "fixed");
|
||
}
|
||
|
||
function note_dns_recovered(ev) {
|
||
settle_recovery("dns", "fixed");
|
||
}
|
||
|
||
function note_nfqueue_recovered(ev) {
|
||
settle_recovery("nfqueue", "fixed");
|
||
}
|
||
|
||
// Guard: skip if a tachyon reload is already in progress (prevents concurrent reload_firewall races)
|
||
function is_reload_in_progress() {
|
||
return fs.stat("/var/run/tachyon.reload.lock") != null
|
||
|| controller.check_tachyon_cli_running();
|
||
}
|
||
|
||
// `force_level` pins the rung instead of consulting the ladder. Only
|
||
// heal_singbox_stopped uses it: a process that is not running is started, not
|
||
// escalated over.
|
||
function safe_proxy_restart(reason, force_level) {
|
||
let now = time();
|
||
if (now - proxy_restart_window_start > 600) {
|
||
proxy_restart_count = 0;
|
||
proxy_restart_window_start = now;
|
||
}
|
||
// The rate limit and the lock below gate both rungs. A light restart is
|
||
// cheaper, not free — three of them in ten minutes is still a proxy that is
|
||
// not going to be fixed by a fourth.
|
||
if (proxy_restart_count >= 3) {
|
||
log_message("Proxy restart rate limit: " + as_string(proxy_restart_count) + " in 10 min, skipping (" + reason + ")", "warn");
|
||
return false;
|
||
}
|
||
if (fs.stat(PROXY_RESTART_LOCK) != null) {
|
||
let lock_content = trim(fs.readfile(PROXY_RESTART_LOCK) || "0");
|
||
let lock_age = now - int(lock_content);
|
||
if (lock_age < 300) {
|
||
log_message("Proxy restart lock exists (age " + as_string(lock_age) + "s), skipping (" + reason + ")", "warn");
|
||
return false;
|
||
}
|
||
log_message("Proxy restart lock stale (age " + as_string(lock_age) + "s), removing", "warn");
|
||
remove_file(PROXY_RESTART_LOCK);
|
||
}
|
||
write_state_file(PROXY_RESTART_LOCK, as_string(now), "proxy restart lock");
|
||
proxy_restart_count++;
|
||
// The proxy port can change across a restart, so the controller's cached
|
||
// lookup is invalidated here rather than after the fact.
|
||
controller.forget_proxy_port();
|
||
|
||
let level = (force_level != null && force_level != "") ? force_level : next_escalation(reason);
|
||
let lock_path = shell_quote(PROXY_RESTART_LOCK);
|
||
|
||
if (level == ESCALATION_LIGHT) {
|
||
log_message("Restarting sing-box service only (" + reason + ")", "warn");
|
||
// Sequential stop/start rather than `restart`: lifecycle.uc drives the
|
||
// service the same way, and a stop that fails must not be followed by a
|
||
// start that races it.
|
||
bg_system("/etc/init.d/sing-box stop >/dev/null 2>&1; " +
|
||
"/etc/init.d/sing-box start >/dev/null 2>&1; rm -f " + lock_path);
|
||
} else {
|
||
log_message("Escalating to full Tachyon restart (" + reason + "): the lighter restart did not restore service", "warn");
|
||
// The lock is released after the restart, not alongside it. This line used
|
||
// to read `restart & rm -f lock &`, which backgrounded both halves at once
|
||
// and dropped the lock immediately — so a second escalation could start
|
||
// while the first restart was still running, which is the one thing the
|
||
// lock exists to prevent.
|
||
bg_system("/etc/init.d/tachyon restart >/dev/null 2>&1; rm -f " + lock_path);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ─── Reload dedup: prevent multiple reload_firewall per cycle ─────────────────
|
||
function safe_reload_firewall() {
|
||
if (fs.stat("/var/run/tachyon/starting") != null || fs.stat("/var/run/tachyon/reloading") != null)
|
||
return;
|
||
let now = time();
|
||
let min_interval = 120;
|
||
if (now - last_reload_time < min_interval) return;
|
||
last_reload_time = now;
|
||
bg_system("/usr/bin/tachyon reload_firewall");
|
||
}
|
||
|
||
// ─── Repairs ──────────────────────────────────────────────────────────────────
|
||
// Each function below is the *action* half of a former ai_heal_* function: the
|
||
// detection it used to perform now happens in the controller and arrives as a
|
||
// fact. The repair bodies themselves are unchanged, so thresholds, messages and
|
||
// side effects stay identical.
|
||
|
||
function heal_nftables(ev) {
|
||
ai_heal_report(
|
||
"nftables",
|
||
"Таблица правил nftables очищена или повреждена",
|
||
"Выполнена быстрая регенерация правил TachyonTable и цепочки TPROXY",
|
||
"fixed"
|
||
);
|
||
safe_reload_firewall();
|
||
}
|
||
|
||
function heal_qos(ev) {
|
||
ai_heal_report(
|
||
"qos_priority",
|
||
"Правила Игрового & Голосового QoS Ускорителя не найдены в nftables",
|
||
"Применены высокоприоритетные метки DSCP EF (0x2e) для Voice/RTC и DSCP AF41 (0x22) для Gaming",
|
||
"fixed"
|
||
);
|
||
safe_reload_firewall();
|
||
}
|
||
|
||
function heal_uncached_rulesets_detour() {
|
||
let cfg = settings();
|
||
if (common.bool_option(cfg, "download_lists_via_proxy", false))
|
||
return false;
|
||
|
||
let has_rulesets = false;
|
||
let proxy_section_name = "";
|
||
|
||
uci_core.foreach(CONFIG_NAME, "section", function(s) {
|
||
if (!common.bool_option(s, "enabled", true)) return;
|
||
let act = common.option(s, "action", "");
|
||
if (act != "bypass" && act != "block" && act != "dns" && act != "") {
|
||
if (proxy_section_name == "")
|
||
proxy_section_name = s[".name"];
|
||
}
|
||
let lists = connections.community_lists(s);
|
||
let custom_rulesets = connections.rule_sets(s);
|
||
if (length(lists) > 0 || length(custom_rulesets) > 0)
|
||
has_rulesets = true;
|
||
});
|
||
|
||
if (has_rulesets && proxy_section_name != "") {
|
||
log_message("Watchdog: DNS stalled with remote rulesets on direct WAN; auto-enabling download_lists_via_proxy through " + proxy_section_name, "warn");
|
||
system("/sbin/uci set tachyon.settings.download_lists_via_proxy='1' >/dev/null 2>&1");
|
||
system("/sbin/uci set tachyon.settings.download_lists_via_proxy_section=" + shell_quote(proxy_section_name) + " >/dev/null 2>&1");
|
||
system("/sbin/uci commit tachyon >/dev/null 2>&1");
|
||
bg_system("/usr/bin/tachyon list_update");
|
||
ai_heal_report(
|
||
"ruleset_proxy_detour",
|
||
"DNS stalled during remote ruleset fetch on direct WAN",
|
||
"Включена загрузка списков через прокси-секцию " + proxy_section_name + " и запущено фоновое обновление",
|
||
"fixed"
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Threshold 3 is the original ai_heal_dns() streak: two isolated failures are
|
||
// noise, three in a row is a stall. The streak reset after acting prevents the
|
||
// in-flight restart from tripping the same threshold on the next tick.
|
||
function heal_dns_stall(ev) {
|
||
if (settings().recovery_bypass == "1") return;
|
||
if (int(ev.payload.streak) < 3) return;
|
||
if (suppressed_by_root_cause("heal_dns_stall")) return;
|
||
|
||
controller.reset_dns_streak();
|
||
|
||
let detour_healed = heal_uncached_rulesets_detour();
|
||
log_message("Watchdog: DNS stalled after 3 attempts, soft-reloading proxy runtime", "warn");
|
||
|
||
let incident = {
|
||
type: "dns",
|
||
description: detour_healed ? "Зависание DNS из-за блокировки прямой загрузки списков правил" : "DNS resolution stalled on sing-box (port 53)",
|
||
resolution: detour_healed ? "Включена загрузка списков через прокси и выполнен мягкий перезапуск" : "Soft-reloaded proxy runtime safely without breaking active TCP/RDP connections"
|
||
};
|
||
|
||
// The restart is asynchronous, so `true` means only that it was launched.
|
||
// The watch on DNS_UP decides whether it worked.
|
||
if (!safe_proxy_restart("dns_stalled")) {
|
||
ai_heal_report(incident.type, incident.description,
|
||
"Перезапуск не выполнен: лимит частоты или активная блокировка", "skipped");
|
||
return;
|
||
}
|
||
watch_recovery("dns", incident, "dns_stalled");
|
||
}
|
||
|
||
// Only a proxy that fails while the uplink itself works is worth restarting
|
||
// over: when direct access is down too, the fault is upstream and a restart
|
||
// would drop live connections for nothing.
|
||
function heal_proxy_connectivity(ev) {
|
||
if (settings().recovery_bypass == "1") return;
|
||
// When dedicated proxy health monitoring is enabled, heal_proxy_health owns
|
||
// the threshold, config validation, and reporting. heal_proxy_connectivity
|
||
// acts as fallback when ai_proxy_health_enabled is "0".
|
||
if (settings().ai_proxy_health_enabled != "0") return;
|
||
if (!ev.payload.direct_ok) return;
|
||
// Two consecutive failures before acting: a single brief proxy-node timeout
|
||
// (e.g. URLTest selecting a slow node for 5–10s) must not wipe cache.db and
|
||
// restart sing-box. The heal_proxy_health subscriber handles single-failure
|
||
// recovery via the threshold it already carries.
|
||
if (int(ev.payload.streak) < 2) return;
|
||
if (suppressed_by_root_cause("heal_proxy_connectivity")) return;
|
||
|
||
let proxy_addr = (ev.payload.host ? ev.payload.host : "127.0.0.1") + ":" + as_string(ev.payload.port);
|
||
let incident = {
|
||
type: "proxy",
|
||
description: "Зависание или неполный отклик прокси-порту sing-box (" + proxy_addr + ")",
|
||
resolution: "Выполнен перезапуск sing-box"
|
||
};
|
||
|
||
// Reporting used to happen here, before the restart, so a rate-limited
|
||
// attempt still announced a repair that never ran.
|
||
if (!safe_proxy_restart("proxy_connectivity")) {
|
||
ai_heal_report(incident.type, incident.description,
|
||
"Очищена база cache.db; перезапуск пропущен по лимиту частоты", "skipped");
|
||
return;
|
||
}
|
||
controller.reset_proxy_consecutive();
|
||
watch_recovery("proxy", incident, "proxy_connectivity");
|
||
}
|
||
|
||
// ─── Subnet cache restore: /etc/tachyon/rulesets/ → /tmp/sing-box/rulesets/ ──
|
||
function ai_heal_subnet_cache() {
|
||
let etc_dir = "/etc/tachyon/rulesets";
|
||
let tmp_dir = "/tmp/sing-box/rulesets";
|
||
|
||
let dir = fs.opendir(etc_dir);
|
||
if (!dir) return;
|
||
|
||
let restored = [];
|
||
let entry;
|
||
while ((entry = dir.read()) != null) {
|
||
if (!match(entry, /\.(srs|lst|json)$/)) continue;
|
||
let tmp_path = tmp_dir + "/" + entry;
|
||
let etc_path = etc_dir + "/" + entry;
|
||
let tmp_st = fs.stat(tmp_path);
|
||
let etc_st = fs.stat(etc_path);
|
||
if (!helpers.file_is_usable(etc_path, 50)) continue;
|
||
if (helpers.file_is_usable(tmp_path, 50)) continue;
|
||
// /tmp file missing or empty, restore from persistent storage
|
||
let content = fs.readfile(etc_path);
|
||
if (content == null || content == "") continue;
|
||
command_success_from_args(["mkdir", "-p", tmp_dir]);
|
||
if (fs.writefile(tmp_path, content) != null) {
|
||
push(restored, entry);
|
||
}
|
||
}
|
||
dir.close();
|
||
|
||
if (length(restored) > 0) {
|
||
let names = join(", ", restored);
|
||
log_message("Subnet cache restored from /etc to /tmp: " + names, "info");
|
||
}
|
||
}
|
||
|
||
// ─── Check nft sets are populated (community subnets) ─────────────────────────
|
||
function heal_community_subnet_sets(ev) {
|
||
if (settings().recovery_bypass == "1") return;
|
||
|
||
for (let set_name in common.array_or_empty(ev.payload.sets))
|
||
log_message("Community subnet set " + set_name + " is empty — will repopulate", "warn");
|
||
|
||
ai_heal_subnet_cache();
|
||
ai_heal_report(
|
||
"nft_community_sets",
|
||
"Пустые nftables sets подсетей (community) — данные не были загружены при reload",
|
||
"Восстановлены nftables sets из persistent кеша (/etc/tachyon/rulesets/)",
|
||
"fixed"
|
||
);
|
||
bg_system("/usr/bin/tachyon reload_firewall");
|
||
}
|
||
|
||
|
||
// ─── TPROXY port liveness ─────────────────────────────────────────────────────
|
||
function heal_tproxy_port(ev) {
|
||
if (settings().recovery_bypass == "1") return;
|
||
|
||
ai_heal_report(
|
||
"tproxy_port",
|
||
sprintf("TPROXY порт %d не слушает — правила перехвата трафика не работают", int(ev.payload.port)),
|
||
"Выполнен reload_firewall для восстановления TPROXY правил",
|
||
"fixed"
|
||
);
|
||
safe_reload_firewall();
|
||
}
|
||
|
||
function heal_wan_and_gateway(ev) {
|
||
if (settings().recovery_bypass == "1") return;
|
||
if (settings().ai_heal_wan_enabled == "0") return;
|
||
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.notify_crash != "0") {
|
||
send_telegram_notification("⚠️ *Watchdog:* WAN/Gateway проблема. Попытка обновления сетевого интерфейса...", "heal_wan_and_gateway", 600);
|
||
}
|
||
// Claimed before the interface goes down, not after: ifdown itself is what
|
||
// makes the proxy and DNS probes fail, and those facts arrive while ifup is
|
||
// still running.
|
||
wan_repair_until = time() + SUPPRESSION_DEADLINE;
|
||
// The address is still there but the default route vanished: a graceful
|
||
// renew re-applies the routes without dropping the interface (issue #31).
|
||
// Always attempt graceful renew first before falling back to ifdown/ifup.
|
||
let cmd = "/sbin/ubus call network.interface.wan renew >/dev/null 2>&1; sleep 6; " +
|
||
"(ip -4 route show default 2>/dev/null | grep -q default || " +
|
||
"(/sbin/ifdown wan >/dev/null 2>&1 && /sbin/ifup wan >/dev/null 2>&1))";
|
||
bg_system(cmd);
|
||
}
|
||
|
||
// The uplink answering again is what ends the WAN repair. probe_wan publishes
|
||
// this on every pass, so the claim is released at the first healthy tick rather
|
||
// than waiting out SUPPRESSION_DEADLINE.
|
||
function note_wan_recovered(ev) {
|
||
wan_repair_until = 0;
|
||
}
|
||
|
||
function heal_subscriptions(ev) {
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.notify_crash != "0") {
|
||
send_telegram_notification("⚠️ *Watchdog:* Подписка недоступна (HTTP " + int(ev.payload.code) + "). Обновите подписку вручную.", "heal_subscriptions", 1800);
|
||
}
|
||
}
|
||
|
||
function heal_uci_config(ev) {
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.notify_crash != "0") {
|
||
send_telegram_notification("⚠️ *Watchdog:* Конфигурация Tachyon повреждена! Восстановление из backup...", "heal_uci_config", 1800);
|
||
}
|
||
let backup = fs.readfile("/etc/backup/tachyon_config");
|
||
if (backup == null || backup == "") return;
|
||
|
||
let valid = command_success_from_args([ "uci", "-c", "/etc/config", "valid", CONFIG_NAME ]) ||
|
||
command_success_from_args([ "/sbin/uci", "valid", CONFIG_NAME ]);
|
||
if (!valid) {
|
||
log_message("Backup config also invalid, skipping restore", "warn");
|
||
return;
|
||
}
|
||
let tmp = "/etc/config/tachyon.restore-tmp";
|
||
if (fs.writefile(tmp, backup) != null) {
|
||
fs.rename(tmp, "/etc/config/tachyon");
|
||
system("chmod 0600 /etc/config/tachyon 2>/dev/null");
|
||
// Pinned heavy: the config file on disk was just replaced, and only a
|
||
// full restart re-reads it into every part of the stack.
|
||
safe_proxy_restart("uci_config_restore", ESCALATION_HEAVY);
|
||
}
|
||
}
|
||
|
||
|
||
// ─── Config validation: sing-box check ────────────────────────────────────────
|
||
function validate_singbox_config() {
|
||
if (settings().ai_config_validation_enabled == "0") return true;
|
||
let st = fs.stat("/etc/sing-box/config.json");
|
||
if (!st || st.size == 0) return false;
|
||
let content = fs.readfile("/etc/sing-box/config.json");
|
||
if (!content) return false;
|
||
try {
|
||
let obj = json(content);
|
||
return type(obj) == "object" && type(obj.outbounds) == "array";
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ─── Proxy Health Monitor ─────────────────────────────────────────────────────
|
||
// The second proxy subscriber: where heal_proxy_connectivity() reacts to a
|
||
// single failure with a working uplink, this one waits for a configurable run
|
||
// of failures and validates the sing-box config before restarting.
|
||
function heal_proxy_health(ev) {
|
||
if (settings().ai_proxy_health_enabled == "0") return;
|
||
if (settings().recovery_bypass == "1") return;
|
||
// Only a proxy that fails while direct access works is worth restarting
|
||
// over: when direct access is down too, the fault is upstream/WAN and a
|
||
// restart would drop live connections for nothing.
|
||
if (!ev.payload.direct_ok) return;
|
||
|
||
let threshold = int(settings().ai_proxy_health_fail_threshold || "3");
|
||
let fails = int(ev.payload.streak);
|
||
if (fails < threshold) return;
|
||
if (suppressed_by_root_cause("heal_proxy_health")) return;
|
||
|
||
let incident = {
|
||
type: "proxy_health",
|
||
description: sprintf("Прокси не отвечает на запросы проверки доступности (%d сбоя подряд, порт %s)", fails, as_string(ev.payload.port)),
|
||
resolution: "Выполнен перезапуск sing-box"
|
||
};
|
||
|
||
// A restart on a config sing-box will refuse to load leaves the proxy down
|
||
// for good, so validation gates the restart. It now also gates the report:
|
||
// a refused restart is not a repair.
|
||
if (settings().ai_config_validation_enabled != "0" && !validate_singbox_config()) {
|
||
log_message("sing-box config validation failed before proxy restart", "err");
|
||
ai_heal_report(incident.type, incident.description,
|
||
"Перезапуск отменён: конфигурация sing-box не проходит валидацию", "skipped");
|
||
return;
|
||
}
|
||
if (!safe_proxy_restart("proxy_health")) {
|
||
ai_heal_report(incident.type, incident.description,
|
||
"Перезапуск пропущен по лимиту частоты", "skipped");
|
||
return;
|
||
}
|
||
controller.reset_proxy_consecutive();
|
||
watch_recovery("proxy", incident, "proxy_health");
|
||
}
|
||
|
||
// ─── DNS Continuous Check ─────────────────────────────────────────────────────
|
||
// The second DNS subscriber: restores dnsmasq's own configuration rather than
|
||
// restarting the proxy, so it is complementary to heal_dns_stall().
|
||
function heal_dns_continuous(ev) {
|
||
if (settings().ai_dns_continuous_enabled == "0") return;
|
||
if (int(ev.payload.consecutive) < 3) return;
|
||
if (suppressed_by_root_cause("heal_dns_continuous")) return;
|
||
|
||
// This repair only ever sets noresolv to the one value it wants, so on the
|
||
// second and later failures it rewrote a setting that already held that
|
||
// value — and reloaded dnsmasq anyway, which drops its cache and re-reads
|
||
// every config file for nothing. DNS failing three times running is exactly
|
||
// when it is likely to fail a fourth, so the useless reload arrived
|
||
// precisely when the resolver could least afford it.
|
||
//
|
||
// Reading first makes agreement a no-op and leaves divergence repaired.
|
||
let current = trim(as_string(uci_core.get("dhcp.@dnsmasq[0].noresolv") || ""));
|
||
if (current == "1") {
|
||
// The counter still resets: the fact was handled, by establishing that
|
||
// dnsmasq already holds the configuration this healer wants. The DNS
|
||
// failure therefore has another cause, and re-running this repair on
|
||
// every tick would keep finding the same thing.
|
||
controller.reset_dns_consecutive();
|
||
ai_heal_report(
|
||
"dns_continuous",
|
||
"DNS resolution failed 3 times consecutively",
|
||
"dnsmasq уже настроен (noresolv=1), перезагрузка не требуется",
|
||
"skipped"
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Synchronous repair: uci and the dnsmasq reload both return before this
|
||
// function does, so the outcome is known here and needs no recovery watch.
|
||
system("/sbin/uci set dhcp.@dnsmasq[0].noresolv='1' >/dev/null 2>&1");
|
||
system("/sbin/uci commit dhcp >/dev/null 2>&1");
|
||
let rc = system("/etc/init.d/dnsmasq reload >/dev/null 2>&1");
|
||
controller.reset_dns_consecutive();
|
||
|
||
ai_heal_report(
|
||
"dns_continuous",
|
||
"DNS resolution failed 3 times consecutively",
|
||
"Restoring dnsmasq config and reloading",
|
||
rc == 0 ? "fixed" : "failed"
|
||
);
|
||
}
|
||
|
||
// ─── AI nfqueue/zapret Runtime Recovery ──────────────────────────────────────
|
||
// When nfqws/nfqws2 processes die (crash, OOM, kill), DPI bypass stops working.
|
||
// The simplest fix is a full tachyon restart which respawns everything.
|
||
function heal_nfqueue_stopped(ev) {
|
||
if (settings().ai_nfqueue_health_enabled == "0") return;
|
||
if (suppressed_by_root_cause("heal_nfqueue_stopped")) return;
|
||
|
||
let running = int(ev.payload.running) || 0;
|
||
let expected = int(ev.payload.expected) || 0;
|
||
|
||
if (running >= expected) return;
|
||
|
||
let dir = as_string(ev.payload.dir || "");
|
||
let zapret2_uc = LIB_DIR + "/providers/zapret2/runtime.uc";
|
||
let zapret_uc = LIB_DIR + "/providers/zapret/runtime.uc";
|
||
let revived = false;
|
||
|
||
if (index(dir, "zapret2") >= 0 && fs.stat(zapret2_uc) != null) {
|
||
log_message("Auto-healing zapret2 workers via start-runtime", "warn");
|
||
revived = (command_status(sprintf("ucode -L %s %s start-runtime >/dev/null 2>&1", shell_quote(LIB_DIR), shell_quote(zapret2_uc))) == 0);
|
||
} else if (fs.stat(zapret_uc) != null) {
|
||
log_message("Auto-healing zapret workers via start-runtime", "warn");
|
||
revived = (command_status(sprintf("ucode -L %s %s start-runtime >/dev/null 2>&1", shell_quote(LIB_DIR), shell_quote(zapret_uc))) == 0);
|
||
}
|
||
|
||
if (revived) {
|
||
ai_heal_report("nfqueue_health", sprintf("Respawned workers for %s (%d/%d alive)", dir, running, expected), "Workers respawned directly", "fixed");
|
||
return;
|
||
}
|
||
|
||
let incident = {
|
||
type: "nfqueue_health",
|
||
description: sprintf("nfqueue/zapret runtime: %d/%d processes alive (%s)",
|
||
running, expected, dir),
|
||
resolution: "Restarting tachyon to respawn nfqws/nfqws2"
|
||
};
|
||
|
||
if (!safe_proxy_restart("nfqueue_health")) {
|
||
ai_heal_report(incident.type, incident.description,
|
||
"Restart skipped due to rate limit", "skipped");
|
||
return;
|
||
}
|
||
watch_recovery("nfqueue", incident, "nfqueue_health");
|
||
}
|
||
|
||
// ─── AI Empty Sections Recovery ─────────────────────────────────────────────
|
||
function heal_empty_sections(ev) {
|
||
let recovered = common.array_or_empty(ev.payload.sections);
|
||
if (length(recovered) == 0) return;
|
||
|
||
for (let name in recovered) {
|
||
log_message("Empty proxy section '" + name + "' — triggering subscription update", "warn");
|
||
bg_system("/usr/bin/tachyon subscription_update_async " + common.shell_quote(name));
|
||
}
|
||
|
||
ai_heal_report(
|
||
"empty_proxy_sections",
|
||
"Empty proxy sections detected: " + join(", ", recovered),
|
||
"Auto-triggering subscription update for " + as_string(length(recovered)) + " section(s)",
|
||
"fixed"
|
||
);
|
||
}
|
||
|
||
// ─── DNS Loop Recovery (dead proxy + DNS detour = total DNS loss) ────────────
|
||
let DNS_RECOVERY_STATE_FILE = "/var/run/tachyon/dns-detour-recovery.json";
|
||
let dns_recovery_active = false;
|
||
|
||
|
||
function read_dns_recovery_state() {
|
||
let data = common.read_json_file(DNS_RECOVERY_STATE_FILE);
|
||
return common.object_or_empty(data);
|
||
}
|
||
|
||
function write_dns_recovery_state(state) {
|
||
common.write_json_file(DNS_RECOVERY_STATE_FILE, state);
|
||
}
|
||
|
||
function remove_dns_recovery_state() {
|
||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||
try { fs.unlink(DNS_RECOVERY_STATE_FILE); } catch(e) {}
|
||
}
|
||
|
||
// Kept as a self-contained check rather than a fact subscriber: it needs the
|
||
// *current* resolver state at the moment it acts (it re-tests DNS to decide
|
||
// whether to re-enable the detour), so acting on a fact observed earlier in the
|
||
// tick could re-enable a detour against a resolver that has since died.
|
||
function ai_heal_dns_loop() {
|
||
if (settings().ai_dns_loop_heal_enabled == "0") return;
|
||
if (is_reload_in_progress()) return;
|
||
let now = time();
|
||
|
||
let cfg = settings();
|
||
let detour_enabled = common.bool_option(cfg, "dns_detour_enabled", false);
|
||
if (!detour_enabled) return;
|
||
|
||
let detour_section = common.option(cfg, "dns_detour_section", "");
|
||
if (detour_section == "") return;
|
||
|
||
if (controller.is_dns_working()) {
|
||
// DNS works — if we were in recovery, try to restore
|
||
let recovery = read_dns_recovery_state();
|
||
if (recovery.phase == "detour_disabled") {
|
||
let reenable_cooldown = recovery.ts ? (now - int(recovery.ts)) : 0;
|
||
if (reenable_cooldown < 300) return;
|
||
if (controller.is_dns_working()) {
|
||
log_message("DNS loop recovery: DNS works, re-enabling DNS detour section", "info");
|
||
system("/sbin/uci set tachyon.settings.dns_detour_enabled='1' >/dev/null 2>&1");
|
||
system("/sbin/uci commit tachyon >/dev/null 2>&1");
|
||
// Pinned heavy for the same reason as uci_config_restore: the
|
||
// UCI change above only takes effect through a full restart.
|
||
safe_proxy_restart("dns_loop_recovery", ESCALATION_HEAVY);
|
||
remove_dns_recovery_state();
|
||
ai_heal_report(
|
||
"dns_loop_recovery",
|
||
"DNS recovered with detour re-enabled",
|
||
"Restored DNS detour section " + detour_section,
|
||
"fixed"
|
||
);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// DNS is dead — check if detour section has outbounds
|
||
let recovery = read_dns_recovery_state();
|
||
if (recovery.phase == "detour_disabled") {
|
||
// Already in recovery — just wait and retry
|
||
log_message("DNS loop recovery: DNS still dead, retrying subscription update", "info");
|
||
bg_system("/usr/bin/tachyon subscription_update_async " + common.shell_quote(detour_section));
|
||
return;
|
||
}
|
||
|
||
let cache_path = "/var/run/tachyon/section-cache/" + detour_section + ".json";
|
||
let cache = common.object_or_empty(common.read_json_file(cache_path));
|
||
let servers = common.array_or_empty(cache.servers);
|
||
let urls = common.array_or_empty(cache.urls);
|
||
let usable = length(servers) + length(urls);
|
||
|
||
let dns_fails = controller.dns_consecutive_fails ? controller.dns_consecutive_fails() : 0;
|
||
if (usable > 0 && dns_fails < 3) {
|
||
// Section has outbounds but DNS failure is transient (< 3 consecutive fails)
|
||
return;
|
||
}
|
||
|
||
// Detour section empty OR deadlocked with persistent DNS failure — disable DNS detour to recover
|
||
log_message("DNS loop detected: DNS detour section '" + detour_section + "' causing DNS failure (usable: " + as_string(usable) + ", dns_fails: " + as_string(dns_fails) + "), disabling DNS detour to recover", "warn");
|
||
system("/sbin/uci set tachyon.settings.dns_detour_enabled='0' >/dev/null 2>&1");
|
||
system("/sbin/uci commit tachyon >/dev/null 2>&1");
|
||
safe_proxy_restart("dns_loop_disable", ESCALATION_HEAVY);
|
||
write_dns_recovery_state({ phase: "detour_disabled", section: detour_section, ts: now });
|
||
|
||
// Trigger subscription update for the detour section
|
||
bg_system("/usr/bin/tachyon subscription_update_async " + common.shell_quote(detour_section));
|
||
|
||
ai_heal_report(
|
||
"dns_loop_detected",
|
||
"DNS loop: detour section '" + detour_section + "' causing DNS failure (" + as_string(usable) + " outbounds, " + as_string(dns_fails) + " consecutive fails)",
|
||
"Temporarily disabled DNS detour to restore DNS, triggered subscription reload",
|
||
"fixed"
|
||
);
|
||
}
|
||
|
||
// ─── Metrics helpers ──────────────────────────────────────────────────────────
|
||
// Defined before export_metrics to ensure forward-reference safety in ucode
|
||
// closure/timer contexts (see issue #14).
|
||
function average_latency(history) {
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let entry in history) {
|
||
if (entry.ok) {
|
||
sum += entry.ms;
|
||
count++;
|
||
}
|
||
}
|
||
return count > 0 ? int(sum / count) : -1;
|
||
}
|
||
|
||
// ─── Metrics export (normal tier) ─────────────────────────────────────────────
|
||
function export_metrics() {
|
||
if (settings().ai_metrics_enabled == "0") return;
|
||
|
||
let now = time();
|
||
let metrics_path = "/tmp/tachyon_metrics.json";
|
||
let data = { hours: [] };
|
||
let existing = fs.readfile(metrics_path);
|
||
if (existing) {
|
||
// A truncated metrics file is discarded and rebuilt from the empty shape
|
||
// above: these are rolling hourly buckets, not authoritative state.
|
||
try { data = json(existing) || data; } catch(e) {}
|
||
}
|
||
|
||
let hour_bucket = int(now / 3600) * 3600;
|
||
let last_bucket = length(data.hours) > 0 ? data.hours[length(data.hours) - 1] : null;
|
||
if (last_bucket && int(last_bucket.ts) == hour_bucket) {
|
||
last_bucket.proxy_ok = controller.proxy_consecutive_fails() == 0;
|
||
last_bucket.proxy_lat_ms = average_latency(controller.proxy_latency_history());
|
||
last_bucket.dns_lat_ms = average_latency(controller.dns_latency_history());
|
||
last_bucket.incidents = ai_incidents_count;
|
||
} else {
|
||
push(data.hours, {
|
||
ts: hour_bucket,
|
||
proxy_ok: controller.proxy_consecutive_fails() == 0,
|
||
proxy_lat_ms: average_latency(controller.proxy_latency_history()),
|
||
dns_lat_ms: average_latency(controller.dns_latency_history()),
|
||
incidents: ai_incidents_count
|
||
});
|
||
}
|
||
|
||
let retention = int(settings().ai_metrics_retention_hours || "24");
|
||
while (length(data.hours) > retention)
|
||
data.hours = slice(data.hours, 1);
|
||
|
||
fs.writefile(metrics_path, sprintf("%J\n", data));
|
||
}
|
||
|
||
// ─── Anomaly Detection ────────────────────────────────────────────────────────
|
||
function heal_anomaly_reconnects(ev) {
|
||
if (settings().ai_anomaly_detection_enabled == "0") return;
|
||
|
||
ai_heal_report(
|
||
"anomaly_reconnects",
|
||
sprintf("sing-box reconnected %d times in the last hour (threshold: %d)",
|
||
int(ev.payload.count), int(ev.payload.threshold)),
|
||
"High reconnect rate detected. Check proxy server health or ISP stability.",
|
||
"warn"
|
||
);
|
||
fs.writefile("/tmp/tachyon_reconnect_count", "0\n");
|
||
}
|
||
|
||
function increment_reconnect_count() {
|
||
let count_file = "/tmp/tachyon_reconnect_count";
|
||
let count = int(trim(fs.readfile(count_file) || "0")) + 1;
|
||
fs.writefile(count_file, as_string(count) + "\n");
|
||
}
|
||
|
||
// ─── Adaptive Intervals ───────────────────────────────────────────────────────
|
||
|
||
// ─── Graceful Degradation wrapper ─────────────────────────────────────────────
|
||
// Still used for the few actions that are not bus subscribers (the metrics
|
||
// export, smart-detect batch processing, the ai-heal CLI audit). Subscribers
|
||
// get the same isolation from the bus itself.
|
||
function safe_call(fn, name) {
|
||
try {
|
||
fn();
|
||
} catch(e) {
|
||
log_message("Graceful degradation: " + name + " failed: " + as_string(e), "err");
|
||
}
|
||
}
|
||
|
||
// ─── rpcd FD leak ─────────────────────────────────────────────────────────────
|
||
function heal_rpcd(ev) {
|
||
// Synchronous restart, so its exit status is the outcome.
|
||
let rc = system("/etc/init.d/rpcd restart </dev/null >/dev/null 2>&1");
|
||
ai_heal_report(
|
||
"rpcd_fd_leak",
|
||
sprintf("rpcd накопил %d открытых FD (порог %d/1024) — LuCI не может запускать команды",
|
||
int(ev.payload.fd_count), int(ev.payload.threshold)),
|
||
"Выполнен перезапуск rpcd для освобождения файловых дескрипторов",
|
||
rc == 0 ? "fixed" : "failed"
|
||
);
|
||
}
|
||
|
||
// ─── Low memory ───────────────────────────────────────────────────────────────
|
||
// Deliberately silent: dropping caches is routine housekeeping, not an
|
||
// incident, so it never called ai_heal_report() and still does not.
|
||
function heal_low_memory(ev) {
|
||
log_message("Low memory detected (" + as_string(ev.payload.free_mb) + "MB). Clearing caches...", "warn");
|
||
system("echo 3 > /proc/sys/vm/drop_caches");
|
||
}
|
||
|
||
function get_clash_url(endpoint) {
|
||
let host = "127.0.0.1:9090";
|
||
let config_data = fs.readfile("/etc/sing-box/config.json");
|
||
if (config_data) {
|
||
try {
|
||
let sb_cfg = json(config_data);
|
||
let ext = sb_cfg.experimental?.clash_api?.external_controller;
|
||
if (ext) {
|
||
let parts = split(ext, ":");
|
||
let ip = (length(parts) > 1) ? parts[0] : "";
|
||
let port = (length(parts) > 1) ? parts[length(parts) - 1] : "9090";
|
||
if (ip == "0.0.0.0" || ip == "") {
|
||
host = "127.0.0.1:" + port;
|
||
} else {
|
||
host = ext;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return "http://" + host + "/" + endpoint;
|
||
}
|
||
|
||
// The 5s throttle that used to live here is now the controller's emit_once
|
||
// window on urltest.switched, so a burst of log lines still costs one poll.
|
||
function notify_urltest_switch(ev) {
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.enabled != "1" || tcfg.notify_crash == "0") return;
|
||
|
||
let p_res = command_capture(command_from_args(["curl", "-s", get_clash_url("proxies")]));
|
||
if (p_res && p_res.status == 0 && p_res.output) {
|
||
try {
|
||
let p_data = json(p_res.output);
|
||
let proxies = p_data.proxies;
|
||
for (let name in proxies) {
|
||
let p = proxies[name];
|
||
if (p.type == "URLTest" && p.now) {
|
||
let safe_name = replace(name, /[^a-zA-Z0-9_\-]/g, "_");
|
||
let last_now = trim(fs.readfile("/tmp/watchdog_urltest_" + safe_name) || "");
|
||
if (last_now != "" && last_now != p.now) {
|
||
send_telegram_notification("🔀 *Watchdog:* Смена прокси в группе `" + name + "`\nНовый активный узел: `" + p.now + "`");
|
||
}
|
||
fs.writefile("/tmp/watchdog_urltest_" + safe_name, p.now);
|
||
}
|
||
}
|
||
}
|
||
catch (e) {
|
||
// The clash API is optional and answers with an error page while
|
||
// sing-box is restarting. Missing one proxy-switch notification is
|
||
// the whole cost; the next tick reads it again.
|
||
}
|
||
}
|
||
}
|
||
|
||
function smart_detect_process_pending() {
|
||
let cfg = settings();
|
||
if (cfg.smart_detect != "1") {
|
||
pending_smart_domains = {};
|
||
return;
|
||
}
|
||
let now = time();
|
||
if (now - smart_detect_last_run < 30) return;
|
||
|
||
let stale_cutoff = now - PENDING_SMART_DOMAINS_TTL;
|
||
for (let dom in keys(pending_smart_domains)) {
|
||
if (pending_smart_domains[dom] < stale_cutoff)
|
||
delete pending_smart_domains[dom];
|
||
}
|
||
|
||
let domain_list = keys(pending_smart_domains);
|
||
if (length(domain_list) == 0) return;
|
||
smart_detect_last_run = now;
|
||
|
||
let seen = {};
|
||
let seen_data = fs.readfile(SMART_DETECT_SEEN_FILE);
|
||
if (seen_data) {
|
||
// Corrupt seen-file means domains get re-detected once, not lost.
|
||
try { seen = json(seen_data) || {}; } catch(e) {}
|
||
}
|
||
|
||
let candidate_domains = [];
|
||
for (let dom in domain_list) {
|
||
if (!seen[dom]) {
|
||
push(candidate_domains, dom);
|
||
}
|
||
}
|
||
pending_smart_domains = {};
|
||
|
||
if (length(candidate_domains) == 0) return;
|
||
|
||
let sections = smart_detect_get_proxy_sections();
|
||
if (length(sections) == 0) return;
|
||
|
||
// Reuse the controller's cached port value: re-parsing config.json here
|
||
// would duplicate the work event_controller already does (and caches).
|
||
let proxy_addr = "127.0.0.1:" + controller.proxy_port();
|
||
// No http/mixed inbound in the generated config: probing is pointless.
|
||
if (proxy_addr == "127.0.0.1:") return;
|
||
|
||
let detect_sections = [];
|
||
let raw_list = cfg.smart_detect_sections;
|
||
if (type(raw_list) == "array") {
|
||
detect_sections = raw_list;
|
||
} else if (raw_list && trim(as_string(raw_list)) != "") {
|
||
detect_sections = [ trim(as_string(raw_list)) ];
|
||
}
|
||
if (length(detect_sections) == 0) {
|
||
detect_sections = sections;
|
||
}
|
||
|
||
for (let domain in candidate_domains) {
|
||
try {
|
||
seen[domain] = now;
|
||
|
||
// DNS pre-check: skip if domain doesn't resolve at all (not a block, DNS fault)
|
||
if (!smart_detect_domain_resolves(domain)) {
|
||
log_message("Smart Detect: " + domain + " does not resolve via DNS, skipping", "debug");
|
||
continue;
|
||
}
|
||
|
||
let direct_ok = command_success_from_args([
|
||
"curl", "-s", "-I", "--connect-timeout", "4", "--max-time", "6",
|
||
"https://" + domain
|
||
]);
|
||
if (direct_ok) continue;
|
||
|
||
// Single probe through the shared http/mixed inbound. This inbound follows
|
||
// the global routing rules, so it cannot be aimed at one specific section:
|
||
// the result is the same for every candidate section. Probe once, then hand
|
||
// the domain to the first section that accepts it (user-defined order).
|
||
let proxy_ok = command_success_from_args([
|
||
"curl", "-s", "-I", "--connect-timeout", "5", "--max-time", "8",
|
||
"--proxy", "http://" + proxy_addr,
|
||
"https://" + domain
|
||
]);
|
||
if (!proxy_ok) {
|
||
log_message("Smart Detect: " + domain + " fails directly and via proxy, skipping", "info");
|
||
continue;
|
||
}
|
||
|
||
let added = false;
|
||
for (let sec_name in detect_sections) {
|
||
sec_name = trim(as_string(sec_name));
|
||
if (sec_name == "") continue;
|
||
|
||
log_message("Smart Detect: adding " + domain + " to section " + sec_name, "info");
|
||
if (smart_detect_add_domain(sec_name, domain)) {
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (tcfg.enabled == "1" && tcfg.bot_token && tcfg.admin_ids) {
|
||
send_telegram_notification(
|
||
"🔍 *Smart Detect*: `" + domain + "` недоступен напрямую, работает через прокси.\nДобавлен в секцию *" + sec_name + "*."
|
||
);
|
||
}
|
||
added = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!added) {
|
||
log_message("Smart Detect: domain " + domain + " not handled by any section", "info");
|
||
}
|
||
} catch (e) {
|
||
log_message("Smart Detect: failed to process " + domain + ": " + as_string(e), "err");
|
||
}
|
||
}
|
||
|
||
let clean = {};
|
||
let cutoff = now - 86400;
|
||
for (let k in keys(seen)) {
|
||
if (seen[k] >= cutoff) clean[k] = seen[k];
|
||
}
|
||
fs.writefile(SMART_DETECT_SEEN_FILE, sprintf("%J", clean));
|
||
}
|
||
|
||
// ─── OOM response ─────────────────────────────────────────────────────────────
|
||
// Shrinks the GOMEMLIMIT scale by 20% per OOM, floored at 0.2 so sing-box is
|
||
// never starved into permanent failure, then restarts to apply it.
|
||
function heal_oom(ev) {
|
||
log_message("OOM event detected from syslog! Reducing GOMEMLIMIT scaling...", "err");
|
||
send_telegram_notification("🚨 *Watchdog:* Обнаружено событие OOM (Out Of Memory)! Уменьшаю GOMEMLIMIT и перезапускаю службы...");
|
||
let scale = 1.0;
|
||
let scale_path = "/etc/tachyon/mem_scale";
|
||
let scale_data = fs.readfile(scale_path);
|
||
if (scale_data != null) {
|
||
let parsed_scale = double(trim(as_string(scale_data)));
|
||
if (parsed_scale > 0.1) scale = parsed_scale;
|
||
}
|
||
let new_scale = scale * 0.8;
|
||
if (new_scale < 0.2) new_scale = 0.2;
|
||
fs.mkdir("/etc/tachyon");
|
||
fs.writefile(scale_path, sprintf("%.2f", new_scale));
|
||
system("logread -c >/dev/null 2>&1");
|
||
command_status("/usr/bin/tachyon restart >/dev/null 2>&1");
|
||
}
|
||
|
||
// Candidate domains are queued here and probed in a batch by
|
||
// smart_detect_process_pending(): probing inside the log handler would block
|
||
// the event loop on curl for every failing connection.
|
||
function collect_smart_detect_candidate(ev) {
|
||
if (length(pending_smart_domains) >= PENDING_SMART_DOMAINS_MAX) return;
|
||
pending_smart_domains[ev.payload.domain] = time();
|
||
}
|
||
|
||
// ─── Honeypot ─────────────────────────────────────────────────────────────────
|
||
// The controller has already validated the address shape, so nothing
|
||
// unvalidated reaches the nft command line here.
|
||
function heal_honeypot_hit(ev) {
|
||
let cfg = settings();
|
||
let ttl = cfg.honeypot_ttl || "86400";
|
||
let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable";
|
||
command_success_from_args(["nft", "add", "element", "inet", nft_table, "tachyon_honeypot",
|
||
"{", ev.payload.ip, "timeout", ttl + "s", "}"]);
|
||
}
|
||
|
||
function setup_honeypot_listener() {
|
||
system("mkfifo /tmp/tachyon_honeypot.fifo >/dev/null 2>&1");
|
||
system("chmod 0660 /tmp/tachyon_honeypot.fifo >/dev/null 2>&1");
|
||
|
||
let hp_pid = trim(fs.readfile("/var/run/tachyon_honeypot_listener.pid") || "");
|
||
if (hp_pid != "" && match(hp_pid, /^[0-9]+$/) != null) {
|
||
if (process_running(hp_pid)) {
|
||
command_success_from_args([ "kill", hp_pid ]);
|
||
let wait_limit = 20;
|
||
while (wait_limit > 0 && process_running(hp_pid)) {
|
||
sleep(100);
|
||
wait_limit--;
|
||
}
|
||
if (process_running(hp_pid)) {
|
||
command_success_from_args([ "kill", "-9", hp_pid ]);
|
||
}
|
||
}
|
||
}
|
||
remove_file("/var/run/tachyon_honeypot_listener.pid");
|
||
|
||
let fifo_fd = fs.open("/tmp/tachyon_honeypot.fifo", "r+");
|
||
if (uloop && fifo_fd) {
|
||
try {
|
||
uloop.handle(fifo_fd.fileno(), function(events) {
|
||
let line;
|
||
while ((line = fifo_fd.read("line")) != null) {
|
||
controller.handle_honeypot_line(line);
|
||
}
|
||
}, uloop.ULOOP_READ);
|
||
} catch (e) {
|
||
log_message("Failed to bind honeypot fifo to uloop: " + as_string(e), "warn");
|
||
}
|
||
} else {
|
||
let cfg = settings();
|
||
let ttl = cfg.honeypot_ttl || "86400";
|
||
let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable";
|
||
let hp_cmd = common.background_pipeline_with_pid(
|
||
"tail -f /tmp/tachyon_honeypot.fifo | while read ip; do " +
|
||
"if [ -n \"$ip\" ]; then " +
|
||
"nft add element inet " + shell_quote(nft_table) + " tachyon_honeypot { \"$ip\" timeout " + shell_quote(ttl) + "s } >/dev/null 2>&1; " +
|
||
"fi; done",
|
||
"> /var/run/tachyon_honeypot_listener.pid");
|
||
system(hp_cmd);
|
||
}
|
||
}
|
||
|
||
// The "OOM was long enough ago" and "scale is below 1.0" tests are the
|
||
// controller's oom.recovery probe; what is left here is the write-back.
|
||
// The 600s self-cooldown stays local because it paces the healer, not the fact.
|
||
function recover_oom_scale(ev) {
|
||
let now = time();
|
||
if (now - last_oom_recovery_time < 600) return;
|
||
last_oom_recovery_time = now;
|
||
|
||
let scale_path = "/etc/tachyon/mem_scale";
|
||
let current_scale = double(ev.payload.scale);
|
||
let new_scale = current_scale + 0.05;
|
||
if (new_scale > 1.0) new_scale = 1.0;
|
||
log_message("OOM recovery: restoring GOMEMLIMIT scale from " + sprintf("%.2f", current_scale) + " to " + sprintf("%.2f", new_scale), "info");
|
||
write_state_file(scale_path, sprintf("%.2f", new_scale), "GOMEMLIMIT scale");
|
||
}
|
||
|
||
function setup_syslog_listener() {
|
||
if (!uloop) return null;
|
||
controller.set_syslog_start(time());
|
||
// Kill orphaned logread -f processes from previous watchdog instances.
|
||
// Without this, every restart cascades: new watchdog inherits old watchdog's
|
||
// logread pipe read-end, keeping old logread alive. Over N restarts,
|
||
// watchdog accumulates N inherited FDs → hits 1024 limit → config generator fails.
|
||
// Anchor with $ to avoid killing system logremote/logfile processes.
|
||
system("pkill -f 'logread -f$' 2>/dev/null; true");
|
||
let log_pipe = fs.popen("logread -f 2>/dev/null", "r");
|
||
if (!log_pipe) return null;
|
||
|
||
try {
|
||
uloop.handle(log_pipe.fileno(), function(events) {
|
||
let line;
|
||
while ((line = log_pipe.read("line")) != null) {
|
||
controller.handle_log_line(trim(as_string(line)));
|
||
}
|
||
}, uloop.ULOOP_READ);
|
||
} catch (e) {
|
||
log_message("Failed to register syslog listener: " + as_string(e), "warn");
|
||
}
|
||
return log_pipe;
|
||
}
|
||
|
||
function setup_ubus_listener() {
|
||
if (!ubus || !uloop) return null;
|
||
let conn = null;
|
||
// Null conn is handled on the next line: without ubus the watchdog falls
|
||
// back to polling, which is the same path taken when the module is absent.
|
||
try { conn = ubus.connect(); } catch (e) {}
|
||
if (!conn || type(conn.listener) != "function") return null;
|
||
|
||
try {
|
||
conn.listener("service.instance.stop", function(ev, msg) {
|
||
if (type(msg) == "object")
|
||
controller.handle_ubus_service_stop(msg.name, "ubus service.instance.stop event");
|
||
});
|
||
conn.listener("service.stop", function(ev, msg) {
|
||
if (type(msg) == "object")
|
||
controller.handle_ubus_service_stop(msg.name, "ubus service.stop event");
|
||
});
|
||
conn.listener("firewall.reload", function(ev, msg) {
|
||
controller.handle_ubus_firewall_reload();
|
||
});
|
||
} catch (e) {
|
||
log_message("Failed to register ubus listeners: " + as_string(e), "warn");
|
||
}
|
||
return conn;
|
||
}
|
||
|
||
// Idempotent: the ai-heal CLI mode and worker() both need the subscriptions in
|
||
// place, and registering twice would double every repair.
|
||
let subscribers_registered = false;
|
||
|
||
// ─── Subscriptions ────────────────────────────────────────────────────────────
|
||
// One table, one place: what the watchdog reacts to and in what order. Priority
|
||
// encodes the old tier ordering — restore the uplink before blaming the proxy,
|
||
// restore the proxy before touching DNS — so a single tick that observes several
|
||
// faults repairs them bottom-up like the sequential check list used to.
|
||
//
|
||
// `cooldown` values are the module globals the old code hand-rolled:
|
||
// heal_wan_and_gateway ← last_wan_heal_time (300s)
|
||
// heal_community_subnet… ← last_subnet_heal_time (300s)
|
||
// heal_empty_sections ← last_section_heal_attempt (120s)
|
||
// heal_anomaly_reconnects ← last_anomaly_check (300s)
|
||
// Debounces that belong to the repair rather than the fact (the 30s sing-box
|
||
// restart floor, the 600s OOM-recovery pacing) stay inside their handlers.
|
||
// A handler that is not a function makes bus.on() return false and register
|
||
// nothing — the repair would then silently never run, with no error anywhere.
|
||
// ucode does not hoist function declarations, so that is exactly what happens if
|
||
// a heal_* is moved below this function. subscribe() turns that silent loss into
|
||
// a startup failure.
|
||
function subscribe(event_type, handler, opts) {
|
||
if (!bus.on(event_type, handler, opts)) {
|
||
let label = (type(opts) == "object" && opts.name != null) ? opts.name : as_string(event_type);
|
||
log_message("FATAL: subscriber " + label + " for " + as_string(event_type) +
|
||
" could not be registered; this repair would never run", "err");
|
||
die("watchdog: failed to register subscriber " + label);
|
||
}
|
||
}
|
||
|
||
function register_subscribers() {
|
||
if (subscribers_registered) return;
|
||
subscribers_registered = true;
|
||
|
||
subscribe(EV.WAN_DOWN, heal_wan_and_gateway,
|
||
{ name: "heal_wan_and_gateway", priority: PRIORITY_WAN, cooldown: 300 });
|
||
|
||
subscribe(EV.SINGBOX_STOPPED, heal_singbox_stopped,
|
||
{ name: "heal_singbox_stopped", priority: PRIORITY_SINGBOX });
|
||
|
||
subscribe(EV.CONFIG_CORRUPT, heal_uci_config,
|
||
{ name: "heal_uci_config", priority: 25 });
|
||
|
||
subscribe(EV.NFT_MISSING, heal_nftables,
|
||
{ name: "heal_nftables", priority: 30 });
|
||
subscribe(EV.QOS_MISSING, heal_qos,
|
||
{ name: "heal_qos", priority: 30 });
|
||
subscribe(EV.TPROXY_DOWN, heal_tproxy_port,
|
||
{ name: "heal_tproxy_port", priority: 30 });
|
||
subscribe(EV.SUBNETS_EMPTY, heal_community_subnet_sets,
|
||
{ name: "heal_community_subnet_sets", priority: 30, cooldown: 300 });
|
||
|
||
// Both proxy subscribers see the same fact, but they used to be measured on
|
||
// different tiers, and their thresholds are calibrated to those rates:
|
||
// ai_heal_proxy_health ran fast (15s), ai_heal_proxy_connectivity ran inside
|
||
// the normal-tier audit (120s+). The probe now runs once at the fast rate,
|
||
// so the connectivity healer carries a 120s cooldown to keep its original
|
||
// pacing. Without it a single stall would restart sing-box eight times as
|
||
// often — the aggressive-restart regression fixed in c8052ee6.
|
||
subscribe(EV.PROXY_DOWN, heal_proxy_connectivity,
|
||
{ name: "heal_proxy_connectivity", priority: PRIORITY_PROXY, cooldown: 120 });
|
||
subscribe(EV.PROXY_DOWN, heal_proxy_health,
|
||
{ name: "heal_proxy_health", priority: PRIORITY_PROXY + 1, cooldown: 120 });
|
||
|
||
// Same split for DNS: ai_heal_dns_continuous was fast, ai_heal_dns was part
|
||
// of the normal-tier audit. The streak the stall healer thresholds on is
|
||
// already paced at the normal rate inside the probe (DNS_STREAK_INTERVAL);
|
||
// the cooldown here is a second bound on how often a restart can be issued,
|
||
// so a streak that stays at 3 cannot restart the proxy every 15 seconds.
|
||
subscribe(EV.DNS_DOWN, heal_dns_stall,
|
||
{ name: "heal_dns_stall", priority: PRIORITY_DNS, cooldown: 120 });
|
||
subscribe(EV.DNS_DOWN, heal_dns_continuous,
|
||
{ name: "heal_dns_continuous", priority: PRIORITY_DNS + 1 });
|
||
|
||
// nfqueue/zapret runtime: respawn crashed nfqws/nfqws2 processes.
|
||
subscribe(EV.NFQUEUE_DOWN, heal_nfqueue_stopped,
|
||
{ name: "heal_nfqueue_stopped", priority: 45, cooldown: 300 });
|
||
|
||
// Recovery facts. These were published from the start but had no subscriber,
|
||
// which is why a repair could only ever report its own intent. No cooldown:
|
||
// settle_recovery() is a no-op unless a watch is actually open, and skipping
|
||
// the one emit that closes a watch would leave it to expire as `failed`.
|
||
subscribe(EV.PROXY_UP, note_proxy_recovered,
|
||
{ name: "note_proxy_recovered", priority: 5 });
|
||
subscribe(EV.DNS_UP, note_dns_recovered,
|
||
{ name: "note_dns_recovered", priority: 5 });
|
||
// The uplink coming back releases the WAN claim, so the proxy and DNS
|
||
// healers resume at the first healthy tick instead of waiting out the
|
||
// suppression deadline.
|
||
subscribe(EV.WAN_UP, note_wan_recovered,
|
||
{ name: "note_wan_recovered", priority: 5 });
|
||
subscribe(EV.NFQUEUE_UP, note_nfqueue_recovered,
|
||
{ name: "note_nfqueue_recovered", priority: 5 });
|
||
|
||
subscribe(EV.SECTIONS_EMPTY, heal_empty_sections,
|
||
{ name: "heal_empty_sections", priority: 60, cooldown: 120 });
|
||
subscribe(EV.SUBSCRIPTION_UNREACHABLE, heal_subscriptions,
|
||
{ name: "heal_subscriptions", priority: 60 });
|
||
|
||
subscribe(EV.OOM_DETECTED, heal_oom,
|
||
{ name: "heal_oom", priority: 70 });
|
||
subscribe(EV.OOM_RECOVERABLE, recover_oom_scale,
|
||
{ name: "recover_oom_scale", priority: 70 });
|
||
subscribe(EV.MEMORY_LOW, heal_low_memory,
|
||
{ name: "heal_low_memory", priority: 70 });
|
||
subscribe(EV.RPCD_FD_LEAK, heal_rpcd,
|
||
{ name: "heal_rpcd", priority: 70 });
|
||
|
||
subscribe(EV.ANOMALY_RECONNECTS, heal_anomaly_reconnects,
|
||
{ name: "heal_anomaly_reconnects", priority: 80, cooldown: 300 });
|
||
|
||
// Observers, not repairs: they must run after the healers above.
|
||
subscribe(EV.SMARTDETECT_CANDIDATE, collect_smart_detect_candidate,
|
||
{ name: "collect_smart_detect_candidate", priority: 90 });
|
||
subscribe(EV.URLTEST_SWITCHED, notify_urltest_switch,
|
||
{ name: "notify_urltest_switch", priority: 90 });
|
||
subscribe(EV.HONEYPOT_HIT, heal_honeypot_hit,
|
||
{ name: "heal_honeypot_hit", priority: 90 });
|
||
subscribe(EV.PAUSE_EXPIRED, function(ev) { check_auto_resume_pause(); },
|
||
{ name: "resume_after_pause", priority: 5 });
|
||
}
|
||
|
||
// One synchronous sweep of every tier, used by the `ai-heal` CLI mode where no
|
||
// worker is running: probe, let the subscribers repair, then publish status.
|
||
function ai_full_health_audit() {
|
||
register_subscribers();
|
||
controller.probe_fast();
|
||
controller.probe_normal();
|
||
controller.probe_slow();
|
||
safe_call(ai_heal_dns_loop, "ai_heal_dns_loop");
|
||
safe_call(settle_expired_recoveries, "settle_expired_recoveries");
|
||
ai_export_status();
|
||
}
|
||
|
||
function worker() {
|
||
log_message("Watchdog daemon started.", "info");
|
||
|
||
// Subscribe before any source is wired up, so no fact observed during
|
||
// startup is published into an empty bus and lost.
|
||
register_subscribers();
|
||
|
||
setup_honeypot_listener();
|
||
// Restore subnet cache from persistent storage at startup (in case /tmp was cleared)
|
||
ai_heal_subnet_cache();
|
||
run_zero_rtt_prefetching();
|
||
|
||
// Throws when it already exists, which is the expected case on every boot
|
||
// after the first.
|
||
try { fs.mkdir("/etc/tachyon"); } catch(e) {}
|
||
|
||
// H-14: Save config backup after successful start
|
||
let current_cfg = fs.readfile("/etc/config/tachyon");
|
||
if (current_cfg != null && current_cfg != "") {
|
||
write_state_file("/etc/backup/tachyon_config", current_cfg, "config backup");
|
||
}
|
||
|
||
// H-8: Write keepalive timestamp for init.d supervision
|
||
write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(time()), "keepalive stamp");
|
||
|
||
if (uloop) {
|
||
try {
|
||
uloop.init();
|
||
} catch (e) {
|
||
log_message("Failed to initialize uloop: " + as_string(e), "warn");
|
||
}
|
||
}
|
||
|
||
let log_pipe = setup_syslog_listener();
|
||
let ubus_conn = setup_ubus_listener();
|
||
|
||
function perform_fast_checks() {
|
||
controller.probe_fast();
|
||
// After the probes, so a recovery observed on this very tick closes its
|
||
// watch before the deadline is tested against it.
|
||
safe_call(settle_expired_recoveries, "settle_expired_recoveries");
|
||
}
|
||
|
||
function perform_normal_checks() {
|
||
controller.probe_normal();
|
||
safe_call(smart_detect_process_pending, "smart_detect_process_pending");
|
||
safe_call(export_metrics, "export_metrics");
|
||
safe_call(ai_export_status, "ai_export_status");
|
||
}
|
||
|
||
// ── Mixed proxy port (4534) self-healing ─────────────────────────────────────
|
||
// sing-box can be running while its mixed inbound failed to bind: every LAN
|
||
// client proxy and the Telegram bot route then silently dead-ends. Two
|
||
// consecutive slow checks (~10 min) with a live core and a dead port trigger
|
||
// one core restart; if the port is still dead afterwards, the admin gets a
|
||
// notification instead of endless restart loops.
|
||
|
||
let mixed_port_fail_streak = 0;
|
||
let mixed_port_last_notify = 0;
|
||
|
||
// Mixed-proxy ports the running config actually declares. Reading the
|
||
// generated sing-box config keeps the watchdog in sync with the generator:
|
||
// when no section enables the mixed proxy there is nothing to guard, and
|
||
// demanding port 4534 anyway caused endless restart loops that rebuilt the
|
||
// nftables ruleset every few minutes.
|
||
function mixed_ports_from_config() {
|
||
let settings = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
|
||
let config_path = trim(as_string(settings.config_path || "")) || "/etc/sing-box/config.json";
|
||
let data = fs.readfile(config_path);
|
||
if (data == null)
|
||
return [];
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = json(data);
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
if (parsed == null || parsed.inbounds == null)
|
||
return [];
|
||
|
||
let ports = [];
|
||
for (let inbound in parsed.inbounds) {
|
||
if (inbound.type != "mixed" || inbound.listen_port == null)
|
||
continue;
|
||
let port = int(inbound.listen_port, 10);
|
||
if (port > 0) {
|
||
let known = false;
|
||
for (let p in ports)
|
||
if (p == port) { known = true; break; }
|
||
if (!known)
|
||
push(ports, port);
|
||
}
|
||
}
|
||
return ports;
|
||
}
|
||
|
||
function port_listening(port) {
|
||
let out = command_output_from_args([ "netstat", "-ltn" ]);
|
||
let needle = ":" + port + " ";
|
||
for (let line in split(out, "\n")) {
|
||
if (index(line, needle) >= 0)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function mixed_proxy_port_listening() {
|
||
let ports = mixed_ports_from_config();
|
||
for (let port in ports) {
|
||
if (!port_listening(port))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function check_mixed_proxy_port() {
|
||
let sb_pid = trim(command_output_from_args(["pidof", "sing-box"]));
|
||
if (sb_pid == "") {
|
||
// Core down entirely is the proxy-health loop's domain.
|
||
if (mixed_port_fail_streak > 0)
|
||
log_message("Mixed port watch: sing-box stopped, standing down.", "debug");
|
||
mixed_port_fail_streak = 0;
|
||
return;
|
||
}
|
||
|
||
let ports = mixed_ports_from_config();
|
||
if (length(ports) == 0) {
|
||
// Mixed proxy is not part of the generated config: nothing to watch.
|
||
if (mixed_port_fail_streak > 0)
|
||
log_message("Mixed port watch: config declares no mixed inbound, standing down.", "debug");
|
||
mixed_port_fail_streak = 0;
|
||
return;
|
||
}
|
||
|
||
if (mixed_proxy_port_listening()) {
|
||
if (mixed_port_fail_streak >= 2)
|
||
log_message("Mixed port watch: port(s) " + join(", ", ports) + " recovered after core restart.", "info");
|
||
mixed_port_fail_streak = 0;
|
||
return;
|
||
}
|
||
|
||
mixed_port_fail_streak++;
|
||
log_message(sprintf("sing-box runs but mixed port(s) %s are not listening (streak %d).",
|
||
join(", ", ports), mixed_port_fail_streak), "warn");
|
||
|
||
if (mixed_port_fail_streak == 2) {
|
||
log_message("Restarting sing-box to restore the mixed inbound.", "warn");
|
||
command_status("/usr/bin/tachyon restart >/dev/null 2>&1");
|
||
} else if (mixed_port_fail_streak > 2) {
|
||
let now = time();
|
||
if (now - mixed_port_last_notify >= 3600) {
|
||
mixed_port_last_notify = now;
|
||
send_telegram_notification("⚠️ Порт " + join(", ", ports) + " не слушается при работающем sing-box. Рестарт не помог — проверьте конфигурацию ядра (лог sing-box), раздел диагностики покажет детали.");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Telegram worker liveness ─────────────────────────────────────────────────
|
||
// The worker owns a pid but can hang silently (stuck long-poll, deadlocked
|
||
// loop). It stamps a heartbeat after every successful poll; a stale
|
||
// heartbeat while the bot is enabled means the pid is zombie - restart it.
|
||
|
||
const TELEGRAM_PID_FILE = "/var/run/tachyon_telegram.pid";
|
||
const TELEGRAM_HEARTBEAT_FILE = "/var/run/tachyon_telegram.heartbeat";
|
||
let telegram_worker_last_restart = 0;
|
||
|
||
function telegram_worker_restart() {
|
||
let now = time();
|
||
if (now - telegram_worker_last_restart < 120)
|
||
return;
|
||
telegram_worker_last_restart = now;
|
||
log_message("Telegram worker is enabled but not polling (no heartbeat); restarting it.", "warn");
|
||
command_status("/usr/bin/tachyon telegram_start >/dev/null 2>&1");
|
||
}
|
||
|
||
function check_telegram_worker() {
|
||
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||
if (as_string(tcfg.enabled || "0") != "1")
|
||
return;
|
||
|
||
let heartbeat = trim(fs.readfile(TELEGRAM_HEARTBEAT_FILE) || "");
|
||
if (heartbeat == "") {
|
||
// No stamp at all: either the worker never got a successful poll or
|
||
// the file was wiped by a reboot. Only act when the pid is gone too,
|
||
// otherwise give the worker one poll cycle to succeed.
|
||
let pid = trim(fs.readfile(TELEGRAM_PID_FILE) || "");
|
||
if (pid == "" || !process_running(pid, "ucode"))
|
||
telegram_worker_restart();
|
||
return;
|
||
}
|
||
|
||
let age = time() - int(heartbeat);
|
||
if (age <= 300)
|
||
return;
|
||
|
||
let pid = trim(fs.readfile(TELEGRAM_PID_FILE) || "");
|
||
if (pid != "" && process_running(pid, "ucode")) {
|
||
log_message(sprintf("Telegram worker pid %s is running but its heartbeat is %d s old; restarting.", pid, age), "warn");
|
||
command_status("/usr/bin/tachyon telegram_stop >/dev/null 2>&1");
|
||
}
|
||
telegram_worker_restart();
|
||
}
|
||
|
||
// Native Tailscale sections run tailscaled outside sing-box; nobody else
|
||
// watches that daemon. Two consecutive unhealthy statuses trigger an
|
||
// idempotent provider restart (start-runtime), alerts throttled to 1/hour.
|
||
function check_tailscale_worker() {
|
||
let raw = trim(command_output_from_args([ "/usr/bin/tachyon", "get_tailscale_status" ]) || "");
|
||
if (raw == "")
|
||
return;
|
||
let st = common.object_or_empty(json(raw));
|
||
if (st.configured != true)
|
||
return;
|
||
if (st.ready == true) {
|
||
tailscale_fail_streak = 0;
|
||
return;
|
||
}
|
||
|
||
tailscale_fail_streak++;
|
||
if (tailscale_fail_streak < 2)
|
||
return;
|
||
tailscale_fail_streak = 0;
|
||
|
||
log_message("Native Tailscale runtime is unhealthy (" + as_string(st.status_message || "") + "); restarting", "warn");
|
||
command_status("/usr/bin/tachyon tailscale_restart >/dev/null 2>&1");
|
||
|
||
let now = time();
|
||
if (now - tailscale_last_alert >= 3600) {
|
||
tailscale_last_alert = now;
|
||
send_telegram_notification("🛰 *Watchdog:* Native Tailscale was down, restarting the service.");
|
||
}
|
||
}
|
||
|
||
// Cross-section failover probe; the module self-gates on
|
||
// settings.section_failover_enabled so this is a no-op by default.
|
||
function check_section_failover() {
|
||
command_status("/usr/bin/tachyon failover_check >/dev/null 2>&1");
|
||
}
|
||
|
||
function perform_slow_checks() {
|
||
controller.probe_slow();
|
||
safe_call(ai_heal_dns_loop, "ai_heal_dns_loop");
|
||
safe_call(check_mixed_proxy_port, "check_mixed_proxy_port");
|
||
safe_call(check_telegram_worker, "check_telegram_worker");
|
||
safe_call(check_tailscale_worker, "check_tailscale_worker");
|
||
safe_call(check_section_failover, "check_section_failover");
|
||
}
|
||
if (uloop) {
|
||
let tick;
|
||
tick = function() {
|
||
let now = time();
|
||
try {
|
||
write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(time()), "keepalive stamp");
|
||
if (now - last_fast_check >= 15) {
|
||
last_fast_check = now;
|
||
perform_fast_checks();
|
||
}
|
||
if (now - last_normal_check >= controller.adaptive_normal_interval()) {
|
||
last_normal_check = now;
|
||
perform_normal_checks();
|
||
}
|
||
if (now - last_slow_check >= 300) {
|
||
last_slow_check = now;
|
||
perform_slow_checks();
|
||
}
|
||
} catch (e) {
|
||
log_message("Error in tick: " + as_string(e), "err");
|
||
}
|
||
uloop.timer(5000, tick);
|
||
};
|
||
uloop.timer(10000, tick);
|
||
|
||
log_message("Watchdog running in event-driven uloop mode (fast: 15s, normal: adaptive, slow: 300s).", "info");
|
||
signal("SIGTERM", function(sig) { log_message("SIGTERM received, shutting down", "info"); stop_runtime(); exit(0); });
|
||
signal("SIGINT", function(sig) { log_message("SIGINT received, shutting down", "info"); stop_runtime(); exit(0); });
|
||
uloop.run();
|
||
} else {
|
||
log_message("uloop not available. Running Watchdog in legacy fallback loop mode.", "warn");
|
||
signal("SIGTERM", function(sig) { log_message("SIGTERM received, shutting down", "info"); stop_runtime(); exit(0); });
|
||
signal("SIGINT", function(sig) { log_message("SIGINT received, shutting down", "info"); stop_runtime(); exit(0); });
|
||
while (true) {
|
||
perform_fast_checks();
|
||
perform_normal_checks();
|
||
perform_slow_checks();
|
||
sleep(15000);
|
||
}
|
||
}
|
||
|
||
if (log_pipe) log_pipe.close();
|
||
// Shutdown path: a connection already torn down by the peer throws here, and
|
||
// there is nothing left to salvage either way.
|
||
if (ubus_conn) try { ubus_conn.close(); } catch (e) {}
|
||
return 0;
|
||
}
|
||
|
||
function get_status() {
|
||
let pid = trim(fs.readfile(PID_FILE) || "");
|
||
if (process_running(pid, "ucode")) {
|
||
print("running (pid " + pid + ")\n");
|
||
return 0;
|
||
}
|
||
print("stopped\n");
|
||
return 1;
|
||
}
|
||
|
||
function print_ai_status() {
|
||
let data = fs.readfile("/tmp/tachyon_ai_status.json");
|
||
if (data) {
|
||
print(data);
|
||
} else {
|
||
print("{\"status\":\"unknown\",\"ai_active\":false}\n");
|
||
}
|
||
}
|
||
|
||
function print_ai_status_full() {
|
||
let now = time();
|
||
let proxy_fails = controller.proxy_consecutive_fails();
|
||
let dns_fails = controller.dns_consecutive_fails();
|
||
let proxy_ok = proxy_fails == 0;
|
||
let dns_ok = dns_fails == 0;
|
||
|
||
let sb_uptime = 0;
|
||
let sb_pid = controller.get_sing_box_pid();
|
||
if (sb_pid != "" && process_running(sb_pid, "sing-box")) {
|
||
try {
|
||
let stat_data = fs.readfile("/proc/" + sb_pid + "/stat");
|
||
if (stat_data) {
|
||
let fields = split(trim(stat_data), /[ \t]+/);
|
||
if (length(fields) >= 22) {
|
||
let starttime = int(fields[21]);
|
||
let clk_tck = 100;
|
||
let uptime_seconds = 0;
|
||
// Leaves uptime at 0, which the caller reads as "age unknown" — /proc/uptime
|
||
// is only unreadable if /proc is not mounted, and then nothing else works
|
||
// either.
|
||
try { uptime_seconds = int(trim(fs.readfile("/proc/uptime") || "0")); } catch(e) {}
|
||
sb_uptime = uptime_seconds - int(starttime / clk_tck);
|
||
}
|
||
}
|
||
}
|
||
catch (e) {
|
||
// sb_uptime stays -1, which the report renders as "unknown". The
|
||
// process can exit between reading its pid and reading its stat.
|
||
}
|
||
}
|
||
|
||
let mem_mb = -1;
|
||
let mem_info = fs.readfile("/proc/meminfo") || "";
|
||
for (let line in split(mem_info, "\n")) {
|
||
if (index(line, "MemAvailable:") == 0) {
|
||
let fields = split(trim(line), /[ \t]+/);
|
||
if (length(fields) >= 2) mem_mb = int(fields[1]) / 1024;
|
||
break;
|
||
}
|
||
}
|
||
|
||
let status = last_ai_incident != null && (now - last_ai_incident.timestamp < 300) ? "repaired" : "healthy";
|
||
|
||
let result = {
|
||
status: status,
|
||
ai_active: true,
|
||
uptime_s: sb_uptime,
|
||
memory_mb: mem_mb,
|
||
proxy_ok: proxy_ok,
|
||
proxy_latency_ms: average_latency(controller.proxy_latency_history()),
|
||
proxy_consecutive_fails: proxy_fails,
|
||
dns_ok: dns_ok,
|
||
dns_latency_ms: average_latency(controller.dns_latency_history()),
|
||
dns_consecutive_fails: dns_fails,
|
||
incidents_total: ai_incidents_count,
|
||
last_incident: last_ai_incident,
|
||
healthy_streak: controller.healthy_streak(),
|
||
adaptive_interval_s: controller.adaptive_normal_interval(),
|
||
reconnects_hour: int(trim(fs.readfile("/tmp/tachyon_reconnect_count") || "0"))
|
||
};
|
||
|
||
print(sprintf("%J\n", result));
|
||
}
|
||
|
||
let mode = (ARGV[0] == "") ? ARGV[1] : ARGV[0];
|
||
if (!mode) mode = "";
|
||
|
||
if (mode == "start-runtime")
|
||
exit(start_runtime());
|
||
else if (mode == "stop-runtime")
|
||
exit(stop_runtime());
|
||
else if (mode == "worker")
|
||
exit(worker());
|
||
else if (mode == "status")
|
||
exit(get_status());
|
||
else if (mode == "ai-heal") {
|
||
ai_full_health_audit();
|
||
print_ai_status();
|
||
exit(0);
|
||
}
|
||
else if (mode == "ai-status") {
|
||
print_ai_status();
|
||
exit(0);
|
||
}
|
||
else if (mode == "ai-status-full") {
|
||
print_ai_status_full();
|
||
exit(0);
|
||
}
|
||
else if (mode == "smart-detect-extract-domain") {
|
||
let extracted = smart_detect_extract_domain(ARGV[1]);
|
||
if (extracted == null) exit(1);
|
||
print(extracted + "\n");
|
||
exit(0);
|
||
}
|
||
else {
|
||
warn("Usage: service/watchdog.uc <start-runtime|stop-runtime|worker|status|ai-heal|ai-status|ai-status-full|smart-detect-extract-domain> ...\n");
|
||
exit(1);
|
||
}
|