fix(updates): hoist reload_singbox_after_list_update and persist compiled rulesets to /etc/tachyon/rulesets

This commit is contained in:
Dushnilin
2026-09-12 18:24:50 +03:00
parent 12b07fd2fb
commit d72d3de706
12 changed files with 334 additions and 318 deletions
+120 -108
View File
@@ -2329,9 +2329,19 @@ function ensure_ruleset_source(path) {
}
function cleanup_empty_ruleset(path) {
if (routing_rulesets_module().has_rules(path))
let slash = rindex(path, "/");
let filename = slash >= 0 ? substr(path, slash + 1) : path;
let persistent_path = "/etc/tachyon/rulesets/" + filename;
if (routing_rulesets_module().has_rules(path)) {
if (filename != "") {
ensure_dir("/etc/tachyon/rulesets");
copy_file(path, persistent_path);
}
return true;
}
remove_file(path);
if (filename != "")
remove_file(persistent_path);
return false;
}
@@ -2787,6 +2797,7 @@ function import_domains_from_remote_domain_lists(section, settings) {
if (!import_domains_from_remote_plain_file(url, section, settings))
ok = false;
}
cleanup_empty_ruleset(remote_ruleset_path(section, "domains"));
return ok;
}
@@ -2885,6 +2896,7 @@ function import_subnets_from_remote_subnet_lists(section, settings) {
ok = false;
}
}
cleanup_empty_ruleset(remote_ruleset_path(section, "subnets"));
return ok;
}
@@ -3003,6 +3015,113 @@ function update_list_status(running, success, progress, message) {
});
}
function subscription_cache_env() {
return {
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TMP_SING_BOX_FOLDER,
TMP_RULESET_FOLDER,
TMP_SUBSCRIPTION_FOLDER,
TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR,
TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR,
TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR,
TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR,
TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR,
TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR,
TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE,
TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT,
TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE,
TACHYON_SERVICE_INIT: SERVICE_INIT,
SB_SERVICE_MIXED_INBOUND_ADDRESS,
SB_SERVICE_MIXED_INBOUND_PORT,
SB_VARIANT_STATE_FILE
};
}
function module_env_capture(env, args) {
let output_path = temp_path();
if (output_path == "")
return { status: 1, output: "" };
let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1");
let output = as_string(fs.readfile(output_path) || "");
remove_file(output_path);
return { status, output };
}
function subscription_cache_capture(args) {
let command_args = [ LIB_DIR + "/subscription/cache.uc" ];
for (let arg in args)
push(command_args, arg);
return module_env_capture(subscription_cache_env(), command_args);
}
function subscription_cache_success(args) {
let result = subscription_cache_capture(args);
if (result.output != "")
for (let line in split(result.output, "\n"))
if (trim(as_string(line)) != "")
log_message("subscription cache: " + line, "debug");
return result.status == 0;
}
function log_file_lines_from_text(text, level, prefix) {
for (let line in split(as_string(text), "\n"))
if (trim(as_string(line)) != "")
log_message(as_string(prefix) + as_string(line), level);
}
function singbox_runtime_success(args) {
let command_args = [ LIB_DIR + "/singbox/runtime.uc" ];
for (let arg in args)
push(command_args, arg);
let result = module_env_capture(subscription_cache_env(), command_args);
if (result.output != "")
log_file_lines_from_text(result.output, "debug", "sing-box runtime: ");
return result.status == 0;
}
function write_current_reload_state_clean() {
return service_state_success([
"write-current-reload-state-clean",
RELOAD_STATE_FILE,
RELOAD_STATE_FORMAT,
RULE_CONDITION_CACHE_DIR
]);
}
function reload_singbox_after_list_update() {
let sing_box_pid = trim(module_output([ LIB_DIR + "/service/state.uc", "sing-box-service-runtime-pid" ]));
if (sing_box_pid == "" || int(sing_box_pid) <= 0)
return true;
let sing_box_config_path = option(uci_settings(), "config_path", "") || "/etc/sing-box/config.json";
let sing_box_config_hash_before = file_md5(sing_box_config_path);
if (!singbox_runtime_success([ "init-config", "0", "1", "1" ]))
return false;
let sing_box_config_hash_after = file_md5(sing_box_config_path);
if (sing_box_config_hash_before != sing_box_config_hash_after) {
log_message("Rulesets updated on disk; reloading sing-box with new configuration", "info");
module_success([ DNS_FAILOVER_UC, "stop-runtime" ]);
module_success([ PRIORITY_UC, "stop-runtime" ]);
let ok = service_state_success([
"reload-sing-box-runtime",
sing_box_pid,
sing_box_config_hash_before,
sing_box_config_hash_after,
"1"
]);
module_success([ PRIORITY_UC, "start-runtime" ]);
module_success([ DNS_FAILOVER_UC, "start-runtime" ]);
write_current_reload_state_clean();
return ok;
}
return true;
}
function list_update() {
log_message("Starting lists update", "info");
if (!list_update_pid_begin())
@@ -3172,113 +3291,6 @@ function stop_list_update() {
remove_file(LIST_UPDATE_PID_FILE);
}
function subscription_cache_env() {
return {
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TMP_SING_BOX_FOLDER,
TMP_RULESET_FOLDER,
TMP_SUBSCRIPTION_FOLDER,
TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR,
TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR,
TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR,
TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR,
TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR,
TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR,
TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE,
TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT,
TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE,
TACHYON_SERVICE_INIT: SERVICE_INIT,
SB_SERVICE_MIXED_INBOUND_ADDRESS,
SB_SERVICE_MIXED_INBOUND_PORT,
SB_VARIANT_STATE_FILE
};
}
function module_env_capture(env, args) {
let output_path = temp_path();
if (output_path == "")
return { status: 1, output: "" };
let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1");
let output = as_string(fs.readfile(output_path) || "");
remove_file(output_path);
return { status, output };
}
function subscription_cache_capture(args) {
let command_args = [ LIB_DIR + "/subscription/cache.uc" ];
for (let arg in args)
push(command_args, arg);
return module_env_capture(subscription_cache_env(), command_args);
}
function subscription_cache_success(args) {
let result = subscription_cache_capture(args);
if (result.output != "")
for (let line in split(result.output, "\n"))
if (trim(as_string(line)) != "")
log_message("subscription cache: " + line, "debug");
return result.status == 0;
}
function log_file_lines_from_text(text, level, prefix) {
for (let line in split(as_string(text), "\n"))
if (trim(as_string(line)) != "")
log_message(as_string(prefix) + as_string(line), level);
}
function singbox_runtime_success(args) {
let command_args = [ LIB_DIR + "/singbox/runtime.uc" ];
for (let arg in args)
push(command_args, arg);
let result = module_env_capture(subscription_cache_env(), command_args);
if (result.output != "")
log_file_lines_from_text(result.output, "debug", "sing-box runtime: ");
return result.status == 0;
}
function write_current_reload_state_clean() {
return service_state_success([
"write-current-reload-state-clean",
RELOAD_STATE_FILE,
RELOAD_STATE_FORMAT,
RULE_CONDITION_CACHE_DIR
]);
}
function reload_singbox_after_list_update() {
let sing_box_pid = trim(module_output([ LIB_DIR + "/service/state.uc", "sing-box-service-runtime-pid" ]));
if (sing_box_pid == "" || int(sing_box_pid) <= 0)
return true;
let sing_box_config_path = option(uci_settings(), "config_path", "") || "/etc/sing-box/config.json";
let sing_box_config_hash_before = file_md5(sing_box_config_path);
if (!singbox_runtime_success([ "init-config", "0", "1", "1" ]))
return false;
let sing_box_config_hash_after = file_md5(sing_box_config_path);
if (sing_box_config_hash_before != sing_box_config_hash_after) {
log_message("Rulesets updated on disk; reloading sing-box with new configuration", "info");
module_success([ DNS_FAILOVER_UC, "stop-runtime" ]);
module_success([ PRIORITY_UC, "stop-runtime" ]);
let ok = service_state_success([
"reload-sing-box-runtime",
sing_box_pid,
sing_box_config_hash_before,
sing_box_config_hash_after,
"1"
]);
module_success([ PRIORITY_UC, "start-runtime" ]);
module_success([ DNS_FAILOVER_UC, "start-runtime" ]);
write_current_reload_state_clean();
return ok;
}
return true;
}
function mark_pending_reload(reason) {
service_state_success([ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]);
}
+8 -8
View File
@@ -663,6 +663,14 @@ function dscp_value(section) {
return list_option_value_from_array(dscp_list(section));
}
function contains(arr, val) {
if (type(arr) != "array") return false;
for (let item in arr) {
if (item == val) return true;
}
return false;
}
function routed_dns_enabled(section) {
return bool_option(section, "routed_dns_enabled", false);
}
@@ -683,14 +691,6 @@ function routed_dns_servers(section) {
return servers;
}
function contains(arr, val) {
if (type(arr) != "array") return false;
for (let item in arr) {
if (item == val) return true;
}
return false;
}
function geoip_country_list(section) {
let raw = whitespace_list_value(section, "geoip_country");
let result = [];
+4 -4
View File
@@ -2156,6 +2156,10 @@ function is_container_process(pid) {
return false;
}
function uci_settings() {
return uci_core.get_all(CONFIG_NAME, "settings") || {};
}
function is_adguardhome_primary_dns(cfg) {
let agh_pid = find_process_pid("AdGuardHome");
if (agh_pid == "")
@@ -2187,10 +2191,6 @@ function is_degraded() {
return fs.stat("/tmp/tachyon/degraded") != null;
}
function uci_settings() {
return uci_core.get_all(CONFIG_NAME, "settings") || {};
}
function kill_our_core_processes() {
command_status("/etc/init.d/sing-box stop >/dev/null 2>&1");
command_status("killall -9 sing-box >/dev/null 2>&1");
@@ -134,23 +134,23 @@ function validate_section(section) {
let provider = as_string(section.provider || "");
if (provider != "" && !array_contains(OLCRTC_PROVIDERS, provider))
errors.push("provider must be one of: " + join(", ", OLCRTC_PROVIDERS));
push(errors, "provider must be one of: " + join(", ", OLCRTC_PROVIDERS));
let transport = as_string(section.transport || "");
if (transport != "" && !array_contains(OLCRTC_TRANSPORTS, transport))
errors.push("transport must be one of: " + join(", ", OLCRTC_TRANSPORTS));
push(errors, "transport must be one of: " + join(", ", OLCRTC_TRANSPORTS));
let crypto_key = as_string(section.crypto_key || "");
if (crypto_key != "" && !valid_hex_key(crypto_key))
errors.push("crypto_key must be 64 hex characters");
push(errors, "crypto_key must be 64 hex characters");
let socks_port = as_string(section.socks_port || "");
if (socks_port != "" && !valid_number(socks_port, 1, 65535))
errors.push("socks_port must be 1-65535");
push(errors, "socks_port must be 1-65535");
let dns_server = as_string(section.dns_server || "");
if (dns_server != "" && !valid_host_port(dns_server))
errors.push("dns_server must be in HOST:PORT format");
push(errors, "dns_server must be in HOST:PORT format");
let subscription_links = list_values(section.subscription_links);
for (let link in subscription_links) {
@@ -160,26 +160,15 @@ function validate_section(section) {
if (substr(link, 0, 9) == "olcrtc://") {
let parsed = parse_uri(link);
if (!parsed.valid)
errors.push("invalid olcrtc:// URI: " + parsed.reason);
push(errors, "invalid olcrtc:// URI: " + parsed.reason);
}
else if (!valid_url(link))
errors.push("subscription link must be olcrtc:// URI or HTTP URL: " + link);
push(errors, "subscription link must be olcrtc:// URI or HTTP URL: " + link);
}
return errors;
}
function validate_section_json() {
let input = read_stdin();
let data = object_or_empty(json(input));
let errors = validate_section(data);
if (length(errors) > 0)
write_json({ valid: false, message: join("; ", errors) });
else
write_json({ valid: true, message: "" });
return length(errors) == 0;
}
function read_stdin() {
let input = fs.open("/dev/stdin", "r");
if (!input)
@@ -193,6 +182,17 @@ function write_json(value) {
printf("%s\n", sprintf("%J", value));
}
function validate_section_json() {
let input = read_stdin();
let data = object_or_empty(json(input));
let errors = validate_section(data);
if (length(errors) > 0)
write_json({ valid: false, message: join("; ", errors) });
else
write_json({ valid: true, message: "" });
return length(errors) == 0;
}
return {
validate_section,
validate_section_json,
@@ -150,27 +150,27 @@ function validate_section(section) {
let peer = as_string(section.peer || "");
if (peer != "" && !valid_peer(peer))
errors.push("peer must be in HOST:PORT format");
push(errors, "peer must be in HOST:PORT format");
let mode = as_string(section.mode || "");
if (!valid_mode(mode))
errors.push("mode must be one of: " + join(", ", WDTT_MODES));
push(errors, "mode must be one of: " + join(", ", WDTT_MODES));
let qwdtt_mode = as_string(section.qwdtt_mode || "");
if (!valid_qwdtt_mode(qwdtt_mode))
errors.push("qwdtt_mode must be one of: " + join(", ", QWDTT_MODES));
push(errors, "qwdtt_mode must be one of: " + join(", ", QWDTT_MODES));
let workers = as_string(section.workers || "");
if (workers != "" && !valid_number(workers, 1, 1024))
errors.push("workers must be 1-1024");
push(errors, "workers must be 1-1024");
let max_hashes = as_string(section.max_hashes || "");
if (max_hashes != "" && !valid_number(max_hashes, 1, 64))
errors.push("max_hashes must be 1-64");
push(errors, "max_hashes must be 1-64");
let mtu = as_string(section.mtu || "");
if (mtu != "" && !valid_number(mtu, 576, 1500))
errors.push("mtu must be 576-1500");
push(errors, "mtu must be 576-1500");
let subscription_links = list_values(section.subscription_links);
for (let link in subscription_links) {
@@ -180,35 +180,24 @@ function validate_section(section) {
if (substr(link, 0, 7) == "wdtt://") {
let parsed = parse_wdtt_uri(link);
if (!parsed.valid)
errors.push("invalid wdtt:// URI: " + parsed.reason);
push(errors, "invalid wdtt:// URI: " + parsed.reason);
}
else if (substr(link, 0, 1) == "/") {
/* local file — ok */
}
else if (!valid_url(link))
errors.push("subscription link must be HTTP URL, wdtt:// URI, or local file path: " + link);
push(errors, "subscription link must be HTTP URL, wdtt:// URI, or local file path: " + link);
}
let community_lists = list_values(section.community_lists);
for (let cl in community_lists) {
if (!valid_community_list(cl))
errors.push("unknown community list: " + cl);
push(errors, "unknown community list: " + cl);
}
return errors;
}
function validate_section_json() {
let input = read_stdin();
let data = object_or_empty(json(input));
let errors = validate_section(data);
if (length(errors) > 0)
write_json({ valid: false, message: join("; ", errors) });
else
write_json({ valid: true, message: "" });
return length(errors) == 0;
}
function read_stdin() {
let input = fs.open("/dev/stdin", "r");
if (!input)
@@ -222,6 +211,17 @@ function write_json(value) {
printf("%s\n", sprintf("%J", value));
}
function validate_section_json() {
let input = read_stdin();
let data = object_or_empty(json(input));
let errors = validate_section(data);
if (length(errors) > 0)
write_json({ valid: false, message: join("; ", errors) });
else
write_json({ valid: true, message: "" });
return length(errors) == 0;
}
return {
validate_section,
validate_section_json,
+6 -6
View File
@@ -78,12 +78,6 @@ function get_agent_token() {
return as_string(c.get(CONFIG_NAME + ".settings.agent_api_token") || "");
}
function check_write_auth(bearer) {
let token = get_agent_token();
if (token == "") return false;
return constant_time_eq("Bearer " + token, bearer);
}
// Byte-wise comparison without early exit so response timing does not leak
// how many leading token characters matched.
function constant_time_eq(a, b) {
@@ -100,6 +94,12 @@ function constant_time_eq(a, b) {
return diff == 0;
}
function check_write_auth(bearer) {
let token = get_agent_token();
if (token == "") return false;
return constant_time_eq("Bearer " + token, bearer);
}
// ─── READ endpoints ───────────────────────────────────────────────────────────
function handle_health() {
+20 -20
View File
@@ -290,26 +290,6 @@ function run_leak_check_status(job_id) {
return leak_mod.get_leak_check_status(job_id);
}
function manage_domain_list(action_type, domain, do_delete) {
let c = uci_core.cursor();
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
c.load(CONFIG_NAME);
let target_section = null;
c.foreach(CONFIG_NAME, "section", function(s) {
if (s.enabled == "1" && s.action == action_type) {
target_section = s[".name"];
return false;
}
});
if (!target_section) {
return { success: false, error: "Не найдено активное правило с действием '" + action_type + "'." };
}
return manage_domain_list_by_section(target_section, domain, do_delete);
}
function manage_domain_list_by_section(sec_name, domain, do_delete) {
let c = uci_core.cursor();
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
@@ -354,6 +334,26 @@ function manage_domain_list_by_section(sec_name, domain, do_delete) {
}
}
function manage_domain_list(action_type, domain, do_delete) {
let c = uci_core.cursor();
if (!c) return { success: false, error: "Не удалось инициализировать UCI" };
c.load(CONFIG_NAME);
let target_section = null;
c.foreach(CONFIG_NAME, "section", function(s) {
if (s.enabled == "1" && s.action == action_type) {
target_section = s[".name"];
return false;
}
});
if (!target_section) {
return { success: false, error: "Не найдено активное правило с действием '" + action_type + "'." };
}
return manage_domain_list_by_section(target_section, domain, do_delete);
}
function get_sections() {
let c = uci_core.cursor();
if (!c) return [];
+17 -16
View File
@@ -174,6 +174,15 @@ function lock_dir_write_owner(lock_dir, owner_pid) {
return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n");
}
function release_runtime_dir_lock(lock_dir) {
lock_dir = as_string(lock_dir);
if (lock_dir == "")
return;
command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]);
command_success_from_args([ "rmdir", lock_dir ]);
}
function acquire_runtime_dir_lock(lock_dir, owner_pid) {
lock_dir = as_string(lock_dir);
owner_pid = as_string(owner_pid);
@@ -222,15 +231,6 @@ function acquire_runtime_dir_lock(lock_dir, owner_pid) {
return false;
}
function release_runtime_dir_lock(lock_dir) {
lock_dir = as_string(lock_dir);
if (lock_dir == "")
return;
command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]);
command_success_from_args([ "rmdir", lock_dir ]);
}
function mark_pending_reload(path, reason) {
path = as_string(path || PENDING_RELOAD_FILE);
reason = as_string(reason || "pending");
@@ -410,8 +410,14 @@ function restore_dnsmasq_failsafe() {
return module_status(DNS_APPLY_UC, [ "failsafe-restore" ]);
}
function begin_external_service_action(action, source, owner_pid) {
if (as_string(getenv("TACHYON_UI_ACTION_TRACKED") || "0") == "1")
function owner_pid_value() {
let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ]));
return match(pid, /^[0-9]+$/) != null ? pid : "0";
}
function begin_external_service_action(action, owner_pid, source) {
action = as_string(action);
if (action == "")
return "";
if (!file_exists(UI_UC))
return "";
@@ -429,11 +435,6 @@ function finish_external_service_action(action, job_id, status) {
return module_status(UI_UC, [ "service-action-finish-after-command", action, job_id, as_string(status) ]);
}
function owner_pid_value() {
let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ]));
return match(pid, /^[0-9]+$/) != null ? pid : "0";
}
function runtime_status_object() {
let data = command_output_from_args([ BIN_PATH, "get_status" ]);
try {
+9 -9
View File
@@ -257,6 +257,15 @@ function lock_dir_write_owner(lock_dir, owner_pid) {
return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n");
}
function release_runtime_dir_lock(lock_dir) {
lock_dir = as_string(lock_dir);
if (lock_dir == "")
return;
command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]);
command_success_from_args([ "rmdir", lock_dir ]);
}
function acquire_runtime_dir_lock(lock_dir, owner_pid) {
lock_dir = as_string(lock_dir);
owner_pid = as_string(owner_pid);
@@ -320,15 +329,6 @@ function acquire_runtime_dir_lock_wait(lock_dir, owner_pid, timeout) {
return true;
}
function release_runtime_dir_lock(lock_dir) {
lock_dir = as_string(lock_dir);
if (lock_dir == "")
return;
command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]);
command_success_from_args([ "rmdir", lock_dir ]);
}
// The two unlinks below clean up the temporary file after a failed write or
// rename. Both paths exit(1) immediately after, so the failure is already
// reported; an unlink that throws means the temp file was never created.
+67 -65
View File
@@ -266,27 +266,6 @@ function rotate_log_if_needed() {
fs.writefile(LOG_FILE, "--- log truncated (size cap) ---\n" + tail);
}
function alert_route_failure(attempt, proxy_alive) {
// One-shot per failure episode - the caller clears the flag as soon as
// polling recovers. Delivery rides on tg_request's own fallback chain:
// if the proxy route is what's broken, the message goes out directly.
let cfg = settings();
let token = trim(as_string(cfg.bot_token || ""));
if (token == "") return;
let admins = split(trim(as_string(cfg.admin_ids || "")), /,/);
let chat_id = trim(as_string(admins[0] || ""));
if (chat_id == "") return;
let hint = proxy_alive
? t("route_proxy_alive")
: t("route_proxy_down");
let text = "⚠️ <b>" + t("route_alert_title") + "</b> " + as_string(attempt) +
" " + t("route_alert_consecutive") + ".\n" +
t("route_reason") + " " + hint + ".\n" +
t("route_fallback_hint") + "\n" +
t("route_heal_hint");
send_message(token, chat_id, text, "HTML", null);
}
function tg_request_via(token, method, payload, proxy_args) {
if (!token) return null;
@@ -350,6 +329,28 @@ function send_message(token, chat_id, text, parse_mode, keyboard) {
return res;
}
function alert_route_failure(attempt, proxy_alive) {
// One-shot per failure episode - the caller clears the flag as soon as
// polling recovers. Delivery rides on tg_request's own fallback chain:
// if the proxy route is what's broken, the message goes out directly.
let cfg = settings();
let token = trim(as_string(cfg.bot_token || ""));
if (token == "") return;
let admins = split(trim(as_string(cfg.admin_ids || "")), /,/);
let chat_id = trim(as_string(admins[0] || ""));
if (chat_id == "") return;
let hint = proxy_alive
? t("route_proxy_alive")
: t("route_proxy_down");
let text = "⚠️ <b>" + t("route_alert_title") + "</b> " + as_string(attempt) +
" " + t("route_alert_consecutive") + ".\n" +
t("route_reason") + " " + hint + ".\n" +
t("route_fallback_hint") + "\n" +
t("route_heal_hint");
send_message(token, chat_id, text, "HTML", null);
}
function edit_message(token, chat_id, message_id, text, parse_mode, keyboard) {
text = as_string(text);
if (length(text) > 3900) {
@@ -2269,50 +2270,6 @@ function find_mac_block_rules(c, mac) {
return found;
}
function handle_toggle_mac(token, chat_id, mac_raw) {
let mac = normalize_mac(mac_raw);
if (!mac) {
send_message(token, chat_id, "❌ " + t("mac_invalid"), "HTML");
return view_devices(token, chat_id, null);
}
let c = uci_core.cursor();
if (!c) {
return send_message(token, chat_id, "❌ " + t("mac_firewall_open_failed"), "HTML",
[[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]]);
}
c.load("firewall");
let existing = find_mac_block_rules(c, mac);
let blocked_now;
if (length(existing) > 0) {
for (let name in existing)
c.delete("firewall", name);
blocked_now = false;
} else {
let sec = c.add("firewall", "rule");
if (!sec) {
return send_message(token, chat_id, "❌ " + t("mac_block_rule_failed"), "HTML",
[[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]]);
}
c.set("firewall", sec, "name", "Tachyon block " + mac);
c.set("firewall", sec, "src", "lan");
c.set("firewall", sec, "dest", "*");
c.set("firewall", sec, "proto", "all");
c.set("firewall", sec, "src_mac", mac);
c.set("firewall", sec, "target", "REJECT");
blocked_now = true;
}
c.commit("firewall");
// Reload in the background so the poll loop is not blocked by fw4
system(common.background_command("/etc/init.d/firewall reload"));
send_message(token, chat_id,
(blocked_now ? "🚫 Устройство <code>" : "🔓 Устройство <code>") + mac +
(blocked_now ? "</code> заблокировано." : "</code> разблокировано.") +
"\n\n<i>Правила firewall применяются в фоне (несколько секунд).</i>", "HTML");
return view_devices(token, chat_id, null);
}
function view_devices(token, chat_id, msg_id) {
let lease_file = "/tmp/dhcp.leases";
@@ -2362,6 +2319,51 @@ function view_devices(token, chat_id, msg_id) {
else send_message(token, chat_id, text, "HTML", keyboard);
}
function handle_toggle_mac(token, chat_id, mac_raw) {
let mac = normalize_mac(mac_raw);
if (!mac) {
send_message(token, chat_id, "❌ " + t("mac_invalid"), "HTML");
return view_devices(token, chat_id, null);
}
let c = uci_core.cursor();
if (!c) {
return send_message(token, chat_id, "❌ " + t("mac_firewall_open_failed"), "HTML",
[[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]]);
}
c.load("firewall");
let existing = find_mac_block_rules(c, mac);
let blocked_now;
if (length(existing) > 0) {
for (let name in existing)
c.delete("firewall", name);
blocked_now = false;
} else {
let sec = c.add("firewall", "rule");
if (!sec) {
return send_message(token, chat_id, "❌ " + t("mac_block_rule_failed"), "HTML",
[[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]]);
}
c.set("firewall", sec, "name", "Tachyon block " + mac);
c.set("firewall", sec, "src", "lan");
c.set("firewall", sec, "dest", "*");
c.set("firewall", sec, "proto", "all");
c.set("firewall", sec, "src_mac", mac);
c.set("firewall", sec, "target", "REJECT");
blocked_now = true;
}
c.commit("firewall");
// Reload in the background so the poll loop is not blocked by fw4
system(common.background_command("/etc/init.d/firewall reload"));
send_message(token, chat_id,
(blocked_now ? "🚫 Устройство <code>" : "🔓 Устройство <code>") + mac +
(blocked_now ? "</code> заблокировано." : "</code> разблокировано.") +
"\n\n<i>Правила firewall применяются в фоне (несколько секунд).</i>", "HTML");
return view_devices(token, chat_id, null);
}
function view_watchdog(token, chat_id, msg_id) {
let running = api.process_running_by_pidfile("/var/run/tachyon_watchdog.pid");
let text = "🐕 <b>Watchdog Tachyon</b>\n\n" +
+5 -5
View File
@@ -764,6 +764,11 @@ function action_state_from_dirs() {
};
}
function release_dir_lock(lock_dir) {
remove_file(as_string(lock_dir) + "/pid");
command_success_from_args([ "rmdir", lock_dir ]);
}
function acquire_dir_lock(lock_dir) {
lock_dir = as_string(lock_dir);
let owner_pid = current_pid();
@@ -799,11 +804,6 @@ function acquire_dir_lock(lock_dir) {
return false;
}
function release_dir_lock(lock_dir) {
remove_file(as_string(lock_dir) + "/pid");
command_success_from_args([ "rmdir", lock_dir ]);
}
function service_enabled() {
return file_executable("/etc/rc.d/S99" + SERVICE_NAME);
}
+40 -39
View File
@@ -527,45 +527,6 @@ function watch_recovery(key, incident, reason) {
}
// 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 ──────────────────────────────────────────
@@ -1348,6 +1309,46 @@ function increment_reconnect_count() {
fs.writefile(count_file, as_string(count) + "\n");
}
// 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");
}
}
// ─── Adaptive Intervals ───────────────────────────────────────────────────────
// ─── Graceful Degradation wrapper ─────────────────────────────────────────────