Ранее вочдог опрашивал состояние системы и устранял проблемы в едином смешанном цикле. Добавление новой проверки или действия по восстановлению требовало изменения монолитной функции, где мониторинг и ремонт были переплетены, что затрудняло аудит порядка выполнения и изолированное тестирование. Вводится `usr/lib/service/event_controller.uc` — шина событий pub/sub для вочдога. Теперь зонды публикуют факты (`EV.PROXY_DOWN`, `EV.DNS_DOWN`, `EV.SINGBOX_STOPPED`, `EV.OOM_DETECTED`, `EV.WAN_DOWN` и т.д.), не зная, кто их обрабатывает. Восстановители (healers) подписываются на эти факты и независимо выполняют ремонт. Ключевые изменения: - `event_controller.uc` управляет словарём событий, регистрацией подписчиков, сортировкой по приоритетам и синхронной отправкой. - Зонды (`probe_proxy`, `probe_dns`, `probe_singbox`, `probe_oom`, `probe_wan`) публикуют события вместо прямого вызова функций ремонта. - Восстановители регистрируются как подписчики с явными приоритетами, чтобы первопричины устранялись раньше вторичных сбоев. - `tests/event_bus.sh` добавляет интеграционные тесты для отправки событий, сортировки приоритетов и обработки подписчиков.
1548 lines
62 KiB
Ucode
1548 lines
62 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();
|
|
// FD-cascade prevention: track logread pipe FD to close it in background spawns
|
|
let logread_pipe_fd = -1;
|
|
|
|
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 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) };
|
|
}
|
|
|
|
// Run a command in background, explicitly closing the logread pipe FD to
|
|
// prevent FD-cascade: each restart/reload inherits read-end of logread pipe,
|
|
// keeping orphaned logread -f processes alive across watchdog generations.
|
|
function bg_system(cmd) {
|
|
if (logread_pipe_fd >= 0) {
|
|
system(sprintf("%d<&- ", logread_pipe_fd) + cmd);
|
|
} else {
|
|
system(cmd);
|
|
}
|
|
}
|
|
|
|
function settings() {
|
|
return common.object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
|
|
}
|
|
|
|
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) ]);
|
|
}
|
|
}
|
|
|
|
function send_telegram_notification(message) {
|
|
let now = time();
|
|
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("/usr/bin/tachyon telegram send " + shell_quote(message) + " </dev/null >/dev/null 2>&1 1000<&- &");
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 = command_from_args([ "ucode", "-L", LIB_DIR, WATCHDOG_UC, "worker" ]) +
|
|
" </dev/null >/dev/null 2>&1 1000<&- & echo $! >" + 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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let domain_list = keys(unique_domains);
|
|
if (length(domain_list) == 0) return;
|
|
|
|
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) {
|
|
let batch_cmd = "for d in " + join(" ", batch) + "; do dig @127.0.0.1 \"$d\" A >/dev/null 2>&1; done &";
|
|
system(batch_cmd + " </dev/null >/dev/null 2>&1 1000<&-");
|
|
batch = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
let uloop = null;
|
|
let ubus = null;
|
|
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;
|
|
let last_fast_check = 0;
|
|
let last_normal_check = 0;
|
|
let last_slow_check = 0;
|
|
|
|
// Observation state lives in the controller; these read through to it so the
|
|
// status and metrics contracts keep reporting exactly the same numbers.
|
|
function proxy_consecutive_fails() { return controller.proxy_consecutive_fails(); }
|
|
function dns_consecutive_fails() { return controller.dns_consecutive_fails(); }
|
|
function proxy_latency_history() { return controller.proxy_latency_history(); }
|
|
function dns_latency_history() { return controller.dns_latency_history(); }
|
|
|
|
function check_tachyon_cli_running() {
|
|
let running = false;
|
|
let proc = fs.opendir("/proc");
|
|
if (proc) {
|
|
let entry;
|
|
while ((entry = proc.read()) != null) {
|
|
if (match(entry, /^[0-9]+$/)) {
|
|
let cmdline = fs.readfile("/proc/" + entry + "/cmdline") || "";
|
|
if (index(cmdline, "/usr/bin/tachyon") >= 0) {
|
|
if (index(cmdline, "start") >= 0 || index(cmdline, "restart") >= 0 || index(cmdline, "reload") >= 0 || index(cmdline, "stop") >= 0) {
|
|
running = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
proc.close();
|
|
}
|
|
return running;
|
|
}
|
|
|
|
// 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 (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...");
|
|
}
|
|
safe_proxy_restart("singbox_stopped");
|
|
}
|
|
|
|
function get_sing_box_pid() {
|
|
return controller.get_sing_box_pid();
|
|
}
|
|
|
|
// ─── AI Watchdog Self-Healing Matrix ──────────────────────────────────────────
|
|
let ai_incidents_count = 0;
|
|
let last_ai_incident = null;
|
|
|
|
function ai_export_status() {
|
|
let is_healthy = last_ai_incident == null || (time() - last_ai_incident.timestamp >= 300);
|
|
if (is_healthy) controller.note_healthy();
|
|
let status_obj = {
|
|
timestamp: time(),
|
|
status: is_healthy ? "healthy" : "repaired",
|
|
ai_active: true,
|
|
incidents_resolved_total: ai_incidents_count,
|
|
last_incident: last_ai_incident
|
|
};
|
|
fs.writefile("/tmp/tachyon_ai_status.json", sprintf("%J\n", status_obj));
|
|
}
|
|
|
|
function ai_heal_report(event_type, description, resolution, status_code) {
|
|
ai_incidents_count++;
|
|
controller.note_incident();
|
|
last_ai_incident = {
|
|
type: event_type,
|
|
description: description,
|
|
resolution: resolution,
|
|
timestamp: time()
|
|
};
|
|
|
|
let log_msg = sprintf("🤖 [AI Watchdog] %s. Action taken: %s", description, resolution);
|
|
log_message(log_msg, "warn");
|
|
|
|
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
|
if (tcfg.enabled == "1" && tcfg.bot_token && tcfg.admin_ids && tcfg.notify_crash != "0") {
|
|
let tg_msg = sprintf("🤖 *[ИИ-Автомеханик Tachyon]*\n⚠️ *Проблема:* %s\n🔧 *Авто-решение:* %s", description, resolution);
|
|
send_telegram_notification(tg_msg);
|
|
}
|
|
|
|
ai_export_status();
|
|
}
|
|
|
|
// 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
|
|
|| check_tachyon_cli_running();
|
|
}
|
|
|
|
function safe_proxy_restart(reason) {
|
|
let now = time();
|
|
if (now - proxy_restart_window_start > 600) {
|
|
proxy_restart_count = 0;
|
|
proxy_restart_window_start = now;
|
|
}
|
|
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);
|
|
}
|
|
try { fs.writefile(PROXY_RESTART_LOCK, as_string(now)); } catch(e) {}
|
|
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 lock_path = shell_quote(PROXY_RESTART_LOCK);
|
|
bg_system("/etc/init.d/tachyon restart </dev/null >/dev/null 2>&1 & rm -f " + lock_path + " &");
|
|
return true;
|
|
}
|
|
|
|
// ─── Reload dedup: prevent multiple reload_firewall per cycle ─────────────────
|
|
function safe_reload_firewall() {
|
|
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 </dev/null >/dev/null 2>&1 &");
|
|
}
|
|
|
|
// ─── 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 is_dns_working() {
|
|
return controller.is_dns_working();
|
|
}
|
|
|
|
// 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;
|
|
|
|
controller.reset_dns_streak();
|
|
|
|
log_message("Watchdog: DNS stalled after 3 attempts, soft-reloading proxy runtime", "warn");
|
|
safe_proxy_restart("dns_stalled");
|
|
ai_heal_report(
|
|
"dns",
|
|
"DNS resolution stalled on sing-box (port 53)",
|
|
"Soft-reloaded proxy runtime safely without breaking active TCP/RDP connections",
|
|
"fixed"
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
if (!ev.payload.direct_ok) return;
|
|
|
|
let proxy_addr = "127.0.0.1:" + as_string(ev.payload.port);
|
|
ai_heal_report(
|
|
"proxy",
|
|
"Зависание или неполный отклик прокси-порту sing-box (" + proxy_addr + ")",
|
|
"Очищена база cache.db и выполнен перезапуск sing-box",
|
|
"fixed"
|
|
);
|
|
remove_file("/tmp/sing-box/cache.db");
|
|
safe_proxy_restart("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, /^community-subnets-.+\.lst$/)) 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;
|
|
// Ensure /tmp dir exists
|
|
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 </dev/null >/dev/null 2>&1 &");
|
|
}
|
|
|
|
|
|
// ─── 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) {
|
|
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
|
if (tcfg.notify_crash != "0") {
|
|
send_telegram_notification("⚠️ *Watchdog:* WAN/Gateway проблема. Перезапуск wan...");
|
|
}
|
|
bg_system("/sbin/ifdown wan >/dev/null 2>&1 && /sbin/ifup wan >/dev/null 2>&1 &");
|
|
}
|
|
|
|
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) + "). Обновите подписку вручную.");
|
|
}
|
|
}
|
|
|
|
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...");
|
|
}
|
|
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");
|
|
safe_proxy_restart("uci_config_restore");
|
|
}
|
|
}
|
|
|
|
// ─── AI Settings helpers ──────────────────────────────────────────────────────
|
|
function ai_setting(key, default_val) {
|
|
let cfg = settings();
|
|
let val = cfg[key];
|
|
return val != null ? as_string(val) : as_string(default_val);
|
|
}
|
|
function ai_enabled(key, default_val) {
|
|
return ai_setting(key, default_val) == "1";
|
|
}
|
|
|
|
// ─── Config validation: sing-box check ────────────────────────────────────────
|
|
function validate_singbox_config() {
|
|
if (!ai_enabled("ai_config_validation_enabled", "1")) return true;
|
|
return command_success_from_args(["sing-box", "check", "-c", "/etc/sing-box/config.json"]);
|
|
}
|
|
|
|
// ─── 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 (!ai_enabled("ai_proxy_health_enabled", "1")) return;
|
|
|
|
let threshold = int(ai_setting("ai_proxy_health_fail_threshold", "3"));
|
|
let fails = int(ev.payload.streak);
|
|
if (fails < threshold) return;
|
|
|
|
ai_heal_report(
|
|
"proxy_health",
|
|
sprintf("Proxy health check failed %d times consecutively (port %s)", fails, as_string(ev.payload.port)),
|
|
"Restarting Tachyon to restore proxy connectivity",
|
|
"fixed"
|
|
);
|
|
// A restart on a config sing-box will refuse to load leaves the proxy down
|
|
// for good, so validation gates the restart, not the report.
|
|
if (ai_enabled("ai_config_validation_enabled", "1") && !validate_singbox_config()) {
|
|
log_message("sing-box config validation failed before proxy restart", "err");
|
|
return;
|
|
}
|
|
safe_proxy_restart("proxy_health");
|
|
controller.reset_proxy_consecutive();
|
|
}
|
|
|
|
// ─── 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 (!ai_enabled("ai_dns_continuous_enabled", "1")) return;
|
|
if (int(ev.payload.consecutive) < 3) return;
|
|
|
|
ai_heal_report(
|
|
"dns_continuous",
|
|
"DNS resolution failed 3 times consecutively",
|
|
"Restoring dnsmasq config and reloading",
|
|
"fixed"
|
|
);
|
|
system("/sbin/uci set dhcp.@dnsmasq[0].noresolv='1' >/dev/null 2>&1");
|
|
system("/sbin/uci commit dhcp >/dev/null 2>&1");
|
|
system("/etc/init.d/dnsmasq reload >/dev/null 2>&1");
|
|
controller.reset_dns_consecutive();
|
|
}
|
|
|
|
// ─── 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) + " </dev/null >/dev/null 2>&1 1000<&- &");
|
|
}
|
|
|
|
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 is_dns_dead() {
|
|
return !is_dns_working();
|
|
}
|
|
|
|
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() {
|
|
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 (!ai_enabled("ai_dns_loop_heal_enabled", "1")) 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;
|
|
|
|
// Check if DNS is dead
|
|
if (!is_dns_dead()) {
|
|
// 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;
|
|
let test_dns = is_dns_working();
|
|
if (test_dns) {
|
|
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");
|
|
safe_proxy_restart("dns_loop_recovery");
|
|
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) + " </dev/null >/dev/null 2>&1 1000<&- &");
|
|
return;
|
|
}
|
|
|
|
// Check if detour section is empty
|
|
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);
|
|
|
|
if (usable > 0) {
|
|
// Section has outbounds but DNS is still dead — may be transient
|
|
return;
|
|
}
|
|
|
|
// Detour section empty AND DNS dead — disable DNS detour to recover
|
|
log_message("DNS loop detected: DNS detour section '" + detour_section + "' is empty, 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");
|
|
write_dns_recovery_state({ phase: "detour_disabled", section: detour_section, ts: now });
|
|
|
|
// Trigger subscription update for the empty section
|
|
bg_system("/usr/bin/tachyon subscription_update_async " + common.shell_quote(detour_section) + " </dev/null >/dev/null 2>&1 1000<&- &");
|
|
|
|
ai_heal_report(
|
|
"dns_loop_detected",
|
|
"DNS loop: detour section '" + detour_section + "' has 0 outbounds, DNS completely dead",
|
|
"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 (!ai_enabled("ai_metrics_enabled", "1")) return;
|
|
|
|
let now = time();
|
|
let metrics_path = "/tmp/tachyon_metrics.json";
|
|
let data = { hours: [] };
|
|
let existing = fs.readfile(metrics_path);
|
|
if (existing) {
|
|
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 = proxy_consecutive_fails() == 0;
|
|
last_bucket.proxy_lat_ms = average_latency(proxy_latency_history());
|
|
last_bucket.dns_lat_ms = average_latency(dns_latency_history());
|
|
last_bucket.incidents = ai_incidents_count;
|
|
} else {
|
|
push(data.hours, {
|
|
ts: hour_bucket,
|
|
proxy_ok: proxy_consecutive_fails() == 0,
|
|
proxy_lat_ms: average_latency(proxy_latency_history()),
|
|
dns_lat_ms: average_latency(dns_latency_history()),
|
|
incidents: ai_incidents_count
|
|
});
|
|
}
|
|
|
|
let retention = int(ai_setting("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 (!ai_enabled("ai_anomaly_detection_enabled", "1")) 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 ───────────────────────────────────────────────────────
|
|
function adaptive_normal_interval() {
|
|
return controller.adaptive_normal_interval();
|
|
}
|
|
|
|
// ─── 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) {
|
|
if (!ai_enabled("ai_graceful_degradation_enabled", "1")) {
|
|
fn();
|
|
return;
|
|
}
|
|
try {
|
|
fn();
|
|
} catch(e) {
|
|
log_message("Graceful degradation: " + name + " failed: " + as_string(e), "err");
|
|
}
|
|
}
|
|
|
|
// ─── rpcd FD leak ─────────────────────────────────────────────────────────────
|
|
function heal_rpcd(ev) {
|
|
ai_heal_report(
|
|
"rpcd_fd_leak",
|
|
sprintf("rpcd накопил %d открытых FD (порог %d/1024) — LuCI не может запускать команды",
|
|
int(ev.payload.fd_count), int(ev.payload.threshold)),
|
|
"Выполнен перезапуск rpcd для освобождения файловых дескрипторов",
|
|
"fixed"
|
|
);
|
|
system("/etc/init.d/rpcd restart </dev/null >/dev/null 2>&1");
|
|
}
|
|
|
|
// ─── 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");
|
|
}
|
|
|
|
// 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", "http://127.0.0.1:4534/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) {}
|
|
}
|
|
}
|
|
|
|
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 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) {
|
|
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;
|
|
|
|
let proxy_addr = "127.0.0.1:4534";
|
|
let sb_cfg_data = fs.readfile("/etc/sing-box/config.json");
|
|
if (sb_cfg_data) {
|
|
try {
|
|
let sb_cfg = json(sb_cfg_data);
|
|
if (sb_cfg.inbounds) {
|
|
for (let inb in sb_cfg.inbounds) {
|
|
if (inb.type == "http" || inb.type == "mixed") {
|
|
proxy_addr = "127.0.0.1:" + as_string(inb.listen_port || 4534);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
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) {
|
|
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 = "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 </dev/null >/dev/null 2>&1 1000<&- & echo $! > /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");
|
|
try { fs.writefile(scale_path, sprintf("%.2f", new_scale)); } catch(e) {}
|
|
}
|
|
|
|
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.
|
|
// Use pkill to target only logread in follow mode, not one-shot logread calls.
|
|
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;
|
|
// Track the FD so bg_system() can close it before spawning background processes
|
|
logread_pipe_fd = log_pipe.fileno();
|
|
|
|
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;
|
|
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: 10, cooldown: 300 });
|
|
|
|
subscribe(EV.SINGBOX_STOPPED, heal_singbox_stopped,
|
|
{ name: "heal_singbox_stopped", priority: 20 });
|
|
|
|
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: 40, cooldown: 120 });
|
|
subscribe(EV.PROXY_DOWN, heal_proxy_health,
|
|
{ name: "heal_proxy_health", priority: 41 });
|
|
|
|
// 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: 50, cooldown: 120 });
|
|
subscribe(EV.DNS_DOWN, heal_dns_continuous,
|
|
{ name: "heal_dns_continuous", priority: 51 });
|
|
|
|
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");
|
|
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();
|
|
|
|
// Ensure /etc/tachyon exists for persistent smart detect
|
|
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 != "") {
|
|
try { fs.writefile("/etc/backup/tachyon_config", current_cfg); } catch(e) {}
|
|
}
|
|
|
|
// H-8: Write keepalive timestamp for init.d supervision
|
|
try { fs.writefile("/var/run/tachyon_watchdog.keepalive", as_string(time())); } catch(e) {}
|
|
|
|
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();
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function perform_slow_checks() {
|
|
controller.probe_slow();
|
|
safe_call(ai_heal_dns_loop, "ai_heal_dns_loop");
|
|
}
|
|
|
|
if (uloop) {
|
|
let tick;
|
|
tick = function() {
|
|
let now = time();
|
|
try {
|
|
try { fs.writefile("/var/run/tachyon_watchdog.keepalive", as_string(time())); } catch(e) {}
|
|
if (now - last_fast_check >= 15) {
|
|
last_fast_check = now;
|
|
perform_fast_checks();
|
|
}
|
|
if (now - last_normal_check >= 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");
|
|
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();
|
|
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 = proxy_consecutive_fails();
|
|
let dns_fails = dns_consecutive_fails();
|
|
let proxy_ok = proxy_fails == 0;
|
|
let dns_ok = dns_fails == 0;
|
|
|
|
let sb_uptime = 0;
|
|
let sb_pid = 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;
|
|
try { uptime_seconds = int(trim(fs.readfile("/proc/uptime") || "0")); } catch(e) {}
|
|
sb_uptime = uptime_seconds - int(starttime / clk_tck);
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
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(proxy_latency_history()),
|
|
proxy_consecutive_fails: proxy_fails,
|
|
dns_ok: dns_ok,
|
|
dns_latency_ms: average_latency(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: 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);
|
|
}
|