#!/usr/bin/env ucode let fs = require("fs"); let constants = require("core.constants"); let core_ip = require("core.ip"); let uci_core = require("core.uci"); let runtime_dns = require("singbox.dns"); let common = require("core.common"); let rag = require("diagnostics.rag"); const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constants.TACHYON_CONFIG_NAME || "tachyon"; const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; const TACHYON_VERSION = getenv("TACHYON_VERSION") || constants.TACHYON_VERSION || ""; const TACHYON_CONFIG = getenv("TACHYON_CONFIG") || constants.TACHYON_CONFIG || "/etc/config/" + CONFIG_NAME; const TACHYON_SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || constants.TACHYON_SERVICE_NAME || "tachyon"; const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "Dushnilin/tachyon"; const TACHYON_LUCI_VIEW_DIR = getenv("TACHYON_LUCI_VIEW_DIR") || constants.TACHYON_LUCI_VIEW_DIR || "/www/luci-static/resources/view/tachyon"; const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; const LOGREAD_LINE_LIMIT = "500"; const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; const SYSTEM_INFO_CACHE_TTL = int(getenv("TACHYON_SYSTEM_INFO_CACHE_TTL") || "3600"); const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constants.TMP_SING_BOX_FOLDER || "/tmp/sing-box"; const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constants.TMP_RULESET_FOLDER || TMP_SING_BOX_FOLDER + "/rulesets"; const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constants.TMP_SUBSCRIPTION_FOLDER || TMP_SING_BOX_FOLDER + "/subscriptions"; const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; const CHECK_PROXY_IP_DOMAIN = getenv("CHECK_PROXY_IP_DOMAIN") || constants.CHECK_PROXY_IP_DOMAIN || "ip.podkop.fyi"; const FAKEIP_TEST_DOMAIN = getenv("FAKEIP_TEST_DOMAIN") || constants.FAKEIP_TEST_DOMAIN || "fakeip.podkop.fyi"; const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || constants.RT_TABLE_NAME || "tachyon"; const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "TachyonTable"; const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || constants.NFT_FAKEIP_MARK || "0x04000000"; const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || constants.NFT_COMMON_SET_NAME || "tachyon_subnets"; const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || constants.NFT_PORT_SET_NAME || "tachyon_ports"; const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || constants.NFT_IP_PORT_SET_NAME || "tachyon_ip_ports"; const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || constants.NFT_INTERFACE_SET_NAME || "tachyon_interfaces"; const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || constants.NFT_DISCORD_SET_NAME || "tachyon_discord_subnets"; const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || constants.NFT_LOCALV4_SET_NAME || "localv4"; const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || constants.SB_DNS_INBOUND_ADDRESS || "127.0.0.42"; const SB_TPROXY_INBOUND6_ADDRESS = getenv("SB_TPROXY_INBOUND6_ADDRESS") || constants.SB_TPROXY_INBOUND6_ADDRESS || "::1"; const SB_TPROXY_INBOUND_PORT = getenv("SB_TPROXY_INBOUND_PORT") || constants.SB_TPROXY_INBOUND_PORT || "1602"; const SB_CLASH_API_CONTROLLER_PORT = getenv("SB_CLASH_API_CONTROLLER_PORT") || constants.SB_CLASH_API_CONTROLLER_PORT || "9090"; const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || constants.SB_VARIANT_STATE_FILE || "/etc/tachyon/sing-box-variant"; const SING_BOX_BIN_PATH = getenv("TACHYON_DIAGNOSTICS_SING_BOX_BIN_PATH") || "/usr/bin/sing-box"; const CLOUDFLARE_OCTETS = getenv("CLOUDFLARE_OCTETS") || constants.CLOUDFLARE_OCTETS || "8.47 162.159 188.114"; const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = getenv("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT") || constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT || ""; const DEFAULT_LATENCY_TEST_URL = getenv("DEFAULT_LATENCY_TEST_URL") || "https://www.gstatic.com/generate_204"; const RUNTIME_STABLE_MIN_AGE = getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2"; const STATUS_UC = LIB_DIR + "/diagnostics/status.uc"; const HELPERS_UC = LIB_DIR + "/core/helpers.uc"; const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; const SERVICE_STATE_UC = LIB_DIR + "/service/state.uc"; const SERVICE_UI_UC = LIB_DIR + "/service/ui.uc"; const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; const PROVIDERS_STATUS_UC = LIB_DIR + "/providers/status.uc"; const SINGBOX_RUNTIME_UC = LIB_DIR + "/singbox/runtime.uc"; const ZAPRET_RUNTIME_UC = LIB_DIR + "/providers/zapret/runtime.uc"; const ZAPRET2_RUNTIME_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; const BYEDPI_RUNTIME_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; const TAILSCALE_RUNTIME_UC = LIB_DIR + "/providers/tailscale/runtime.uc"; const ZAPRET_VALIDATOR_UC = LIB_DIR + "/providers/zapret/validator.uc"; const ZAPRET2_VALIDATOR_UC = LIB_DIR + "/providers/zapret2/validator.uc"; const BYEDPI_VALIDATOR_UC = LIB_DIR + "/providers/byedpi/validator.uc"; let as_string = common.as_string; let shell_quote = common.shell_quote; let command_from_args = common.command_from_args; let command_status = common.command_status; let command_output = common.command_output; let command_success = common.command_success; let command_output_from_args = common.command_output_from_args; let command_success_from_args = common.command_success_from_args; let object_or_empty = common.object_or_empty; let read_stdin = common.read_stdin; let read_json_file = common.read_json_file; let write_json = common.write_json; function normalize_status(status) { status = int(status); if (status == -1) return 255; let signal = status & 127; if (signal != 0) return 128 + signal; return (status >> 8) & 255; } function arg_number(value) { value = as_string(value); return value == "" || match(value, /[^0-9-]/) != null ? 0 : int(value, 10); } function arg_bool(value) { value = lc(as_string(value)); return value == "1" || value == "true" || value == "yes" || value == "on"; } const UCI_BACKUP_DIR = "/etc/backup"; const UCI_BACKUP_PATH = UCI_BACKUP_DIR + "/tachyon_config"; function command_capture(command) { let pipe = fs.popen(command, "r"); if (!pipe) return { status: 1, output: "" }; let data = pipe.read("all"); let status = normalize_status(pipe.close()); return { status, output: data == null ? "" : as_string(data) }; } // The inner unlinks clean up the temp file after a failed write or rename; // both paths return false, so the failure is already reported. The outer catch // covers an unreadable /etc/tachyon or an unwritable backup directory, which // the caller treats as "no backup available". function uci_backup_save() { try { let data = fs.readfile(TACHYON_CONFIG); if (data == null || data == "") return false; fs.mkdir(UCI_BACKUP_DIR); let tmp = UCI_BACKUP_PATH + ".tmp"; if (fs.writefile(tmp, data) == null) { try { fs.unlink(tmp); } catch(e) {} return false; } if (!fs.rename(tmp, UCI_BACKUP_PATH)) { try { fs.unlink(tmp); } catch(e) {} return false; } return true; } catch (e) { return false; } } // Same shape as uci_backup_save above: the inner unlinks clean up after a // failed write or rename, and both paths return false. function uci_backup_restore() { try { let data = fs.readfile(UCI_BACKUP_PATH); if (data == null || data == "") return false; let tmp = TACHYON_CONFIG + ".tmp"; if (fs.writefile(tmp, data) == null) { try { fs.unlink(tmp); } catch(e) {} return false; } if (!fs.rename(tmp, TACHYON_CONFIG)) { try { fs.unlink(tmp); } catch(e) {} return false; } return true; } catch (e) { return false; } } function uci_config_valid() { let data = fs.readfile(TACHYON_CONFIG); if (data == null || data == "") return false; let res = command_status("uci -c /etc/config valid " + CONFIG_NAME + " >/dev/null 2>&1"); return res == 0; } function command_exists(name) { return command_success_from_args([ "command", "-v", as_string(name) ]); } function parse_json_or_null(value) { try { return json(as_string(value)); } catch (e) { return null; } } function words(value) { value = trim(as_string(value)); return value == "" ? [] : split(value, /[ \t\r\n]+/); } function push_unique(result, seen, value) { value = as_string(value); if (value == "" || seen[value]) return; seen[value] = true; push(result, value); } function network_status_ip_addresses(data, key) { let value = parse_json_or_null(data); let addresses = type(value) == "object" ? value[key] : null; let result = []; let seen = {}; if (type(addresses) == "array") { for (let item in addresses) { if (type(item) == "object") push_unique(result, seen, item.address || ""); } } return result; } function get_wan_ip_addresses() { let result = []; let seen = {}; for (let interface in [ "wan", "wwan" ]) { let data = command_output_from_args([ "ubus", "-S", "call", "network.interface." + interface, "status" ]); for (let ip in network_status_ip_addresses(data, "ipv4-address")) push_unique(result, seen, ip); for (let ip in network_status_ip_addresses(data, "ipv6-address")) push_unique(result, seen, ip); } let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]); let fields = words(route); let iface = ""; for (let i = 0; i + 1 < length(fields); i++) { if (fields[i] == "dev") { iface = fields[i + 1]; break; } } if (iface == "") return ""; let addr = command_output_from_args([ "ip", "-4", "addr", "show", "dev", iface ]); for (let line in split(addr, "\n")) { line = trim(as_string(line)); let matched = match(line, /^inet[ \t]+([0-9.]+)\//); if (matched != null) push_unique(result, seen, matched[1]); } return join(" ", result); } function get_wan_interface() { let iface = trim(as_string(uci_core.get(CONFIG_NAME + ".settings.output_network_interface"))); if (iface != "") return iface; iface = trim(as_string(uci_core.get("network.wan.device"))); if (iface != "") return iface; iface = trim(as_string(uci_core.get("network.wan.ifname"))); if (iface != "") return iface; let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]); let fields = words(route); for (let i = 0; i + 1 < length(fields); i++) { if (fields[i] == "dev") { iface = fields[i + 1]; break; } } if (iface != "") return iface; return "eth0"; } function default_gateway_exists() { let out = command_capture("ip route 2>/dev/null").output; return index(out, "default") >= 0; } function wan_has_ip() { let wan_ips = get_wan_ip_addresses(); if (wan_ips != "") return true; let iface = get_wan_interface(); let out = command_capture("ip addr show " + shell_quote(iface) + " 2>/dev/null").output; if (index(out, "inet ") >= 0) return true; let ubus_status = command_output_from_args([ "ubus", "-S", "call", "network.interface.wan", "status" ]); let status_json = parse_json_or_null(ubus_status); if (type(status_json) == "object" && status_json.up === true) return true; return default_gateway_exists(); } function get_all_dns_servers(cfg, key) { let servers = []; let raw = cfg[key]; if (type(raw) == "array") { for (let s in raw) { let trimmed = trim(as_string(s)); if (trimmed != "") push(servers, split(trimmed, "#")[0]); } } else if (raw && trim(as_string(raw)) != "") { for (let s in split(trim(as_string(raw)), /\s+/)) { let trimmed = trim(s); if (trimmed != "") push(servers, split(trimmed, "#")[0]); } } return servers; } function module_args(module_path, args) { let result = [ "ucode", "-L", LIB_DIR, "--", module_path ]; for (let arg in args) push(result, arg); return result; } function module_capture(module_path, args) { return command_capture(command_from_args(module_args(module_path, args))); } function module_capture_stdin(module_path, args, input) { let tmp = trim(command_output_from_args([ "mktemp" ])); if (tmp == "") return { status: 1, output: "" }; if (!fs.writefile(tmp, as_string(input))) { fs.unlink(tmp); return { status: 1, output: "" }; } let result = command_capture(command_from_args(module_args(module_path, args)) + " < " + shell_quote(tmp)); fs.unlink(tmp); return result; } function module_output(module_path, args) { let result = module_capture(module_path, args); return result.status == 0 ? result.output : ""; } function module_output_stdin(module_path, args, input) { let result = module_capture_stdin(module_path, args, input); return result.status == 0 ? result.output : ""; } function module_success(module_path, args) { return command_success(command_from_args(module_args(module_path, args))); } function module_status(module_path, args) { return module_capture(module_path, args).status; } function module_passthrough(module_path, args) { let result = module_capture(module_path, args); if (result.output != "") print(result.output); return result.status; } function status_capture(args, input) { if (input != null) return module_capture_stdin(STATUS_UC, args, input); return command_capture(command_from_args(module_args(STATUS_UC, args))); } function status_output(args, input) { let result = status_capture(args, input); return result.status == 0 ? result.output : ""; } function status_success(args, input) { return status_capture(args, input).status == 0; } function option(section, key, fallback) { if (fallback == null) fallback = ""; let value = object_or_empty(section)[key]; if (value == null) return as_string(fallback); if (type(value) == "array") return join(" ", value); return as_string(value); } function list_option(section, key) { let value = object_or_empty(section)[key]; if (type(value) == "array") return value; if (as_string(value) != "") return [ as_string(value) ]; return []; } function bool_option(section, key, fallback) { return arg_bool(option(section, key, fallback ? "1" : "0")); } function settings() { return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); } function uci_sections(type_name) { return uci_core.section_objects(CONFIG_NAME, type_name); } function uci_get(path) { return uci_core.get(path); } function uci_show(path) { return uci_core.exists(path); } function append_unique(values, value) { value = as_string(value); if (value == "") return; for (let item in values) if (item == value) return; push(values, value); } function config_section_types(config_path) { let data = as_string(fs.readfile(config_path) || ""); let result = []; for (let line in split(data, "\n")) { line = trim(as_string(line)); if (substr(line, 0, 7) != "config ") continue; let fields = split(line, /[ \t\r\n]+/); if (length(fields) >= 2) append_unique(result, replace(as_string(fields[1]), /['"]/g, "")); } return result; } function uci_show_quote(value) { return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; } function append_uci_show_option(lines, package_name, section_name, key, value) { if (key == ".name" || key == ".type") return; let path = as_string(package_name) + "." + as_string(section_name) + "." + as_string(key) + "="; if (type(value) == "array") { for (let item in value) push(lines, path + uci_show_quote(item)); } else { push(lines, path + uci_show_quote(value)); } } function uci_show_data(package_name, config_path) { let lines = []; package_name = as_string(package_name); for (let type_name in config_section_types(config_path)) { for (let section in uci_core.section_objects(package_name, type_name)) { let name = as_string(section[".name"] || ""); if (name == "") continue; push(lines, package_name + "." + name + "=" + as_string(section[".type"] || type_name)); for (let key, value in section) append_uci_show_option(lines, package_name, name, key, value); } } return join("\n", lines) + "\n"; } function network_show_data() { return uci_show_data("network", "/etc/config/network"); } function firewall_show_data() { return uci_show_data("firewall", "/etc/config/firewall"); } function file_exists(path) { return fs.stat(as_string(path)) != null; } function file_executable(path) { return command_success_from_args([ "test", "-x", as_string(path) ]); } function ensure_dir(path) { return command_success_from_args([ "mkdir", "-p", as_string(path) ]); } // 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 first_line_value(path, fallback) { let data = fs.readfile(as_string(path)); if (data == null) return as_string(fallback); let line = split(as_string(data), "\n")[0]; line = replace(as_string(line), /\r$/, ""); return line != "" ? line : as_string(fallback); } function stdout_is_tty() { return command_success_from_args([ "test", "-t", "1" ]); } function nolog(message) { if (!stdout_is_tty()) return; let timestamp = replace(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), /[\r\n]+$/g, ""); print("\033[0;36m[", timestamp, "]\033[0m \033[0;32m", as_string(message), "\033[0m\n"); } function log_message(message, level) { level = as_string(level || "info"); command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); } function valid_ipv4(value) { return core_ip.valid_ipv4(value, true, false); } function valid_public_ipv4(value) { value = as_string(value); if (!valid_ipv4(value)) return false; let parts = split(value, "."); let a = int(parts[0], 10); let b = int(parts[1], 10); if (a == 0 || a == 10 || a == 127 || a >= 224) return false; if (a == 169 && b == 254) return false; if (a == 192 && (b == 168 || b == 0 || b == 2)) return false; if (a == 198 && (b == 18 || b == 19 || b == 51)) return false; if (a == 203 && b == 0) return false; if (a == 100 && b >= 64 && b <= 127) return false; if (a == 172 && b >= 16 && b <= 31) return false; return true; } function valid_public_ipv6(value) { value = lc(as_string(value)); if (!core_ip.valid_ipv6(value)) return false; if (value == "::" || value == "::1") return false; if (substr(value, 0, 4) == "fe80" || substr(value, 0, 2) == "ff") return false; if (substr(value, 0, 2) == "fc" || substr(value, 0, 2) == "fd") return false; if (substr(value, 0, 4) == "2001" && index(value, "2001:db8") == 0) return false; return true; } function valid_public_ip(value) { return valid_public_ipv4(value) || valid_public_ipv6(value); } function helper_output(mode, args) { let full = [ mode ]; for (let arg in args) push(full, arg); return replace(module_output(HELPERS_UC, full), /[\r\n]+$/g, ""); } function server_inbound_tag(section) { return helper_output("server-inbound-tag", [ section ]); } function server_required_inbound_proto(protocol) { protocol = as_string(protocol); if (protocol == "json_inbound") return ""; return protocol == "hysteria2" || protocol == "tuic" ? "udp" : "tcp"; } function server_runtime_type_for_protocol(protocol) { protocol = as_string(protocol); if (protocol == "json_inbound") return ""; if (protocol == "mtproto") return "mtproxy"; return protocol; } function server_listen_requires_firewall(listen, wan_ip) { listen = as_string(listen); if (listen == "0.0.0.0" || listen == "::" || valid_public_ip(listen)) return true; for (let ip in words(wan_ip)) if (ip == listen) return true; return false; } function firewall_required_protocols_open(port, required_proto) { let firewall = firewall_show_data(); return status_success([ "firewall-required-protocols-open", port, required_proto ], firewall); } function server_required_port_conflict_owners(listen, port, required_proto) { return replace(status_output( [ "server-required-port-conflict-owners", listen, port, required_proto ], command_output_from_args([ "netstat", "-lnp" ]) ), /[\r\n]+$/g, ""); } function server_required_ports_listening(listen, port, required_proto) { return status_success( [ "server-required-ports-listening", listen, port, required_proto ], command_output_from_args([ "netstat", "-ln" ]) ); } function resolve_public_host_ips(host) { host = as_string(host); if (substr(host, 0, 1) == "[" && substr(host, length(host) - 1, 1) == "]") host = substr(host, 1, length(host) - 2); if (host == "") return ""; if (valid_ipv4(host)) return host; if (core_ip.valid_ipv6(host)) return host; let seen = {}; for (let line in split(command_output_from_args([ "dig", "+short", "A", host, "+timeout=2", "+tries=1" ]), "\n")) { line = trim(as_string(line)); if (valid_ipv4(line)) seen[line] = true; } for (let line in split(command_output_from_args([ "dig", "+short", "AAAA", host, "+timeout=2", "+tries=1" ]), "\n")) { line = trim(as_string(line)); if (core_ip.valid_ipv6(line)) seen[line] = true; } return join(" ", sort(keys(seen))); } function public_host_flags(public_host, public_host_ips, wan_ip, wan_public) { return replace(status_output( [ "public-host-flags", public_host, public_host_ips, wan_ip, wan_public ], null ), /[\r\n]+$/g, ""); } function check_inbounds_config() { let count = 0; for (let section in uci_sections("server")) if (bool_option(section, "enabled", false)) count++; write_json({ enabled_count: count }); return 0; } function check_inbounds() { let cfg = settings(); let sing_box_config_path = option(cfg, "config_path", ""); let wan_ip = get_wan_ip_addresses(); let wan_public = 0; for (let ip in words(wan_ip)) { if (valid_public_ip(ip)) { wan_public = 1; break; } } let items = []; let enabled_count = 0; for (let section in uci_sections("server")) { if (!bool_option(section, "enabled", false)) continue; enabled_count++; let section_name = as_string(section[".name"] || ""); let label = option(section, "label", section_name); let protocol = option(section, "protocol", "vless"); let listen = option(section, "listen", "0.0.0.0"); let listen_port = option(section, "listen_port", ""); let public_host = option(section, "public_host", ""); let routing_mode = option(section, "routing_mode", "rules"); let inbound_tag = server_inbound_tag(section_name); let expected_type = server_runtime_type_for_protocol(protocol); let required_proto = server_required_inbound_proto(protocol); let runtime_json = protocol == "tailscale" ? module_output(PROVIDERS_STATUS_UC, [ "endpoint-summary", sing_box_config_path, inbound_tag ]) : module_output(PROVIDERS_STATUS_UC, [ "inbound-summary", sing_box_config_path, inbound_tag ]); let listening = -1; let firewall_required = 0; let firewall_open = -1; let port_conflict = 0; let port_conflict_owners = ""; if (protocol != "tailscale" && protocol != "json_inbound") { port_conflict_owners = server_required_port_conflict_owners(listen, listen_port, required_proto); if (port_conflict_owners != "") port_conflict = 1; listening = server_required_ports_listening(listen, listen_port, required_proto) ? 1 : 0; if (server_listen_requires_firewall(listen, wan_ip)) { firewall_required = 1; firewall_open = firewall_required_protocols_open(listen_port, required_proto) ? 1 : 0; } } let routes_configured = module_success(PROVIDERS_STATUS_UC, [ "has-route-rule-for-inbound", sing_box_config_path, inbound_tag ]) ? 1 : 0; let public_host_ips = protocol == "json_inbound" ? "" : resolve_public_host_ips(public_host); let flags = words(public_host_flags(public_host, public_host_ips, wan_ip, wan_public)); while (length(flags) < 3) push(flags, "-1"); let item_json = status_output([ "inbound-item-json", runtime_json, section_name, label, protocol, routing_mode, inbound_tag, listen, listen_port, public_host, public_host_ips, expected_type, required_proto, listening, firewall_required, firewall_open, port_conflict, port_conflict_owners, routes_configured, flags[0], flags[1], flags[2] ], null); let item = parse_json_or_null(item_json); push(items, type(item) == "object" ? item : {}); } write_json({ enabled_count, config_path: sing_box_config_path, wan_ip, wan_public, items }); return 0; } function cleanup_check_proxy_dir(dir) { dir = as_string(dir); let prefix = TMP_SING_BOX_FOLDER + "/check-proxy-"; if (substr(dir, 0, length(prefix)) == prefix) command_success_from_args([ "rm", "-rf", dir ]); } function check_proxy() { let sing_box_config_path = option(settings(), "config_path", ""); if (!command_exists("sing-box")) { nolog("sing-box is not installed"); return 1; } if (!file_exists(sing_box_config_path)) { nolog("Configuration file not found"); return 1; } nolog("Checking sing-box configuration..."); if (!command_success_from_args([ "sing-box", "-c", sing_box_config_path, "check" ])) { nolog("Invalid configuration"); return 1; } print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); nolog("Checking proxy connection..."); let check_proxy_dir = TMP_SING_BOX_FOLDER + "/check-proxy-" + clock()[0] + "-" + clock()[1]; let check_proxy_config = check_proxy_dir + "/config.json"; let check_proxy_cache = check_proxy_dir + "/cache.db"; cleanup_check_proxy_dir(check_proxy_dir); ensure_dir(check_proxy_dir); if (!status_success([ "prepare-check-proxy-config", sing_box_config_path, check_proxy_config, check_proxy_cache ], null)) { nolog("Failed to prepare temporary configuration"); cleanup_check_proxy_dir(check_proxy_dir); return 1; } let outbound_tag = replace(status_output( [ "check-proxy-outbound-tag", check_proxy_config, CHECK_PROXY_IP_DOMAIN ], null ), /[\r\n]+$/g, ""); let response = ""; for (let attempt = 1; attempt <= 5; attempt++) { let args = [ "sing-box", "tools", "fetch", "ifconfig.me", "-c", check_proxy_config, "-D", check_proxy_dir, "--disable-color" ]; if (outbound_tag != "") { push(args, "-o"); push(args, outbound_tag); } response = command_output(command_from_args(args) + " 2>/dev/null"); if (status_success([ "proxy-response-is-retryable-error" ], response)) continue; let masked_response_ip = replace(status_output([ "proxy-response-ip-mask" ], response), /[\r\n]+$/g, ""); if (masked_response_ip != "") { nolog(masked_response_ip + " - should match proxy IP"); cleanup_check_proxy_dir(check_proxy_dir); return 0; } if (attempt == 5) { nolog("Failed to get valid IP address after 5 attempts"); nolog(response == "" ? "Error: Empty response" : "Error response: " + response); cleanup_check_proxy_dir(check_proxy_dir); return 1; } } cleanup_check_proxy_dir(check_proxy_dir); return 1; } function domain_lists_contain_cloud_provider() { for (let section in uci_sections("section")) { if (!bool_option(section, "domain_list_enabled", false)) continue; for (let value in list_option(section, "domain_list")) if (value == "hetzner" || value == "ovh") return true; } return false; } function check_nft() { if (!command_exists("nft")) { nolog("nft is not installed"); return 1; } nolog("Checking " + NFT_TABLE_NAME + " rules..."); if (!command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { nolog("❌ " + NFT_TABLE_NAME + " not found"); return 1; } if (domain_lists_contain_cloud_provider()) { nolog("Sets statistics:"); for (let set_name in [ NFT_COMMON_SET_NAME, NFT_PORT_SET_NAME, NFT_IP_PORT_SET_NAME, NFT_INTERFACE_SET_NAME, NFT_DISCORD_SET_NAME, NFT_LOCALV4_SET_NAME ]) { if (!command_success_from_args([ "nft", "list", "set", "inet", NFT_TABLE_NAME, set_name ])) continue; let count = replace(status_output( [ "nft-set-element-count" ], command_output_from_args([ "nft", "-j", "list", "set", "inet", NFT_TABLE_NAME, set_name ]) ), /[\r\n]+$/g, ""); print("- ", set_name, ": ", count, " elements\n"); } nolog("Chain configurations:"); print(status_output( [ "nft-chain-config-blocks", "mangle", "proxy" ], command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ]) )); } else { nolog("Sets configuration:"); print(command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])); } nolog("NFT check completed"); return 0; } function check_logs() { if (!command_exists("logread")) { nolog("Error: logread command not found"); return 1; } let rendered = status_capture([ "tachyon-logs" ], command_output_from_args([ "logread", "-l", LOGREAD_LINE_LIMIT ])); if (rendered.output != "") print(rendered.output); else print("No Tachyon entries found in recent system logs\n"); return 0; } function check_sing_box_logs() { if (!command_exists("logread")) { nolog("Error: logread command not found"); return 1; } let rendered = status_capture([ "matching-log-tail", "sing-box", "100" ], command_output_from_args([ "logread", "-l", LOGREAD_LINE_LIMIT ])); if (rendered.output != "") print(rendered.output); else print("No matching logs found in system journal (needle: sing-box)\n"); return 0; } function tachyon_logs_fixture() { let rendered = status_capture([ "tachyon-logs" ], read_stdin()); if (rendered.output != "") print(rendered.output); return rendered.status; } function show_sing_box_config(visibility) { visibility = as_string(visibility || "masked"); let sing_box_config_path = option(settings(), "config_path", ""); nolog("Current sing-box configuration:"); if (!file_exists(sing_box_config_path)) { nolog("Configuration file not found"); return 1; } if (visibility == "raw") print(as_string(fs.readfile(sing_box_config_path))); else print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); return 0; } function show_config(visibility) { visibility = as_string(visibility || "masked"); if (!file_exists(TACHYON_CONFIG)) { nolog("Configuration file not found"); return 1; } if (visibility == "raw") print(as_string(fs.readfile(TACHYON_CONFIG))); else print(status_output([ "tachyon-config-masked", TACHYON_CONFIG ], null)); return 0; } function show_version() { print(TACHYON_VERSION, "\n"); return 0; } function show_sing_box_version() { print(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, ""), "\n"); return 0; } function get_luci_app_version() { let path = TACHYON_LUCI_VIEW_DIR + "/main.js"; let data = fs.readfile(path); if (data == null) return "not installed"; for (let line in split(as_string(data), "\n")) { let matched = match(line, /^[ \t]*var[ \t]+([^ \t=]+)[ \t]*=[ \t]*"([^"]*)"/); if (matched != null && matched[1] == "TACHYON_LUCI_APP_VERSION") return as_string(matched[2]); } return ""; } function system_info_cache_is_valid() { let cache = read_json_file(SYSTEM_INFO_CACHE_FILE); if (type(cache) != "object") return false; let now = int(clock()[0]); let generated_at = arg_number(cache.generated_at || 0); if (now > 0 && generated_at > 0 && SYSTEM_INFO_CACHE_TTL > 0 && now - generated_at >= SYSTEM_INFO_CACHE_TTL) return false; return cache.tachyon_version == TACHYON_VERSION && cache.luci_app_version == get_luci_app_version(); } function ensure_subscription_runtime_dirs() { module_success(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); ensure_dir(RUNTIME_STATE_DIR); } function write_system_info_cache(value) { ensure_subscription_runtime_dirs(); let tmpfile = SYSTEM_INFO_CACHE_FILE + "." + clock()[0] + "." + clock()[1] + ".tmp"; if (fs.writefile(tmpfile, as_string(value) + "\n") == null) return false; remove_file(SYSTEM_INFO_CACHE_FILE); if (!fs.rename(tmpfile, SYSTEM_INFO_CACHE_FILE)) { remove_file(tmpfile); return false; } return true; } function sing_box_marker_is(expected) { return module_success(SINGBOX_RUNTIME_UC, [ "marker-is", expected ]); } function sing_box_component_action_running() { return module_success(SERVICE_UI_UC, [ "component-action-running-for", "sing_box" ]); } // Only the variants whose binary cannot be executed for a version string fall back // to the state file: `extended-compressed` is a self-extracting stub and `lx` needs // a runtime that may be absent. Plain `extended` runs `sing-box version` fine, and // listing it here made the UI show whatever was last written to // /etc/tachyon/sing-box-version — a file only the component action updates. A // sing-box installed any other way (install.sh, opkg, by hand) then displayed a // stale version forever, and the update badge compared against it. function sing_box_live_probe_disabled() { return sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx") || sing_box_component_action_running(); } // Resolves the pair (version, version-output) both callers below need. The state // file is only consulted for variants whose binary cannot be run, and an empty or // missing state falls through to the live probe rather than reporting "unknown": // the state is written by the component action alone, so any sing-box installed by // install.sh, opkg or by hand has none. function sing_box_resolved_version() { if (sing_box_live_probe_disabled()) { let state = replace(module_output(SINGBOX_RUNTIME_UC, [ "read-version-state" ]), /[\r\n]+$/g, ""); if (state != "") return { version: state, output: "" }; } let output = module_output(SINGBOX_RUNTIME_UC, [ "version-output" ]); return { version: replace(module_output_stdin(SINGBOX_RUNTIME_UC, [ "version-from-output" ], output), /[\r\n]+$/g, ""), output: output }; } function sing_box_tiny_package_installed() { return module_success(PACKAGES_UC, [ "installed", "sing-box-tiny" ]); } function sing_box_capability_flags(sing_box_version, sing_box_version_output) { let extended = 0; let tiny = 0; let tailscale = 0; if (sing_box_marker_is("extended") || sing_box_marker_is("extended-compressed") || module_success(SINGBOX_RUNTIME_UC, [ "is-extended", sing_box_version ])) extended = 1; if (sing_box_marker_is("lx") || module_success(SINGBOX_RUNTIME_UC, [ "is-lx", sing_box_version ])) extended = 1; if (extended == 0 && (sing_box_marker_is("tiny") || sing_box_tiny_package_installed())) tiny = 1; if (extended == 1) tailscale = 1; else if (as_string(sing_box_version_output) != "") { if (module_success(SINGBOX_RUNTIME_UC, [ "supports-tailscale", sing_box_version, sing_box_version_output ])) tailscale = 1; } else if (tiny == 0 && sing_box_component_action_running()) tailscale = 1; return { extended, tiny, tailscale }; } function provider_installed(runtime_uc) { return module_success(runtime_uc, [ "installed" ]); } function provider_version(runtime_uc) { let value = replace(module_output(runtime_uc, [ "package-version" ]), /[\r\n]+$/g, ""); return value != "" ? value : "unknown"; } function openwrt_release() { let data = fs.readfile("/etc/os-release"); if (data == null) return "unknown"; for (let line in split(as_string(data), "\n")) { if (substr(line, 0, length("OPENWRT_RELEASE=")) != "OPENWRT_RELEASE=") continue; let value = substr(line, length("OPENWRT_RELEASE=")); if (length(value) >= 2) { let quote = substr(value, 0, 1); if ((quote == "\"" || quote == "'") && substr(value, length(value) - 1) == quote) value = substr(value, 1, length(value) - 2); } return value != "" ? value : "unknown"; } return "unknown"; } function build_system_info() { let tachyon_latest_version = first_line_value("/tmp/tachyon.latest-version.cache", "unknown"); let luci_app_version = get_luci_app_version(); let sing_box_version = ""; let sing_box_version_output = ""; if (command_exists("sing-box")) { let resolved = sing_box_resolved_version(); sing_box_version = resolved.version; sing_box_version_output = resolved.output; if (sing_box_version == "") sing_box_version = "unknown"; } else { sing_box_version = "not installed"; sing_box_version_output = ""; } let flags = sing_box_capability_flags(sing_box_version, sing_box_version_output); let sing_box_compressed = flags.extended == 1 && sing_box_marker_is("extended-compressed") ? 1 : 0; let sing_box_lx = flags.extended == 1 && sing_box_marker_is("lx") ? 1 : 0; let sing_box_repo_url = "https://github.com/SagerNet/sing-box"; if (sing_box_lx == 1) sing_box_repo_url = "https://github.com/Leadaxe/sing-box-lx"; else if (flags.extended == 1) sing_box_repo_url = "https://github.com/shtorm-7/sing-box-extended"; let zapret_installed = provider_installed(ZAPRET_RUNTIME_UC) ? 1 : 0; let zapret_version = zapret_installed ? provider_version(ZAPRET_RUNTIME_UC) : "not installed"; let zapret2_installed = provider_installed(ZAPRET2_RUNTIME_UC) ? 1 : 0; let zapret2_version = zapret2_installed ? provider_version(ZAPRET2_RUNTIME_UC) : "not installed"; let byedpi_installed = provider_installed(BYEDPI_RUNTIME_UC) ? 1 : 0; let byedpi_version = byedpi_installed ? provider_version(BYEDPI_RUNTIME_UC) : "not installed"; let tailscale_installed = provider_installed(TAILSCALE_RUNTIME_UC) ? 1 : 0; let tailscale_version = tailscale_installed ? provider_version(TAILSCALE_RUNTIME_UC) : "not installed"; let device_model = first_line_value("/tmp/sysinfo/model", "unknown"); let direct_bypass_enabled = bool_option(settings(), "direct_bypass_enabled", false) ? 1 : 0; let direct_bypass_port = option(settings(), "direct_bypass_port", "2080"); let direct_bypass_address = direct_bypass_enabled ? trim(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/singbox/runtime.uc", "service-listen-address" ])) : ""; let torrserver_direct_status = parse_json_or_null(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/torrserver/direct.uc", "status" ])); if (type(torrserver_direct_status) != "object") torrserver_direct_status = {}; let base_bdir = getenv("TACHYON_COMPONENT_BACKUPS_DIR") || "/etc/tachyon/component-backups"; let read_backup_meta = function(comp) { let path = base_bdir + "/" + comp + "/metadata.json"; let st = fs.stat(path); if (!st) return null; let data = as_string(fs.readfile(path)); if (data == "") return null; try { let p = json(data); if (type(p) == "object" && p.version) return p; } catch (e) {} return null; }; let sb_meta = read_backup_meta("sing_box"); let zapret_meta = read_backup_meta("zapret"); let zapret2_meta = read_backup_meta("zapret2"); let byedpi_meta = read_backup_meta("byedpi"); let tailscale_meta = read_backup_meta("tailscale"); return { tachyon_version: TACHYON_VERSION, tachyon_commit_sha: constants.TACHYON_COMMIT_SHA && !match(constants.TACHYON_COMMIT_SHA, /COMPILED/) ? constants.TACHYON_COMMIT_SHA : "", tachyon_latest_version: tachyon_latest_version || "unknown", luci_app_version, sing_box_version, sing_box_extended: flags.extended, sing_box_tiny: flags.tiny, sing_box_compressed, sing_box_lx, sing_box_tailscale: flags.tailscale, sing_box_repo_url, sing_box_backup_version: sb_meta ? as_string(sb_meta.version) : "", sing_box_backup_time: sb_meta ? int(sb_meta.timestamp || 0) : 0, zapret_version, zapret_installed, zapret_backup_version: zapret_meta ? as_string(zapret_meta.version) : "", zapret_backup_time: zapret_meta ? int(zapret_meta.timestamp || 0) : 0, zapret2_version, zapret2_installed, zapret2_backup_version: zapret2_meta ? as_string(zapret2_meta.version) : "", zapret2_backup_time: zapret2_meta ? int(zapret2_meta.timestamp || 0) : 0, byedpi_version, byedpi_installed, byedpi_backup_version: byedpi_meta ? as_string(byedpi_meta.version) : "", byedpi_backup_time: byedpi_meta ? int(byedpi_meta.timestamp || 0) : 0, tailscale_version, tailscale_installed, tailscale_backup_version: tailscale_meta ? as_string(tailscale_meta.version) : "", tailscale_backup_time: tailscale_meta ? int(tailscale_meta.timestamp || 0) : 0, direct_bypass_enabled, direct_bypass_address, direct_bypass_port, torrserver_running: int(torrserver_direct_status.running || 0), torrserver_direct_available: int(torrserver_direct_status.available || 0), torrserver_direct_enabled: int(torrserver_direct_status.enabled || 0), torrserver_direct_active: int(torrserver_direct_status.active || 0), openwrt_version: openwrt_release(), device_model, generated_at: int(clock()[0]) }; } function get_system_info() { if (system_info_cache_is_valid()) { print(as_string(fs.readfile(SYSTEM_INFO_CACHE_FILE))); return 0; } let system_info = sprintf("%J", build_system_info()); write_system_info_cache(system_info); print(system_info, "\n"); return 0; } function get_server_capabilities() { if (!file_executable(SING_BOX_BIN_PATH)) { write_json({ sing_box_extended: 0, sing_box_tiny: 0, sing_box_tailscale: 0 }); return 0; } let resolved = sing_box_resolved_version(); let flags = sing_box_capability_flags(resolved.version, resolved.output); write_json({ sing_box_extended: flags.extended, sing_box_tiny: flags.tiny, sing_box_tailscale: flags.tailscale }); return 0; } function neutralize_zapret_defaults() { log_message("Standalone zapret is not neutralized automatically; Tachyon uses /opt/zapret/nfq/nfqws as an external provider and manages only its own NFQUEUE range.", "info"); return 0; } function sing_box_process_is_running() { return command_success_from_args([ "pgrep", "-x", "sing-box" ]) || command_success_from_args([ "pgrep", "-f", "^/usr/bin/sing-box[[:space:]]" ]); } function service_status_label(running, enabled) { if (arg_number(running) == 1) return arg_number(enabled) == 1 ? "running & enabled" : "running but disabled"; return arg_number(enabled) == 1 ? "stopped but enabled" : "stopped & disabled"; } function write_service_status(running, enabled, dns_configured) { write_json({ running, enabled, status: service_status_label(running, enabled), dns_configured }); } function dnsmasq_has_tachyon_dns() { return module_success(DNS_APPLY_UC, [ "has-tachyon-dns" ]); } function get_sing_box_status() { let running = module_success(SERVICE_STATE_UC, [ "sing-box-service-stable", RUNTIME_STABLE_MIN_AGE ]) ? 1 : 0; let enabled = file_executable("/etc/rc.d/S99sing-box") ? 1 : 0; let dns_configured = dnsmasq_has_tachyon_dns() ? 1 : 0; write_service_status(running, enabled, dns_configured); return 0; } function get_status() { let running = module_success(SERVICE_STATE_UC, [ "tachyon-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, RUNTIME_STABLE_MIN_AGE ]) ? 1 : 0; let enabled = file_executable("/etc/rc.d/S99" + TACHYON_SERVICE_NAME) ? 1 : 0; let dns_configured = dnsmasq_has_tachyon_dns() ? 1 : 0; write_service_status(running, enabled, dns_configured); return 0; } function subscription_cache(args) { return module_capture(SUBSCRIPTION_CACHE_UC, args); } function print_subscription_result(result, fallback) { if (result.status == 0 && result.output != "") { print(result.output); return 0; } print(as_string(fallback)); return 0; } function section_safe(section) { section = as_string(section); return section != "" && index(section, "/") < 0 && index(section, "..") < 0; } function get_outbound_metadata(section) { subscription_cache([ "ensure-runtime-dirs" ]); if (!section_safe(section)) return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "outbound-metadata-path", section ]), /[\r\n]+$/g, ""); if (metadata_path == "") return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); let result = subscription_cache([ "get-outbound-metadata", SECTION_CACHE_DIR, section, metadata_path ]); if (result.status != 0) result = subscription_cache([ "empty-outbound-metadata" ]); return print_subscription_result(result, ""); } function get_subscription_metadata(section) { subscription_cache([ "ensure-runtime-dirs" ]); if (!section_safe(section)) { print("{}\n"); return 0; } let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "subscription-metadata-path", section ]), /[\r\n]+$/g, ""); if (metadata_path == "") { print("{}\n"); return 0; } let result = subscription_cache([ "get-subscription-metadata", SECTION_CACHE_DIR, section, metadata_path ]); return print_subscription_result(result, "{}\n"); } function validate_nfqws_strategy_json(raw_opt) { let result = module_capture(ZAPRET_VALIDATOR_UC, [ "validate-json", "nfqws", as_string(raw_opt), ZAPRET_LEGACY_DEFAULT_NFQWS_OPT ]); if (result.output != "") print(result.output); return 0; } function validate_nfqws2_strategy_json(raw_opt) { let result = module_capture(ZAPRET2_VALIDATOR_UC, [ "validate-json", "nfqws2", as_string(raw_opt) ]); if (result.output != "") print(result.output); return 0; } function validate_byedpi_strategy_json(raw_opt) { let result = module_capture(BYEDPI_VALIDATOR_UC, [ "validate-json", as_string(raw_opt) ]); if (result.output != "") print(result.output); else print("{\"valid\":false,\"message\":\"ByeDPI validator produced no output\"}\n"); return 0; } function url_host(value) { return helper_output("url-get-host", [ value ]); } function dns_check_resolve_host(host, resolver, timeout_seconds) { host = as_string(host); resolver = as_string(resolver); if (host == "") return ""; if (valid_ipv4(host)) return host; if (resolver == "") return ""; timeout_seconds = int(timeout_seconds || 2); if (command_exists("dig")) { for (let line in split(command_output_from_args([ "dig", "@" + resolver, host, "A", "+short", "+timeout=" + as_string(timeout_seconds), "+tries=1" ]), "\n")) { line = trim(as_string(line)); if (valid_ipv4(line)) return line; } } let out = command_output_from_args([ "nslookup", host, resolver ]); for (let line in split(out, "\n")) { line = trim(as_string(line)); let m = match(line, /Address:[ \t]*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/); if (m && m[1] && m[1] != resolver && valid_ipv4(m[1])) return m[1]; } return ""; } function dns_check_through_singbox(domain) { let resolved = dns_check_resolve_host(domain, SB_DNS_INBOUND_ADDRESS, 3); if (resolved != "") return true; let res = command_capture("nslookup " + shell_quote(domain) + " " + SB_DNS_INBOUND_ADDRESS + " 2>&1"); return (index(res.output, "Address") >= 0 || index(res.output, "name =") >= 0) && index(res.output, "NXDOMAIN") < 0 && index(res.output, "can't resolve") < 0 && index(res.output, "timed out") < 0; } function device_ipv4_address(interface) { let value = replace(module_output(SINGBOX_RUNTIME_UC, [ "device-ipv4-address", interface ]), /[\r\n]+$/g, ""); if (value != "") return value; let output = command_output_from_args([ "ip", "-4", "addr", "show", "dev", interface ]); for (let line in split(output, "\n")) { line = trim(as_string(line)); let matched = match(line, /^inet[ \t]+([0-9.]+)\//); if (matched != null) return as_string(matched[1]); } return ""; } function dns_check_router_resolver_available(domain) { for (let address in [ "127.0.0.1", SB_DNS_INBOUND_ADDRESS ]) { if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) return true; } let listen_address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); if (listen_address != "" && command_success_from_args([ "dig", "@" + listen_address, domain, "+timeout=2", "+tries=1" ])) return true; let source_interfaces = option(settings(), "source_network_interfaces", "br-lan"); for (let interface in words(source_interfaces)) { let address = device_ipv4_address(interface); if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) return true; } return false; } function dns_check_timeout_seconds(value) { let rest = as_string(value); let milliseconds = 0.0; let units = { ns: 0.000001, us: 0.001, ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }; while (rest != "") { let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); if (!matched) return 2; milliseconds += (matched[1] * 1) * units[matched[3]]; rest = substr(rest, length(matched[0])); } return milliseconds > 0 ? int((milliseconds + 999) / 1000) : 2; } function check_dns_available() { let cfg = settings(); let dns_type = option(cfg, "dns_type", ""); let active = runtime_dns.active_values(cfg); let dns_server = active.main; let bootstrap_dns_server = active.bootstrap; let dont_touch_dhcp = bool_option(cfg, "dont_touch_dhcp", false) ? 1 : 0; let domain = "example.com"; let timeout_seconds = dns_check_timeout_seconds(option(cfg, "dns_check_timeout", "2s")); let dns_status = 0; let dns_on_router = 0; let bootstrap_dns_status = 0; let dhcp_config_status = 1; let active_dns_args = [ "dig" ]; if (runtime_dns.failover_enabled(cfg)) { push(active_dns_args, "-p"); push(active_dns_args, as_string(runtime_dns.health_port("active", 0))); } push(active_dns_args, "@" + SB_DNS_INBOUND_ADDRESS); push(active_dns_args, domain); push(active_dns_args, "A"); push(active_dns_args, "+short"); push(active_dns_args, "+timeout=" + as_string(timeout_seconds)); push(active_dns_args, "+tries=1"); for (let line in split(command_output_from_args(active_dns_args), "\n")) if (valid_ipv4(trim(as_string(line)))) { dns_status = 1; break; } if (dns_check_router_resolver_available(domain)) dns_on_router = 1; let dns_server_host = url_host(dns_server); if (dns_server_host == "") dns_server_host = dns_server; if (bootstrap_dns_server != "") { if (length(active.state.bootstrap_servers) > 1) { for (let line in split(command_output_from_args([ "dig", "-p", as_string(runtime_dns.health_port("bootstrap", active.state.bootstrap_index)), "@" + runtime_dns.DNS_HEALTH_ADDRESS, domain, "A", "+short", "+timeout=" + as_string(timeout_seconds), "+tries=1" ]), "\n")) if (valid_ipv4(trim(as_string(line)))) { bootstrap_dns_status = 1; break; } } else { let bootstrap_check_domain = domain; if (dns_server_host != "" && !valid_ipv4(dns_server_host)) bootstrap_check_domain = dns_server_host; if (dns_check_resolve_host(bootstrap_check_domain, bootstrap_dns_server, timeout_seconds) != "") bootstrap_dns_status = 1; } } if (!module_success(DNS_APPLY_UC, [ "default-config-complete" ])) dhcp_config_status = 0; let display_dns_server = replace(status_output([ "mask-dns-server", dns_server ], null), /[\r\n]+$/g, ""); write_json({ dns_type, dns_server: display_dns_server, dns_server_index: active.state.main_index, dns_server_count: length(active.state.main_servers), dns_status, dns_on_router, bootstrap_dns_server, bootstrap_dns_server_index: active.state.bootstrap_index, bootstrap_dns_server_count: length(active.state.bootstrap_servers), bootstrap_dns_status, dhcp_config_status, dont_touch_dhcp }); return 0; } function nft_chain_counter_status(chain) { let output = command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]); let status = words(status_output([ "nft-chain-counter-status" ], output)); while (length(status) < 2) push(status, "0"); return [ arg_number(status[0]), arg_number(status[1]) ]; } function nft_table_has_other_mark_rules(family, table_name) { let output = command_output_from_args([ "nft", "list", "table", family, table_name ]); return status_success([ "stdin-contains", "meta mark set" ], output); } function check_nft_rules() { command_status("sh -c " + shell_quote( "curl -m 3 -s " + shell_quote("https://" + CHECK_PROXY_IP_DOMAIN + "/check") + " >/dev/null 2>&1 & pid1=$!; " + "curl -m 3 -s " + shell_quote("https://" + FAKEIP_TEST_DOMAIN + "/check") + " >/dev/null 2>&1 & pid2=$!; " + "wait $pid1 2>/dev/null; wait $pid2 2>/dev/null; sleep 1" )); let table_exist = 0; let rules_mangle_exist = 0; let rules_mangle_counters = 0; let rules_mangle_output_exist = 0; let rules_mangle_output_counters = 0; let rules_proxy_exist = 0; let rules_proxy_counters = 0; let rules_other_mark_exist = 0; if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { table_exist = 1; if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle" ])) { let status = nft_chain_counter_status("mangle"); rules_mangle_exist = status[0]; rules_mangle_counters = status[1]; } if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle_output" ])) { let status = nft_chain_counter_status("mangle_output"); rules_mangle_output_exist = status[0]; rules_mangle_output_counters = status[1]; } if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "proxy" ])) { let status = nft_chain_counter_status("proxy"); rules_proxy_exist = status[0]; rules_proxy_counters = status[1]; } } for (let line in split(command_output_from_args([ "nft", "list", "tables" ]), "\n")) { let fields = words(line); if (length(fields) < 3) continue; let family = fields[1]; let table_name = fields[2]; if (table_name == NFT_TABLE_NAME) continue; if (nft_table_has_other_mark_rules(family, table_name)) { rules_other_mark_exist = 1; break; } } write_json({ table_exist, rules_mangle_exist, rules_mangle_counters, rules_mangle_output_exist, rules_mangle_output_counters, rules_proxy_exist, rules_proxy_counters, rules_other_mark_exist }); return 0; } function strip_leading_v(value) { value = as_string(value); return substr(value, 0, 1) == "v" ? substr(value, 1) : value; } function sing_box_standard_ports_listening(netstat) { netstat = as_string(netstat); let port_53_ok = index(netstat, "127.0.0.42:53") >= 0; let tproxy_suffix = ":" + SB_TPROXY_INBOUND_PORT; let port_1602_ok = index(netstat, "0.0.0.0" + tproxy_suffix) >= 0 || index(netstat, "127.0.0.1" + tproxy_suffix) >= 0; let port_1602_v6_ok = index(netstat, SB_TPROXY_INBOUND6_ADDRESS + tproxy_suffix) >= 0 || index(netstat, "[" + SB_TPROXY_INBOUND6_ADDRESS + "]" + tproxy_suffix) >= 0 || index(netstat, "0:0:0:0:0:0:0:1" + tproxy_suffix) >= 0 || index(netstat, ":::" + SB_TPROXY_INBOUND_PORT) >= 0; return port_53_ok && port_1602_ok && port_1602_v6_ok; } function sing_box_standard_ports_listening_fixture() { exit(sing_box_standard_ports_listening(read_stdin()) ? 0 : 1); } function check_sing_box() { let sing_box_installed = 0; let sing_box_version_ok = 0; let sing_box_extended = 0; let sing_box_service_exist = 0; let sing_box_autostart_disabled = 0; let sing_box_process_running = 0; let sing_box_ports_listening = 0; if (command_exists("sing-box")) { sing_box_installed = 1; let version = strip_leading_v(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, "")); if (version != "") { if (sing_box_marker_is("lx") || module_success(SINGBOX_RUNTIME_UC, [ "is-lx", version ])) sing_box_extended = 1; else if (sing_box_marker_is("extended-compressed") || module_success(SINGBOX_RUNTIME_UC, [ "is-extended", version ])) sing_box_extended = 1; if (module_success(HELPERS_UC, [ "version-at-least", version, "1.12.4" ])) sing_box_version_ok = 1; } else if (sing_box_marker_is("extended-compressed") || sing_box_marker_is("lx")) sing_box_extended = 1; } if (file_exists("/etc/init.d/sing-box")) { sing_box_service_exist = 1; if (!command_success_from_args([ "/etc/init.d/sing-box", "enabled" ])) sing_box_autostart_disabled = 1; } if (sing_box_process_is_running()) sing_box_process_running = 1; if (sing_box_standard_ports_listening(command_output_from_args([ "netstat", "-ln" ]))) sing_box_ports_listening = 1; write_json({ sing_box_installed, sing_box_version_ok, sing_box_extended, sing_box_service_exist, sing_box_autostart_disabled, sing_box_process_running, sing_box_ports_listening }); return 0; } function check_fakeip() { let fakeip_address = ""; let fakeip6_address = ""; for (let line in split(command_output_from_args([ "dig", "+short", "@" + SB_DNS_INBOUND_ADDRESS, FAKEIP_TEST_DOMAIN, "A", "+timeout=2", "+tries=1" ]), "\n")) { line = trim(as_string(line)); if (valid_ipv4(line)) { fakeip_address = line; break; } } for (let line in split(command_output_from_args([ "dig", "+short", "@" + SB_DNS_INBOUND_ADDRESS, FAKEIP_TEST_DOMAIN, "AAAA", "+timeout=2", "+tries=1" ]), "\n")) { line = lc(trim(as_string(line))); if (core_ip.valid_ipv6(line)) { fakeip6_address = line; break; } } write_json({ fakeip: match(fakeip_address, /^198\.(18|19)\./) != null || match(fakeip6_address, /^fc[0-3][0-9a-f]:/) != null, IP: fakeip_address != "" ? fakeip_address : fakeip6_address, IPv4: fakeip_address, IPv6: fakeip6_address }); return 0; } function clash_json_output(args) { let out = command_output(command_from_args(args)); if (out != null && out != "") print(out); else print("{}\n"); return 0; } function clash_api_url() { let address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); if (address == "") address = "127.0.0.1"; return address + ":" + SB_CLASH_API_CONTROLLER_PORT; } function clash_auth_args() { let cfg = settings(); if (!bool_option(cfg, "enable_yacd_wan_access", false)) return []; return [ "--header", "Authorization: Bearer " + option(cfg, "yacd_secret_key", "") ]; } function clash_urlencode(value) { return replace(status_output([ "url-encode", value ], null), /[\r\n]+$/g, ""); } function clash_json_error(message) { let result = status_capture([ "json-error", message ], null); if (result.output != "") print(result.output); return 1; } function clash_proxy_type_map(base_url, auth) { let args = [ "curl", "-s" ]; for (let item in auth) push(args, item); push(args, base_url + "/proxies"); let value = {}; try { value = json(command_output(command_from_args(args))); } catch (e) { return {}; } let result = {}; for (let tag, proxy in object_or_empty(value.proxies)) result[tag] = as_string(object_or_empty(proxy).type || ""); return result; } function clash_latency_endpoint(base_url, proxy_tag, proxy_type) { proxy_type = as_string(proxy_type); if (lc(proxy_type) == "urltest") return base_url + "/group/" + clash_urlencode(proxy_tag) + "/delay"; return base_url + "/proxies/" + clash_urlencode(proxy_tag) + "/delay"; } function latency_test_url() { let value = option(settings(), "latency_test_url", DEFAULT_LATENCY_TEST_URL); return value == "" ? DEFAULT_LATENCY_TEST_URL : value; } function clash_api(action, arg1, arg2, arg3) { let base_url = clash_api_url(); let test_url = latency_test_url(); let auth = clash_auth_args(); if (action == "get_proxies") { let args = [ "curl", "-s" ]; for (let item in auth) push(args, item); push(args, base_url + "/proxies"); return clash_json_output(args); } if (action == "get_connections") { let args = [ "curl", "-s" ]; for (let item in auth) push(args, item); push(args, base_url + "/connections"); return clash_json_output(args); } if (action == "get_proxy_latency") { if (as_string(arg1) == "") return clash_json_error("proxy_tag required"); let url = as_string(arg3 || ""); if (url == "") url = test_url; let args = [ "curl", "-G", "-s", base_url + "/proxies/" + clash_urlencode(arg1) + "/delay" ]; for (let item in auth) push(args, item); push(args, "--data-urlencode"); push(args, "url=" + url); push(args, "--data-urlencode"); push(args, "timeout=" + as_string(arg2 || "2000")); return clash_json_output(args); } if (action == "get_proxy_latencies") { if (as_string(arg1) == "") return clash_json_error("proxy_tags_json required"); let tags = status_capture([ "clash-proxy-tags-lines", arg1 ], null); if (tags.status != 0) return clash_json_error("proxy_tags_json must be a JSON array of non-empty strings"); let proxy_tags = []; for (let proxy_tag in split(tags.output, "\n")) { proxy_tag = as_string(proxy_tag); if (proxy_tag != "") push(proxy_tags, proxy_tag); } let count = 0; let failed = 0; let progress_path = as_string(arg3); let total = length(proxy_tags); if (progress_path != "") module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]); let proxy_types = clash_proxy_type_map(base_url, auth); let ordered_proxy_tags = []; for (let proxy_tag in proxy_tags) if (lc(as_string(proxy_types[proxy_tag])) != "urltest") push(ordered_proxy_tags, proxy_tag); for (let proxy_tag in proxy_tags) if (lc(as_string(proxy_types[proxy_tag])) == "urltest") push(ordered_proxy_tags, proxy_tag); for (let proxy_tag in ordered_proxy_tags) { let args = [ "curl", "-G", "-s", "-m", "15", clash_latency_endpoint(base_url, proxy_tag, proxy_types[proxy_tag]) ]; for (let item in auth) push(args, item); push(args, "--data-urlencode"); push(args, "url=" + test_url); push(args, "--data-urlencode"); push(args, "timeout=" + as_string(arg2 || "5000")); if (status_capture([ "stdin-json" ], command_output(command_from_args(args))).status != 0) failed++; count++; if (progress_path != "") module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]); } let result = status_capture([ "clash-proxy-latencies-result", count, failed ], null); if (result.output != "") print(result.output); return result.status; } if (action == "get_group_latency") { if (as_string(arg1) == "") return clash_json_error("group_tag required"); let args = [ "curl", "-G", "-s", base_url + "/group/" + clash_urlencode(arg1) + "/delay" ]; for (let item in auth) push(args, item); push(args, "--data-urlencode"); push(args, "url=" + test_url); push(args, "--data-urlencode"); push(args, "timeout=" + as_string(arg2 || "5000")); return clash_json_output(args); } if (action == "set_group_proxy") { if (as_string(arg1) == "" || as_string(arg2) == "") return clash_json_error("group_tag and proxy_tag required"); let payload = status_output([ "clash-set-group-proxy-payload", arg2 ], null); let args = [ "curl", "-X", "PUT", "-s", "-w", "\n%{http_code}", base_url + "/proxies/" + clash_urlencode(arg1) ]; for (let item in auth) push(args, item); push(args, "--data-raw"); push(args, payload); let result = status_capture([ "clash-set-group-proxy-result", arg1, arg2 ], command_output(command_from_args(args))); if (result.status == 0) command_status("conntrack -F >/dev/null 2>&1 || true"); if (result.output != "") print(result.output); return result.status; } if (action == "close_connection") { if (as_string(arg1) == "") return clash_json_error("connection_id required"); let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections/" + clash_urlencode(arg1) ]; for (let item in auth) push(args, item); let result = status_capture([ "clash-close-connection-result", arg1 ], command_output(command_from_args(args))); if (result.output != "") print(result.output); return result.status; } if (action == "close_all_connections") { let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections" ]; for (let item in auth) push(args, item); let result = status_capture([ "clash-close-all-connections-result" ], command_output(command_from_args(args))); if (result.output != "") print(result.output); return result.status; } let unknown = status_capture([ "clash-unknown-action" ], null); if (unknown.output != "") print(unknown.output); return 1; } function print_global(message) { print(as_string(message), "\n"); } function render_or_fail(mode_args, input, fail_message, ok_statuses) { let result = status_capture(mode_args, input); if (result.output != "") print(result.output); for (let status in ok_statuses) if (result.status == status) return result.status; print_global(fail_message); return result.status; } function global_check(arg1, arg2) { let visibility = as_string(arg2 || "masked"); if (as_string(arg1) == "raw" || as_string(arg1) == "masked") visibility = as_string(arg1); print_global("═══ Global check run!"); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ System info"); let system_info_json = sprintf("%J", build_system_info()); render_or_fail([ "global-system-info" ], system_info_json, "❌ Failed to parse system info", [ 0 ]); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ DNS status"); let dns_check_capture = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-dns-available" ]))); if (dns_check_capture.output != "") { let dns_render = render_or_fail( [ "global-dns-check", bool_option(settings(), "dont_touch_dhcp", false) ? "1" : "0" ], dns_check_capture.output, "❌ Failed to parse DNS info", [ 0, 10 ] ); if (dns_render == 10) print(status_output([ "dhcp-dnsmasq-config", "/etc/config/dhcp" ], null)); } else print_global("❌ Failed to get DNS info"); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("ЁЯУж Sing-box status"); let singbox_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-sing-box" ]))).output; if (singbox_check_json != "") render_or_fail([ "global-sing-box-check" ], singbox_check_json, "❌ Failed to parse sing-box info", [ 0 ]); else print_global("❌ Failed to get sing-box info"); print_global("---------------------------"); print_global("Inbounds checks"); let inbounds_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-inbounds" ]))).output; if (inbounds_check_json != "") render_or_fail([ "global-inbounds-check" ], inbounds_check_json, "[FAIL] Failed to parse inbounds check details", [ 0 ]); else print_global("[FAIL] Failed to get inbounds info"); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ NFT rules status"); let nft_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-nft-rules" ]))).output; if (nft_check_json != "") { let nft_render = render_or_fail([ "global-nft-check" ], nft_check_json, "❌ Failed to parse NFT rules info", [ 0 ]); if (nft_render == 0 && status_success([ "global-nft-other-mark-exists" ], nft_check_json)) print(status_output([ "nft-ruleset-other-mark-lines", NFT_TABLE_NAME ], command_output_from_args([ "sh", "-c", "nft list ruleset | grep -E '^table|mark set|meta mark'; exit 0" ]))); } else print_global("❌ Failed to get NFT rules info"); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ Tachyon config"); show_config(visibility); print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ WAN config"); if (uci_show("network.wan")) { if (visibility == "raw") print(as_string(fs.readfile("/etc/config/network"))); else print(status_output([ "wan-config-masked", "/etc/config/network" ], null)); } else print_global("❌ WAN configuration not found"); let network_show = network_show_data(); for (let line in split(status_output([ "network-endpoint-host-warnings", CLOUDFLARE_OCTETS ], network_show), "\n")) { if (line == "") continue; let fields = split(line, "\t"); if (length(fields) < 2) continue; if (fields[0] == "engage") print_global("⚠️ WARP detected: " + fields[1]); else if (fields[0] == "prefix") { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ WARP detected: " + fields[1]); } } for (let peer_section in split(status_output([ "network-wireguard-route-allowed-peers" ], network_show), "\n")) { peer_section = as_string(peer_section); if (peer_section == "") continue; let default_routes = allowed_ips_default_routes(uci_get(peer_section + ".allowed_ips")); if (length(default_routes) > 0) { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ WG Route allowed IP enabled with " + join(", ", default_routes)); } } if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "status" ])) { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ Standalone zapret service is active. Tachyon uses separate queues, but packet-level policy overlap is possible."); } else if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "enabled" ])) { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ Standalone zapret autostart is enabled. Tachyon will not modify /etc/config/zapret."); } if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "status" ])) { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ Standalone zapret2 service is active. Tachyon uses separate queues, but packet-level policy overlap is possible."); } else if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "enabled" ])) { print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("⚠️ Standalone zapret2 autostart is enabled. Tachyon will not modify /etc/config/zapret2."); } print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); print_global("═══ FakeIP status"); let fakeip_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-fakeip" ]))).output; if (fakeip_check_json != "") render_or_fail([ "global-fakeip-check" ], fakeip_check_json, "❌ Failed to parse FakeIP info", [ 0 ]); else print_global("❌ Failed to get FakeIP info"); return 0; } function find_process_pid(name) { let pids = split(trim(command_capture(command_from_args(["pidof", name])).output), /\s+/); return length(pids) > 0 && pids[0] != "" ? pids[0] : ""; } // The main service loop writes its pid file on start (start_runtime in // watchdog.uc). With enable_watchdog='0' the loop is not spawned but sing-box // keeps running as its own procd service — that state still counts as running: // the doctor must never treat a live Tachyon as a stopped one. function tachyon_is_running() { let wd_pid = trim(fs.readfile("/var/run/tachyon_watchdog.pid") || ""); if (wd_pid != "" && fs.stat("/proc/" + wd_pid) != null) return true; return find_process_pid("sing-box") != ""; } function tachyon_is_enabled() { return file_executable("/etc/rc.d/S99" + TACHYON_SERVICE_NAME); } 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"); } // ─── Recovery mode: Tachyon is stopped, restore stock internet ─────────────── // The doctor doubles as an emergency repair tool: it must work with the service // fully stopped (disabled in LuCI, crashed, or removed) and return the router // to a stock state — WAN up, DNS resolving for the LAN — without ever starting // Tachyon. Everything here is idempotent: on a cleanly stopped service the // checks pass and nothing is touched. // Declared before run_doctor_checks(): ucode does not hoist function // declarations, and run_doctor_checks() dispatches to this mode. // Doctor repairs are opt-in. By default the doctor only diagnoses: every // mutation (UCI rewrites, service restarts, process kills, resolv.conf or // nftables changes) is recorded as a planned action instead of being applied. // Repair mode is enabled explicitly via CLI ("doctor --fix") or by passing // repair=true to run_doctor_checks(). let DOCTOR_REPAIR_MODE = false; let DOCTOR_PLANNED_FIXES = []; function doc_repair_enabled() { return DOCTOR_REPAIR_MODE; } function doc_plan(action) { push(DOCTOR_PLANNED_FIXES, as_string(action)); } // Mode-aware mutation primitives used inside the doctor checks. In dry-run // they record intent and report success so surrounding control flow stays // unchanged; callers gate their re-verification on doc_repair_enabled(). function doc_set(path, value) { if (!DOCTOR_REPAIR_MODE) { doc_plan("uci set " + as_string(path) + "=" + as_string(value)); return true; } return uci_core.set(path, value); } function doc_commit(pkg) { if (!DOCTOR_REPAIR_MODE) { doc_plan("uci commit " + as_string(pkg)); return true; } return uci_core.commit(pkg); } function doc_run(cmd) { if (!DOCTOR_REPAIR_MODE) { doc_plan("run: " + as_string(cmd)); return 0; } return command_status(cmd); } function doc_unlink(path) { if (!DOCTOR_REPAIR_MODE) { doc_plan("unlink " + as_string(path)); return; } try { fs.unlink(path); } catch (e) {} } function doc_symlink(target, path) { if (!DOCTOR_REPAIR_MODE) { doc_plan("symlink " + as_string(target) + " -> " + as_string(path)); return true; } try { fs.symlink(target, path); return true; } catch (e) { return false; } } function run_recovery_checks() { let report = []; let issues = 0; let fixed = 0; let doc_check = function(icon, name, status, fix_msg) { let msg = fix_msg != "" ? fix_msg : status; push(report, sprintf("%s %-30s %s", icon, name, msg)); }; // In dry-run the restoration steps are reported as planned instead of // being applied, so a plain diagnostic pass never mutates the system. let mark_fixed = function(name, status, what) { if (DOCTOR_REPAIR_MODE) { doc_check("❌", name, status, "→ FIXED: " + what); fixed++; } else { doc_check("⚠️", name, status, "→ WILL FIX (doctor --fix): " + what); } }; let time_str = command_output_from_args(["date", "+%d.%m %H:%M"]); push(report, sprintf("🩺 *tachyon doctor* — %s — *режим восстановления*", trim(time_str))); if (!DOCTOR_REPAIR_MODE) push(report, "Сервис Tachyon остановлен. Сухой режим: показываю, что будет восстановлено (применение — doctor --fix)."); else push(report, "Сервис Tachyon остановлен. Возвращаю систему в сток и проверяю интернет."); push(report, ""); // WAN must be up before DNS makes any sense. if (wan_has_ip() && default_gateway_exists()) { doc_check("✅", "WAN interface", get_wan_interface() + " up, gateway present", ""); } else { issues++; let had_ip = wan_has_ip(); doc_run("/sbin/ifup wan >/dev/null 2>&1"); doc_run("sleep 3"); if (!had_ip) { if (!DOCTOR_REPAIR_MODE || wan_has_ip()) { mark_fixed("WAN interface", get_wan_interface() + " no IP", "WAN поднят"); } else { doc_check("❌", "WAN interface", "no IP address", "→ проверьте подключение к провайдеру"); } } else { if (!DOCTOR_REPAIR_MODE || default_gateway_exists()) { mark_fixed("Default gateway", "missing", "маршрут восстановлен"); } else { doc_check("❌", "Default gateway", "missing", "→ проверьте конфигурацию сети"); } } } // Leftover routing is what breaks internet when the service is down. // Idempotent cleanup mirroring uninstall.uc. if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { doc_run("nft delete table inet " + NFT_TABLE_NAME + " >/dev/null 2>&1"); mark_fixed("nftables table", "leftover", "удалена"); } else { doc_check("✅", "nftables table", "absent", ""); } let ip_rule_out = command_capture("ip rule list").output; if (index(ip_rule_out, "fwmark") >= 0 && index(ip_rule_out, "lookup " + RT_TABLE_NAME) >= 0) { doc_run("ip rule del fwmark 0x1/0x1 >/dev/null 2>&1"); doc_run("ip rule del fwmark 0x2/0x2 >/dev/null 2>&1"); doc_run("ip route flush table " + RT_TABLE_NAME + " >/dev/null 2>&1"); mark_fixed("routing rules (fwmark)", "leftover", "удалены"); } else { doc_check("✅", "routing rules (fwmark)", "absent", ""); } // dnsmasq must answer the LAN and talk to upstream directly. if (module_status(DNS_APPLY_UC, [ "has-tachyon-dns" ]) == 0) { issues++; if (DOCTOR_REPAIR_MODE) module_status(DNS_APPLY_UC, [ "failsafe-restore" ]); else doc_plan("dns/apply.uc failsafe-restore"); if (!DOCTOR_REPAIR_MODE || module_status(DNS_APPLY_UC, [ "has-tachyon-dns" ]) != 0) { mark_fixed("dnsmasq DNS", "redirected to sing-box", "возвращён на прямые upstream"); } else { doc_check("❌", "dnsmasq DNS", "redirected to sing-box", "→ не удалось восстановить — проверьте /etc/config/dhcp"); } } else { doc_check("✅", "dnsmasq DNS", "direct (stock)", ""); } let dropins = [ "/etc/dnsmasq.d/tachyon.conf", "/tmp/dnsmasq.d/tachyon.conf" ]; let dropins_removed = false; for (let d in dropins) { if (fs.stat(d) != null) { doc_unlink(d); dropins_removed = true; } } if (dropins_removed) { mark_fixed("dnsmasq drop-ins", "leftover", "удалены"); } else { doc_check("✅", "dnsmasq drop-ins", "absent", ""); } // resolv.conf must be the stock symlink and point at a working upstream. let resolv_fixed = false; let resolv_link = ""; try { resolv_link = fs.readlink("/etc/resolv.conf") || ""; } catch(e) {} if (resolv_link != "/tmp/resolv.conf" && resolv_link != "../tmp/resolv.conf") { doc_unlink("/etc/resolv.conf"); if (doc_symlink("/tmp/resolv.conf", "/etc/resolv.conf")) resolv_fixed = true; } if (trim(fs.readfile("/tmp/resolv.conf") || "") == "") { if (DOCTOR_REPAIR_MODE) { fs.writefile("/tmp/resolv.conf", "nameserver 1.1.1.1\nnameserver 8.8.8.8\n"); } else { doc_plan("write /tmp/resolv.conf nameservers"); } resolv_fixed = true; } if (resolv_fixed) { mark_fixed("resolv.conf", "broken", "восстановлена ссылка и nameserver"); } else { doc_check("✅", "resolv.conf", "OK (-> /tmp/resolv.conf)", ""); } // A stray sing-box (crashed service, half-removed install) must not sit // between the LAN and the WAN. if (find_process_pid("sing-box") != "") { if (DOCTOR_REPAIR_MODE) kill_our_core_processes(); else doc_plan("kill leftover sing-box processes"); mark_fixed("sing-box process", "leftover", "остановлен"); } else { doc_check("✅", "sing-box process", "absent", ""); } // Final verification: DNS through dnsmasq and straight upstream. push(report, ""); let lan_dns = dns_check_resolve_host("google.com", "127.0.0.1", 3); let up_dns = dns_check_resolve_host("google.com", "1.1.1.1", 3); if (lan_dns != "" && up_dns != "") { doc_check("✅", "DNS resolution", "LAN + upstream working", ""); } else if (lan_dns != "") { issues++; doc_check("⚠️", "DNS resolution", "LAN OK, upstream blocked", "→ провайдер режет upstream, проверьте /tmp/resolv.conf"); } else { issues++; doc_check("❌", "DNS resolution", "not working", "→ проверьте интернет-соединение"); } push(report, ""); if (issues == 0) { push(report, "✅ Система в стоковом состоянии — интернет работает напрямую"); } else if (DOCTOR_REPAIR_MODE) { push(report, sprintf("⚠️ Проблем: %d Исправлено: %d", issues, fixed)); } else { push(report, sprintf("⚠️ Проблем: %d Планируется к исправлению: %d (применить: tachyon doctor --fix)", issues, length(DOCTOR_PLANNED_FIXES))); } return { report: join("\n", report) + "\n", issues, fixed, checks: [], planned_fixes: DOCTOR_PLANNED_FIXES }; } function run_doctor_checks_impl(repair) { DOCTOR_REPAIR_MODE = (repair == true); DOCTOR_PLANNED_FIXES = []; let report = []; let issues = 0; let fixed = 0; let cfg = uci_settings(); // With the service stopped the doctor switches to recovery mode: it must // restore the stock internet (DNS back to direct upstream, no leftover // rules) instead of "repairing" the stopped state by re-hijacking dnsmasq // onto a dead sing-box DNS listener — the exact way a user ends up with no // internet after disabling Tachyon (issue #31). if (!tachyon_is_running()) { return run_recovery_checks(); } let is_degraded_flag = is_degraded(); let checks = []; let doc_check = function(icon, name, status, fix_msg) { let msg = status; if (fix_msg != "") { msg = fix_msg; } push(report, sprintf("%s %-30s %s", icon, name, msg)); push(checks, { name: name, status: icon == "✅" ? "pass" : (icon == "⚠️" ? "warn" : (icon == "❌" ? "fail" : "info")), detail: status, fix: fix_msg }); }; let time_str = command_output_from_args(["date", "+%d.%m %H:%M"]); push(report, sprintf("🩺 *tachyon doctor* — %s", trim(time_str))); push(report, ""); if (cfg.recovery_bypass == "1") { push(report, "⚠️ *Режим аварийного обхода (Safe Bypass) активен.*"); push(report, "Все прокси-службы и правила фильтрации временно отключены."); push(report, ""); let wd_running = false; let wd_pid = trim(fs.readfile("/var/run/tachyon_watchdog.pid") || ""); if (wd_pid != "" && fs.stat("/proc/" + wd_pid) != null) { wd_running = true; } let core_disp = cfg.core || "sing-box"; doc_check("⚫", core_disp, "bypassed (stopped)", ""); doc_check("⚫", "nftables rules", "bypassed (flushed)", ""); doc_check("⚫", "routing rules (ip rule)", "bypassed (removed)", ""); doc_check("⚫", "dnsmasq server", "bypassed (direct WAN)", ""); if (dns_check_resolve_host("google.com", "127.0.0.1", 2) != "") { doc_check("✅", "DNS resolution", "working (direct)", ""); } else { doc_check("❌", "DNS resolution", "failed", "→ Проверьте подключение к интернету"); issues++; } if (wd_running) { doc_check("✅", "Watchdog", "running (standby)", ""); } else { doc_check("⚠️", "Watchdog", "stopped", "→ перезапустите службу"); issues++; } push(report, ""); push(report, "ℹ️ Автоматические проверки приостановлены в режиме Safe Bypass."); return { report: join("\n", report) + "\n", issues, fixed, checks }; } let binary_name = "sing-box"; let init_script = "/etc/init.d/sing-box"; let config_file_path = "/etc/sing-box/config.json"; // 1. Process Check let has_sections = false; let uci_sections = uci_core.get_all(CONFIG_NAME); if (uci_sections) { for (let k in keys(uci_sections)) { if (uci_sections[k][".type"] == "section") { has_sections = true; break; } } } let pid = find_process_pid(binary_name); if (pid != "") { doc_check("✅", binary_name + " process", "running", ""); } else if (!has_sections) { doc_check("ℹ️", binary_name + " process", "not started", "→ Настройте подключение в LuCI — ядро запустится автоматически"); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("kill conflicting processes; " + init_script + " start"); doc_check("⚠️", binary_name + " process", "stopped", "→ WILL FIX (doctor --fix): запуск после очистки конфликтующих портов"); } else { kill_our_core_processes(); command_status("sleep 1"); command_status(init_script + " start >/dev/null 2>&1"); command_status("sleep 3"); pid = find_process_pid(binary_name); if (pid != "") { doc_check("❌", binary_name + " process", "stopped", "→ FIXED: запущен после очистки конфликтующих портов"); fixed++; } else { doc_check("❌", binary_name + " process", "stopped", "→ не удалось запустить — проверьте логи"); } } } // 2. Configuration Check if (fs.stat(config_file_path) != null) { let check_res = command_status(binary_name + " check -c " + config_file_path + " >/dev/null 2>&1"); if (check_res == 0) { doc_check("✅", binary_name + " config", "valid", ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("regenerate sing-box config (configure-service) + restart"); doc_check("⚠️", binary_name + " config", "invalid", "→ WILL FIX (doctor --fix): пересоздание конфига"); } else { let regen_status = command_status("ucode -L " + LIB_DIR + " " + SINGBOX_RUNTIME_UC + " configure-service >/dev/null 2>&1"); if (regen_status == 0) { let check_res2 = command_status(binary_name + " check -c " + config_file_path + " >/dev/null 2>&1"); if (check_res2 == 0) { doc_check("❌", binary_name + " config", "invalid", "→ FIXED: пересоздан и успешно валидирован"); fixed++; command_status(init_script + " restart >/dev/null 2>&1"); } else { doc_check("❌", binary_name + " config", "invalid", "→ не удалось восстановить (ошибка валидации)"); } } else { doc_check("❌", binary_name + " config", "invalid", "→ не удалось перегенерировать конфиг"); } } } } else { if (!has_sections) { doc_check("ℹ️", binary_name + " config", "not yet created", "→ Настройте Tachyon в LuCI для генерации конфига"); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("regenerate sing-box config (configure-service)"); doc_check("⚠️", "sing-box config", "missing", "→ WILL FIX (doctor --fix): генерация конфига"); } else { let regen_status = command_status("ucode -L " + LIB_DIR + " " + SINGBOX_RUNTIME_UC + " configure-service >/dev/null 2>&1"); if (regen_status == 0 && fs.stat(config_file_path) != null) { doc_check("❌", "sing-box config", "missing", "→ FIXED: пересоздан"); fixed++; } else { doc_check("❌", binary_name + " config", "missing", "→ не удалось сгенерировать config"); } } } } // 2b. UCI Config Integrity Check if (has_sections) { if (uci_config_valid()) { doc_check("✅", "UCI config", "valid", ""); uci_backup_save(); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("restore UCI config from backup"); doc_check("⚠️", "UCI config", "corrupted", "→ WILL FIX (doctor --fix): восстановление из backup"); } else if (uci_backup_restore()) { command_status("sleep 1"); if (uci_config_valid()) { doc_check("❌", "UCI config", "corrupted", "→ FIXED: восстановлен из backup"); fixed++; } else { doc_check("❌", "UCI config", "corrupted", "→ backup тоже повреждён, проверьте /etc/config/tachyon вручную"); } } else { doc_check("❌", "UCI config", "corrupted or missing", "→ backup не найден, восстановите конфиг вручную"); } } } // 3. Nftables Table Check let routing_mode = cfg.routing_mode || "nftables"; if (routing_mode == "nftables") { let out_nft = command_capture("nft list table inet " + NFT_TABLE_NAME + " | grep tproxy").output; if (index(out_nft, "tproxy") >= 0) { doc_check("✅", "nftables table", "present", ""); } else { if (is_degraded_flag) { doc_check("⚠️", "nftables table", "missing or incomplete", "→ GRACEFUL DEGRADATION: Proxy offline"); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("delete nft table + /usr/bin/tachyon restart"); doc_check("⚠️", "nftables table", "missing or incomplete", "→ WILL FIX (doctor --fix): пересоздание правил"); } else { command_status("nft delete table inet " + NFT_TABLE_NAME + " >/dev/null 2>&1"); let rebuild_status = command_status("/usr/bin/tachyon restart >/dev/null 2>&1"); let out_nft_check = command_capture("nft list table inet " + NFT_TABLE_NAME + " | grep tproxy").output; if (index(out_nft_check, "tproxy") >= 0) { doc_check("❌", "nftables table", "missing or incomplete", "→ FIXED: правила пересозданы"); fixed++; } else { doc_check("❌", "nftables table", "missing or incomplete", "→ не удалось восстановить nftables правила"); } } } } // 4. IP Rule Check let ip_rule_out = command_capture("ip rule list").output; if (index(ip_rule_out, "fwmark") >= 0 && index(ip_rule_out, "lookup " + RT_TABLE_NAME) >= 0) { doc_check("✅", "ip rule (fwmark)", "present", ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("/usr/bin/tachyon restart"); doc_check("⚠️", "ip rule", "missing", "→ WILL FIX (doctor --fix): восстановление маршрута"); } else { let rebuild_status = command_status("/usr/bin/tachyon restart >/dev/null 2>&1"); let ip_rule_check = command_capture("ip rule list").output; if (index(ip_rule_check, "fwmark") >= 0 && index(ip_rule_check, "lookup " + RT_TABLE_NAME) >= 0) { doc_check("❌", "ip rule", "missing", "→ FIXED: маршрут восстановлен"); fixed++; } else { doc_check("❌", "ip rule", "missing", "→ не удалось восстановить ip rule"); } } } } else { let ip_link_out = command_capture("ip link show tun0").output; if (index(ip_link_out, "tun0") >= 0) { doc_check("✅", "tun0 interface", "up", ""); } else { issues++; command_status(init_script + " restart >/dev/null 2>&1"); command_status("sleep 3"); let ip_link_check = command_capture("ip link show tun0").output; if (index(ip_link_check, "tun0") >= 0) { doc_check("❌", "tun0 interface", "missing", "→ FIXED: интерфейс tun0 поднят после перезапуска службы"); fixed++; } else { doc_check("❌", "tun0 interface", "missing", "→ не удалось поднять tun0"); } } } // 4e. Dnsmasq Redirection Check if (module_status(DNS_APPLY_UC, [ "has-tachyon-dns" ]) == 0) { doc_check("✅", "dnsmasq server (Direct)", SB_DNS_INBOUND_ADDRESS, ""); } else { if (is_degraded_flag) { doc_check("⚠️", "dnsmasq server (Direct)", "direct", "→ GRACEFUL DEGRADATION: Proxy offline"); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("dns/apply.uc configure force"); doc_check("⚠️", "dnsmasq server (Direct)", "incorrect", "→ WILL FIX (doctor --fix): перенаправление на sing-box (" + SB_DNS_INBOUND_ADDRESS + ")"); } else { module_status(DNS_APPLY_UC, [ "configure", "force" ]); command_status("sleep 1"); if (module_status(DNS_APPLY_UC, [ "has-tachyon-dns" ]) == 0) { doc_check("❌", "dnsmasq server (Direct)", "incorrect", "→ FIXED: направлен на sing-box (" + SB_DNS_INBOUND_ADDRESS + ")"); fixed++; } else { doc_check("❌", "dnsmasq server (Direct)", "incorrect", "→ не удалось перенаправить"); } } } } // 4b. Dnsmasq Params Check let noresolv = uci_core.get("dhcp.@dnsmasq[0].noresolv"); let localuse = uci_core.get("dhcp.@dnsmasq[0].localuse"); let rebind_protection = uci_core.get("dhcp.@dnsmasq[0].rebind_protection"); if (noresolv == "1" && localuse == "1" && rebind_protection == "0") { doc_check("✅", "dnsmasq params", "OK (noresolv=1, localuse=1, rebind_protection=0)", ""); } else { issues++; // noresolv/localuse are required for the sing-box DNS redirect to // work; rebind_protection=0 is a deliberate compatibility downgrade // for FakeIP answers — it must never be applied silently by a // diagnostic pass. if (!DOCTOR_REPAIR_MODE) { doc_plan("uci set dhcp noresolv=1 localuse=1 rebind_protection=0 + dnsmasq restart"); doc_check("⚠️", "dnsmasq params", "incorrect", "→ WILL FIX (doctor --fix): noresolv=1, localuse=1, rebind_protection=0"); } else { doc_set("dhcp.@dnsmasq[0].noresolv", "1"); doc_set("dhcp.@dnsmasq[0].localuse", "1"); doc_set("dhcp.@dnsmasq[0].rebind_protection", "0"); doc_commit("dhcp"); command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1"); command_status("sleep 1"); let noresolv2 = uci_core.get("dhcp.@dnsmasq[0].noresolv"); let localuse2 = uci_core.get("dhcp.@dnsmasq[0].localuse"); let rebind_protection2 = uci_core.get("dhcp.@dnsmasq[0].rebind_protection"); if (noresolv2 == "1" && localuse2 == "1" && rebind_protection2 == "0") { doc_check("❌", "dnsmasq params", "incorrect", "→ FIXED: noresolv=1, localuse=1, rebind_protection=0"); fixed++; } else { doc_check("❌", "dnsmasq params", "incorrect", "→ не удалось исправить параметры"); } } } // 4c. Resolv.conf symlink let resolv_link = ""; // Throws when /etc/resolv.conf is a regular file rather than a symlink, // which is precisely the broken state the branch below repairs. try { resolv_link = fs.readlink("/etc/resolv.conf") || ""; } catch(e) {} if (resolv_link == "/tmp/resolv.conf" || resolv_link == "../tmp/resolv.conf") { doc_check("✅", "resolv.conf symlink", "OK (-> " + resolv_link + ")", ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("restore /etc/resolv.conf -> /tmp/resolv.conf symlink"); doc_check("⚠️", "resolv.conf symlink", "broken", "→ WILL FIX (doctor --fix): восстановление ссылки"); } else { fs.unlink("/etc/resolv.conf"); let sym_ok = false; try { fs.symlink("/tmp/resolv.conf", "/etc/resolv.conf"); sym_ok = true; } catch (e) { // sym_ok stays false and the failure is reported to the user through // doc_check() below, which is this module's output channel. } if (sym_ok) { doc_check("❌", "resolv.conf symlink", "broken", "→ FIXED: восстановлена ссылка на /tmp/resolv.conf"); fixed++; } else { doc_check("❌", "resolv.conf symlink", "broken", "→ не удалось восстановить ссылку"); } } } // 5. DNS configuration servers let bootstrap_dns = "77.88.8.8"; let main_dns = "1.1.1.1"; let main_dns_type = "udp"; if (cfg.bootstrap_dns_server) { let b_dns_list = type(cfg.bootstrap_dns_server) == "array" ? cfg.bootstrap_dns_server : split(trim(as_string(cfg.bootstrap_dns_server)), /\s+/); if (length(b_dns_list) > 0) bootstrap_dns = b_dns_list[0]; } if (cfg.dns_server) { let m_dns_list = type(cfg.dns_server) == "array" ? cfg.dns_server : split(trim(as_string(cfg.dns_server)), /\s+/); if (length(m_dns_list) > 0) main_dns = m_dns_list[0]; } main_dns_type = cfg.dns_type || "udp"; // Strip proxy_group suffix let display_main = split(main_dns, "#")[0]; let display_bootstrap = split(bootstrap_dns, "#")[0]; // Check Bootstrap DNS let bootstrap_dns_reachable = false; if (dns_check_resolve_host("openwrt.org", bootstrap_dns, 2) != "") { bootstrap_dns_reachable = true; doc_check("✅", "DNS bootstrap (" + display_bootstrap + ")", "reachable", ""); } else { issues++; if (command_status("ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1") == 0) { if (!DOCTOR_REPAIR_MODE) { doc_plan("reset resolv.conf to public nameservers + dnsmasq restart"); doc_check("⚠️", "DNS bootstrap (" + display_bootstrap + ")", "unreachable", "→ WILL FIX (doctor --fix): сброс resolv.conf на 1.1.1.1"); } else { fs.unlink("/etc/resolv.conf"); // If the symlink cannot be created the writefile below still lands on // /tmp/resolv.conf and the dnsmasq restart still picks it up; the // resolve check that follows decides whether any of it worked. try { fs.symlink("/tmp/resolv.conf", "/etc/resolv.conf"); } catch(e) {} fs.writefile("/tmp/resolv.conf", "nameserver 1.1.1.1\nnameserver 8.8.8.8\n"); command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1"); command_status("sleep 2"); if (dns_check_resolve_host("openwrt.org", bootstrap_dns, 2) != "") { bootstrap_dns_reachable = true; doc_check("❌", "DNS bootstrap (" + display_bootstrap + ")", "unreachable", "→ FIXED: сброшен resolv.conf на 1.1.1.1, DNS перезапущен"); fixed++; } else { doc_check("❌", "DNS bootstrap (" + display_bootstrap + ")", "unreachable", "→ DNS заблокирован или недоступен"); } } } else { if (!DOCTOR_REPAIR_MODE) { doc_plan("/sbin/ifup wan"); doc_check("⚠️", "DNS bootstrap (" + display_bootstrap + ")", "unreachable", "→ WILL FIX (doctor --fix): перезапуск WAN-линка"); } else { command_status("/sbin/ifup wan >/dev/null 2>&1"); command_status("sleep 3"); doc_check("❌", "DNS bootstrap (" + display_bootstrap + ")", "unreachable", "→ FIXED: линк отсутствует, отправлен сигнал перезапуска WAN"); fixed++; } } } // Check Main DNS let dns_main_reachable = false; if (main_dns_type == "doh") { let doh_url = display_main; if (!match(doh_url, /^https?:\/\//)) doh_url = "https://" + doh_url; if (!match(doh_url, /\/dns-query$/) && !match(doh_url, /\/query$/) && !match(doh_url, /^https?:\/\/[^\/]+\/.+/)) doh_url = doh_url + "/dns-query"; let curl_cmd = "curl -s -m 4 -o /dev/null -w '%{http_code}' " + shell_quote(doh_url) + " 2>/dev/null"; let curl_res = command_capture(curl_cmd); if (curl_res.status == 0 && int(curl_res.output) < 400) { dns_main_reachable = true; doc_check("✅", "DNS main (" + display_main + ")", "reachable", ""); } else { let base_url = replace(doh_url, /\/dns-query$/, ""); let curl_cmd2 = "curl -s -m 4 -o /dev/null -w '%{http_code}' " + shell_quote(base_url) + " 2>/dev/null"; let curl_res2 = command_capture(curl_cmd2); if (curl_res2.status == 0 && int(curl_res2.output) < 400) { dns_main_reachable = true; doc_check("✅", "DNS main (" + display_main + ")", "reachable", ""); } else { doc_check("⚠️", "DNS main (" + display_main + ")", "ISP blocks direct DoH", "→ Норма: провайдер блокирует DoH напрямую, sing-box использует DoH через прокси"); } } } else { if (dns_check_resolve_host("openwrt.org", main_dns, 2) != "") { dns_main_reachable = true; doc_check("✅", "DNS main (" + display_main + ")", "reachable", ""); } else { issues++; if (bootstrap_dns_reachable) { if (!DOCTOR_REPAIR_MODE) { doc_plan("dnsmasq restart"); doc_check("⚠️", "DNS main (" + display_main + ")", "unreachable", "→ WILL FIX (doctor --fix): перезапуск dnsmasq"); } else { command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1"); command_status("sleep 1"); if (dns_check_resolve_host("openwrt.org", main_dns, 2) != "") { dns_main_reachable = true; doc_check("❌", "DNS main (" + display_main + ")", "unreachable", "→ FIXED: перезапущен dnsmasq"); fixed++; } } } if (!dns_main_reachable && DOCTOR_REPAIR_MODE) { doc_check("❌", "DNS main (" + display_main + ")", "unreachable", "→ Основной DNS недоступен"); } } } // 5b. DNS Resolution through sing-box if (has_sections) { if (dns_check_through_singbox("google.com")) { doc_check("✅", "sing-box DNS", "resolving via " + SB_DNS_INBOUND_ADDRESS, ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("dnsmasq -> sing-box DNS reconfigure; if needed service restart"); doc_check("⚠️", "sing-box DNS", "not resolving", "→ WILL FIX (doctor --fix): перенаправление dnsmasq на sing-box"); } else { module_status(DNS_APPLY_UC, [ "configure", "force" ]); command_status("sleep 1"); if (dns_check_through_singbox("google.com")) { doc_check("❌", "sing-box DNS", "not resolving", "→ FIXED: dnsmasq перенаправлен на sing-box"); fixed++; } else { command_status(init_script + " restart >/dev/null 2>&1"); command_status("sleep 3"); if (dns_check_through_singbox("google.com")) { doc_check("❌", "sing-box DNS", "not resolving", "→ FIXED: sing-box перезапущен"); fixed++; } else { doc_check("❌", "sing-box DNS", "not resolving", "→ критическая ошибка DNS"); } } } } } // 5c. Multi-DNS validation let all_bootstrap = get_all_dns_servers(cfg, "bootstrap_dns_server"); if (length(all_bootstrap) == 0) all_bootstrap = ["77.88.8.8"]; let reachable_count = 0; for (let srv in all_bootstrap) { if (dns_check_resolve_host("openwrt.org", srv, 2) != "") reachable_count++; } if (reachable_count == length(all_bootstrap)) { doc_check("✅", "DNS servers", sprintf("all %d reachable", reachable_count), ""); } else if (reachable_count > 0) { doc_check("⚠️", "DNS servers", sprintf("%d/%d reachable", reachable_count, length(all_bootstrap)), "→ некоторые серверы недоступны"); } else { issues++; doc_check("❌", "DNS servers", "all unreachable", "→ все bootstrap DNS серверы недоступны"); } // 6. Clash API Check let clash_addr = clash_api_url(); let curl_clash = command_capture("curl -s -m 5 -o /dev/null -w %{http_code} http://" + clash_addr + "/version"); if (curl_clash.status == 0 && int(curl_clash.output) == 200) { doc_check("✅", "Clash API", "reachable (" + clash_addr + ")", ""); } else { issues++; if (pid != "") { command_status(init_script + " restart >/dev/null 2>&1"); command_status("sleep 3"); let curl_clash2 = command_capture("curl -s -m 5 -o /dev/null -w %{http_code} http://" + clash_addr + "/version"); if (curl_clash2.status == 0 && int(curl_clash2.output) == 200) { doc_check("❌", "Clash API", "unreachable", "→ FIXED: sing-box перезапущен"); fixed++; } else { doc_check("❌", "Clash API", "unreachable", "→ sing-box не отвечает на Clash API"); } } else { doc_check("⚠️", "Clash API", "unreachable", "→ sing-box не запущен"); } } // 6c. WireGuard/AWG tunnel health from recent core logs. A userspace // wireguard outbound whose peer never answers produces a steady stream // of "operation timed out" connection errors - the config is applied // correctly, the failure is outside Tachyon (server down, ISP filtering // the UDP endpoint). Surfacing this as its own check stops the classic // "Tachyon broke my AWG" misattribution. if (pid != "") { let wg_log = lc(command_capture("logread -l 400 2>/dev/null | grep -i 'outbound/wireguard' | tail -n 8").output); let wg_failures = index(wg_log, "operation timed out") >= 0 || (index(wg_log, "handshake") >= 0 && index(wg_log, "timeout") >= 0); if (wg_log != "" && wg_failures) { issues++; doc_check("⚠️", "WireGuard/AWG tunnel", "peer not answering through core", "→ ядро не получает ответов от пира (запросы уходят, ответы нет): проверьте доступность AWG/WARP-сервера и UDP-порта у провайдера. Конфигурация секции применена корректно — проблема вне Tachyon"); } else if (wg_log != "") { doc_check("✅", "WireGuard/AWG tunnel", "no recent failures in core log", ""); } } // 6d. DPI bypass providers health (Zapret, Zapret2, ByeDPI) let providers_to_check = [ { name: "Zapret", kind: "zapret", runtime: ZAPRET_RUNTIME_UC }, { name: "Zapret2", kind: "zapret2", runtime: ZAPRET2_RUNTIME_UC }, { name: "ByeDPI", kind: "byedpi", runtime: BYEDPI_RUNTIME_UC } ]; for (let p in providers_to_check) { let raw_st = trim(command_capture("ucode -L " + LIB_DIR + " " + p.runtime + " status 2>/dev/null").output); if (raw_st == "") continue; let st = null; try { st = json(raw_st); } catch (e) {} if (!st || st.configured != true) continue; // Check if Tachyon-managed runtime is ready if (st.ready == true) { doc_check("✅", p.name + " runtime", sprintf("ready (%d/%d workers)", st.running_process_count || 0, st.expected_process_count || 0), ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("ucode -L " + LIB_DIR + " " + p.runtime + " start-runtime"); doc_check("⚠️", p.name + " runtime", sprintf("not ready (%d/%d workers)", st.running_process_count || 0, st.expected_process_count || 0), "→ WILL FIX: запуск воркеров Tachyon " + p.name); } else { command_status("ucode -L " + LIB_DIR + " " + p.runtime + " start-runtime >/dev/null 2>&1"); command_status("sleep 1"); let raw_st2 = trim(command_capture("ucode -L " + LIB_DIR + " " + p.runtime + " status 2>/dev/null").output); let st2 = null; try { st2 = json(raw_st2); } catch (e) {} if (st2 && st2.ready == true) { doc_check("❌", p.name + " runtime", sprintf("not ready (%d/%d workers)", st.running_process_count || 0, st.expected_process_count || 0), "→ FIXED: воркеры " + p.name + " запущены"); fixed++; } else { doc_check("❌", p.name + " runtime", "failed to start", "→ не удалось запустить воркеры " + p.name); } } } } // 7. Free RAM Check let free_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) { free_mb = int(fields[1]) / 1024; } break; } } if (free_mb > 20) { doc_check("✅", "Free RAM", sprintf("%dMB", free_mb), ""); } else if (free_mb >= 0) { issues++; doc_check("⚠️", "Free RAM", sprintf("%dMB", free_mb), "→ Мало памяти!"); if (!DOCTOR_REPAIR_MODE) { doc_plan("drop OS caches + sing-box restart"); doc_check("⚠️", "Free RAM", sprintf("%dMB", free_mb), "→ WILL FIX (doctor --fix): сброс кэшей памяти и перезапуск sing-box"); } else { command_status("sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null; /etc/init.d/sing-box restart >/dev/null 2>&1"); doc_check("⚠️", "Free RAM", sprintf("%dMB", free_mb), "→ FIXED: кэши памяти сброшены, sing-box перезапущен"); fixed++; } } else { doc_check("✅", "Free RAM", "unknown", ""); } // 9. Watchdog Process Check let watchdog_running = false; let watchdog_pid = trim(fs.readfile("/var/run/tachyon_watchdog.pid") || ""); if (watchdog_pid != "" && fs.stat("/proc/" + watchdog_pid) != null) { watchdog_running = true; } if (watchdog_running) { doc_check("✅", "Watchdog", "running", ""); } else { if (cfg.enable_watchdog != "0") { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("/etc/init.d/tachyon restart"); doc_check("⚠️", "Watchdog", "dead", "→ WILL FIX (doctor --fix): перезапуск системы"); } else { let restart_status = command_status("/etc/init.d/tachyon restart >/dev/null 2>&1"); if (restart_status == 0) { doc_check("❌", "Watchdog", "dead", "→ FIXED: система перезапущена"); fixed++; } else { doc_check("❌", "Watchdog", "dead", "→ не удалось перезапустить систему"); } } } else { doc_check("⚫", "Watchdog", "disabled (ok)", ""); } } // Tailscale native runtime check (skipped entirely without native sections // so sing-box-mode users never see the node). let ts_status_raw = trim(module_output(TAILSCALE_RUNTIME_UC, [ "status" ])); if (ts_status_raw != "") { let ts = object_or_empty(json(ts_status_raw)); if (ts.configured == true) { if (ts.installed != true) { issues++; doc_check("⚠️", "Tailscale", "package missing", "→ install the Tailscale component on the Updates tab"); } else if (ts.ready == true) { doc_check("✅", "Tailscale", "running (" + as_string(ts.version || "") + ")", ""); } else { issues++; doc_check("❌", "Tailscale", as_string(ts.status_message || "not ready"), "→ run: tachyon tailscale_restart"); } } } // 11. MSS Clamping Check if (routing_mode == "nftables") { let out_clamping = command_capture("nft list table inet " + NFT_TABLE_NAME + " | grep maxseg").output; if (index(out_clamping, "tcp flags syn tcp option maxseg size set rt mtu") >= 0 || index(out_clamping, "tcp flags syn tcp option maxseg size set 1400") >= 0) { doc_check("✅", "MSS Clamping rule", "active", ""); } else { if (is_degraded_flag) { doc_check("⚠️", "MSS Clamping rule", "missing or inactive", "→ GRACEFUL DEGRADATION: Proxy offline"); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("inject MSS clamping nft rules"); doc_check("⚠️", "MSS Clamping rule", "missing", "→ WILL FIX (doctor --fix): применение MSS Clamping"); } else { command_status("nft add chain inet " + NFT_TABLE_NAME + " mangle_forward '{ type filter hook forward priority -150; }' >/dev/null 2>&1"); command_status("nft add chain inet " + NFT_TABLE_NAME + " mangle_output '{ type filter hook output priority -150; }' >/dev/null 2>&1"); let r1 = command_status("nft add rule inet " + NFT_TABLE_NAME + " mangle_forward tcp flags syn tcp option maxseg size set rt mtu >/dev/null 2>&1"); let r2 = command_status("nft add rule inet " + NFT_TABLE_NAME + " mangle_output tcp flags syn tcp option maxseg size set rt mtu >/dev/null 2>&1"); if (r1 != 0 || r2 != 0) { command_status("nft add rule inet " + NFT_TABLE_NAME + " mangle_forward tcp flags syn tcp option maxseg size set 1400 >/dev/null 2>&1"); command_status("nft add rule inet " + NFT_TABLE_NAME + " mangle_output tcp flags syn tcp option maxseg size set 1400 >/dev/null 2>&1"); } let out_clamping_check = command_capture("nft list table inet " + NFT_TABLE_NAME + " | grep maxseg").output; if (index(out_clamping_check, "tcp flags syn tcp option maxseg size set rt mtu") >= 0 || index(out_clamping_check, "tcp flags syn tcp option maxseg size set 1400") >= 0) { doc_check("❌", "MSS Clamping rule", "missing", "→ FIXED: MSS Clamping rules applied"); fixed++; } else { doc_check("❌", "MSS Clamping rule", "missing", "→ не удалось применить MSS Clamping"); } } } } } // 12. OOM audit Check // Note: the log is never wiped anymore — it is forensic evidence the // rest of the diagnostics pipeline relies on. let logread_out = command_capture("logread -l 200").output; let logread_lower = lc(logread_out); if (index(logread_lower, "out of memory") >= 0 || index(logread_lower, "oom-killer") >= 0) { issues++; let oom_victim_singbox = index(lc(logread_out), "killed process") >= 0 && match(lc(logread_out), /killed process[^\n]*sing-box/) != null; if (!DOCTOR_REPAIR_MODE) { doc_plan("clear system log + drop OS caches"); doc_check("❌", "System OOM checks", oom_victim_singbox ? "OOM detected, sing-box was killed" : "OOM detected in system logs", "→ WILL FIX (doctor --fix): очистка логов и сброс кэшей памяти"); } else { command_status("sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null; logread -c >/dev/null 2>&1; rm -f /etc/tachyon/mem_scale"); doc_check("❌", "System OOM checks", oom_victim_singbox ? "OOM detected, sing-box was killed" : "OOM detected in system logs", "→ FIXED: журналы сброшены, кэши памяти освобождены"); fixed++; } } else { doc_check("✅", "System OOM checks", "No OOM events detected in logs", ""); } // 13. Port conflicts let netstat_out = command_capture("netstat -ltnp").output; let mixed_port_str = as_string(common.get_mixed_port()); let ports_to_check = [mixed_port_str, "9090"]; for (let port in ports_to_check) { if (index(netstat_out, ":" + port + " ") >= 0) { let sb_pid = find_process_pid("sing-box"); if (sb_pid == "") { let conflict_pids = []; let conflict_names = []; for (let line in split(netstat_out, "\n")) { if (index(line, ":" + port + " ") >= 0) { let fields = split(trim(line), /[ \t]+/); if (length(fields) >= 7) { let pid_info = fields[6]; let slash_idx = index(pid_info, "/"); if (slash_idx >= 0) { let conflict_pid = substr(pid_info, 0, slash_idx); push(conflict_names, substr(pid_info, slash_idx + 1)); push(conflict_pids, conflict_pid); } } } } if (length(conflict_pids) > 0) { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("kill orphan port owner(s): " + join(",", conflict_pids)); doc_check("⚠️", "Port conflict :" + port, "port bound by " + join(",", conflict_names), "→ WILL FIX (doctor --fix): завершение процесса " + join(",", conflict_names)); } else { let killed = false; for (let conflict_pid in conflict_pids) { if (command_status("kill -9 " + conflict_pid + " >/dev/null 2>&1") == 0) killed = true; } if (killed) { doc_check("❌", "Port conflict :" + port, "port bound by orphan", "→ FIXED: конфликтный процесс завершен"); fixed++; } else { doc_check("❌", "Port conflict :" + port, "port bound by unknown process", "→ завершите процесс вручную"); } } } else { issues++; doc_check("❌", "Port conflict :" + port, "port bound by unknown process", "→ завершите процесс вручную"); } } else { doc_check("✅", "Port check :" + port, "bound by sing-box", ""); } } else { doc_check("✅", "Port check :" + port, "free", ""); } } // 14. WAN Interface Check if (wan_has_ip()) { doc_check("✅", "WAN interface", get_wan_interface() + " up", ""); } else if (bootstrap_dns_reachable || dns_main_reachable) { doc_check("ℹ️", "WAN interface", get_wan_interface() + " no IP (but DNS reachable — proxy working)", ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("ifup wan + route flush cache"); doc_check("⚠️", "WAN interface", get_wan_interface() + " no IP", "→ WILL FIX (doctor --fix): перезапуск WAN interface"); } else { command_status("ubus call network.interface.wan up 2>/dev/null; ifup wan 2>/dev/null; ip route flush cache 2>/dev/null"); command_status("sleep 2"); if (wan_has_ip()) { doc_check("❌", "WAN interface", get_wan_interface() + " no IP", "→ FIXED: WAN interface перезапущен"); fixed++; } else { doc_check("❌", "WAN interface", "no IP address", "→ проверьте подключение к провайдеру"); } } } // 15. Default Gateway Check if (default_gateway_exists()) { doc_check("✅", "Default gateway", "present", ""); } else if (bootstrap_dns_reachable || dns_main_reachable) { doc_check("ℹ️", "Default gateway", "not found (but DNS reachable — proxy working)", ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("ifup wan + route flush cache"); doc_check("⚠️", "Default gateway", "missing", "→ WILL FIX (doctor --fix): восстановление маршрута"); } else { command_status("ubus call network.interface.wan up 2>/dev/null; ifup wan 2>/dev/null; ip route flush cache 2>/dev/null"); command_status("sleep 2"); if (default_gateway_exists()) { doc_check("❌", "Default gateway", "missing", "→ FIXED: маршрут восстановлен через ifup wan"); fixed++; } else { doc_check("❌", "Default gateway", "missing", "→ проверьте конфигурацию сети"); } } } // 15b. Community Lists Presence Check // For every enabled section that declares community_lists, verify the // corresponding .srs files have been downloaded into TMP_RULESET_FOLDER. // Missing or empty files mean sing-box is running without the expected // routing data — this is the silent green-screen failure the user reported. { let missing_lists = []; let all_required = []; // Collect all unique community list names across enabled sections let all_sections = uci_core.section_objects(CONFIG_NAME, "section"); for (let sec in all_sections) { if (sec.enabled == "0") continue; let cl = sec.community_lists; if (!cl) continue; let list_arr = type(cl) == "array" ? cl : split(trim("" + cl), /\s+/); for (let entry in list_arr) { let name = trim("" + entry); if (name == "") continue; // Deduplicate let already = false; for (let existing in all_required) { if (existing == name) { already = true; break; } } if (!already) push(all_required, name); } } if (length(all_required) > 0) { for (let list_name in all_required) { let srs_path = TMP_RULESET_FOLDER + "/community-" + list_name + ".srs"; let srs_stat = fs.stat(srs_path); if (srs_stat == null || srs_stat.size == 0) { push(missing_lists, list_name); } } if (length(missing_lists) == 0) { doc_check("✅", "Community lists", sprintf("all %d lists present", length(all_required)), ""); } else { issues++; if (!DOCTOR_REPAIR_MODE) { doc_plan("/usr/bin/tachyon list_update"); doc_check("⚠️", "Community lists", sprintf("%d/%d missing", length(missing_lists), length(all_required)), "→ WILL FIX (doctor --fix): загрузка через list_update (с прокси-маршрутизацией при необходимости)"); } else { // Attempt repair: download missing lists command_status("/usr/bin/tachyon list_update > /dev/null 2>&1"); let still_missing = []; for (let list_name in missing_lists) { let srs_path = TMP_RULESET_FOLDER + "/community-" + list_name + ".srs"; let srs_stat = fs.stat(srs_path); if (srs_stat == null || srs_stat.size == 0) { push(still_missing, list_name); } } if (length(still_missing) > 0) { let first_proxy = ""; for (let s in all_sections) { if (s.enabled != "0" && s.action != "bypass" && s.action != "block" && s.action != "dns" && s.action != "") { first_proxy = s[".name"]; break; } } if (first_proxy != "") { command_status("uci set tachyon.settings.download_lists_via_proxy='1'; uci set tachyon.settings.download_lists_via_proxy_section=" + shell_quote(first_proxy) + "; uci commit tachyon >/dev/null 2>&1"); command_status("/usr/bin/tachyon list_update > /dev/null 2>&1"); still_missing = []; for (let list_name in missing_lists) { let srs_path = TMP_RULESET_FOLDER + "/community-" + list_name + ".srs"; let srs_stat = fs.stat(srs_path); if (srs_stat == null || srs_stat.size == 0) { push(still_missing, list_name); } } } } if (length(still_missing) == 0) { doc_check("❌", "Community lists", sprintf("%d/%d missing", length(missing_lists), length(all_required)), "→ FIXED: загружены через list_update (маршрут через прокси настроен)"); fixed++; } else { doc_check("❌", "Community lists", sprintf("%d/%d отсутствуют: %s", length(still_missing), length(all_required), join(", ", still_missing)), "→ не удалось загрузить — проверьте доступность прокси/GitHub"); } } } } } // 16. Subscription Health Check if (has_sections && cfg.subscription_url) { let sub_url = trim(as_string(cfg.subscription_url)); if (sub_url != "") { let sub_check = command_capture("curl -s -m 15 -o /dev/null -w %{http_code} --connect-timeout 5 " + shell_quote(sub_url) + " 2>&1"); let sub_code = int(sub_check.output); if (sub_check.status == 0 && sub_code >= 200 && sub_code < 400) { doc_check("✅", "Subscription", "active (HTTP " + sub_code + ")", ""); } else { issues++; doc_check("⚠️", "Subscription", "unreachable (HTTP " + sub_code + ")", "→ обновите подписку вручную"); } } } // 17. Disk Space Check — a full /tmp breaks cache writes, subscription // downloads and job state files; a full overlay breaks config commits. { let df_out = command_capture("df -k /tmp /overlay / 2>/dev/null").output; let disk_warned = {}; for (let line in split(df_out, "\n")) { let fields = split(trim(as_string(line)), /[ \t]+/); if (length(fields) < 5 || fields[0] == "Filesystem") continue; let mount = fields[length(fields) - 1]; let use_pct = int(replace(fields[length(fields) - 2], "%", "")); if (use_pct >= 90 && !disk_warned[mount]) { disk_warned[mount] = true; issues++; doc_check("⚠️", "Disk space", sprintf("%s %d%% full", mount, use_pct), "→ очистите место: переполнение ломает кэш и запись конфигов"); } } if (length(keys(disk_warned)) == 0) doc_check("✅", "Disk space", "OK", ""); } // 18. System Clock Check — a wrong year silently breaks every TLS // connection (DoH, subscriptions, LLM APIs) and is easy to miss after a // cold boot without NTP. { let year = int(command_output_from_args(["date", "+%Y"])); if (year >= 2024 && year <= 2100) { doc_check("✅", "System clock", sprintf("OK (%d)", year), ""); } else { issues++; doc_check("⚠️", "System clock", sprintf("suspicious year %d", year), "→ проверьте NTP/дату: неверное время ломает TLS (DoH, подписки)"); } } // 19. CA Bundle Check — curl HTTPS fails with SSL errors when the // ca-bundle package is missing; the code blames it in TLS verdicts but // never verified it before. { let ca_found = fs.stat("/etc/ssl/certs/ca-certificates.crt") != null || fs.stat("/etc/ssl/certs/ca-bundle.crt") != null; if (ca_found) { doc_check("✅", "CA certificates", "ca-bundle present", ""); } else { issues++; doc_check("⚠️", "CA certificates", "/etc/ssl/certs/ missing", "→ установите пакет ca-bundle: без него HTTPS-проверки падают"); } } // 20. Port 53 Conflicts — another DNS daemon on port 53 competes with // dnsmasq and produces intermittent resolution failures. { let dns53_owners = []; for (let line in split(netstat_out, "\n")) { if (index(line, ":53 ") < 0 || index(line, "LISTEN") < 0) continue; let fields = split(trim(line), /[ \t]+/); if (length(fields) < 7) continue; let pid_info = as_string(fields[6]); let owner = index(pid_info, "/") >= 0 ? substr(pid_info, index(pid_info, "/") + 1) : pid_info; if (owner == "" || owner == "dnsmasq" || owner == "sing-box") continue; let already = false; for (let o in dns53_owners) { if (o == owner) { already = true; break; } } if (!already) push(dns53_owners, owner); } if (length(dns53_owners) == 0) { doc_check("✅", "Port 53 conflicts", "none", ""); } else { issues++; doc_check("⚠️", "Port 53 conflicts", "bound by " + join(", ", dns53_owners), "→ конкурирующий DNS-демон на :53 даёт плавающие сбои резолвинга"); } } // 21. Subscription Cache Age — sing-box can route through days-old node // lists while the subscription URL itself is reachable. if (has_sections && cfg.subscription_url) { let newest_mtime = 0; let cache_files = fs.glob(constants.TMP_SUBSCRIPTION_FOLDER + "/*.json") || []; for (let cf in cache_files) { let st = fs.stat(cf); if (st && st.mtime > newest_mtime) newest_mtime = st.mtime; } if (newest_mtime > 0) { let age_days = int((time() - newest_mtime) / 86400); if (age_days <= 2) { doc_check("✅", "Subscription cache", sprintf("fresh (%d d)", age_days), ""); } else { issues++; doc_check("⚠️", "Subscription cache", sprintf("stale (%d days old)", age_days), "→ узлы могли устареть: обновите подписку в LuCI"); } } } push(report, ""); if (issues == 0) { push(report, "✅ Всё в порядке — проблем не обнаружено"); } else if (DOCTOR_REPAIR_MODE) { push(report, sprintf("⚠️ Проблем: %d Исправлено: %d", issues, fixed)); } else { push(report, sprintf("⚠️ Проблем: %d Планируется к исправлению: %d (применить: tachyon doctor --fix)", issues, length(DOCTOR_PLANNED_FIXES))); } return { report: join("\n", report) + "\n", issues, fixed, checks, planned_fixes: DOCTOR_PLANNED_FIXES }; } // Concurrent doctor runs (LuCI button, Telegram bot, cron) would both see the // same broken state and both execute repairs. A mkdir lock serializes them; // a crashed run's lock is stolen after 15 minutes of silence. // Declared after run_doctor_checks_impl(): ucode does not hoist function // declarations, so the wrapper must follow its callee. const DOCTOR_LOCK_DIR = "/var/run/tachyon.doctor.lock"; const DOCTOR_HISTORY_FILE = "/tmp/tachyon_doctor_history.json"; function doctor_lock_try() { command_status("mkdir -p /var/run /tmp/run 2>/dev/null"); if (command_status("mkdir " + shell_quote(DOCTOR_LOCK_DIR) + " 2>/dev/null") == 0) return true; let st = fs.stat(DOCTOR_LOCK_DIR); let age = (st && st.mtime) ? (int(clock()[0]) - st.mtime) : 0; if (age > 900) { command_status("rm -rf " + shell_quote(DOCTOR_LOCK_DIR) + " 2>/dev/null"); return command_status("mkdir " + shell_quote(DOCTOR_LOCK_DIR) + " 2>/dev/null") == 0; } return false; } function doctor_lock_release() { command_status("rm -rf " + shell_quote(DOCTOR_LOCK_DIR) + " 2>/dev/null"); } // Outcome journal: lets the report say "this is the Nth failing run today" // instead of treating every incident as unique. Capped and written // atomically like every other doctor state file. function doctor_history_load() { let raw = read_json_file(DOCTOR_HISTORY_FILE); let data = object_or_empty(raw); if (type(data.entries) != "array") data.entries = []; return data; } function doctor_history_record(result) { let data = doctor_history_load(); let first_fail = ""; for (let c in (result.checks || [])) { if (c.status == "fail") { first_fail = as_string(c.name); break; } } push(data.entries, { ts: time(), issues: int(result.issues || 0), fixed: int(result.fixed || 0), planned: length(result.planned_fixes || []), first_fail: first_fail }); while (length(data.entries) > 50) splice(data.entries, 0, 1); let tmp_path = DOCTOR_HISTORY_FILE + ".tmp." + int(clock()[0]); let f = fs.open(tmp_path, "w"); if (f) { f.write(sprintf("%J\n", data)); f.close(); fs.rename(tmp_path, DOCTOR_HISTORY_FILE); } } function doctor_history_trend(result) { if (int(result.issues || 0) <= 0) return ""; let data = doctor_history_load(); let day_ago = time() - 86400; let recent_failures = 0; for (let e in data.entries) { if (int(e.ts || 0) >= day_ago && int(e.issues || 0) > 0) recent_failures++; } if (recent_failures < 2) return ""; return sprintf("⚠️ Это %d-й диагностики-запуск с проблемами за последние 24 часа — сбой повторяющийся, проверьте историю.", recent_failures); } function run_doctor_checks(repair) { if (!doctor_lock_try()) { return { report: "🩺 *tachyon doctor* — другой экземпляр диагностики уже выполняется, повторите позже.\n", issues: 0, fixed: 0, checks: [], planned_fixes: [], busy: true }; } // On a crash the lock is left behind on purpose: doctor_lock_try() // steals it after 15 minutes of silence. let result = run_doctor_checks_impl(repair); doctor_lock_release(); if (!result.busy) { doctor_history_record(result); let trend = doctor_history_trend(result); if (trend != "") result.report = result.report + trend + "\n"; } return result; } function query_llm(provider, api_key, custom_url, prompt_text, model_override) { provider = lc(trim(as_string(provider))); if (provider == "anthropic" || provider == "claude") { let api_url = "https://api.anthropic.com/v1/messages"; let model = model_override || "claude-3-5-haiku-20241022"; let payload = { model: model, max_tokens: 1024, messages: [ { role: "user", content: prompt_text } ] }; let payload_path = "/tmp/llm_payload.json"; common.write_json_file(payload_path, payload); let curl_args = [ "curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-H", "x-api-key: " + api_key, "-H", "anthropic-version: 2023-06-01", "--connect-timeout", "10", "-m", "60", "-d", "@" + payload_path, api_url ]; let result = command_capture(command_from_args(curl_args)); remove_file(payload_path); if (result.status != 0 || result.output == "") { return null; } let response_data = parse_json_or_null(result.output); if (response_data && type(response_data.content) == "array" && length(response_data.content) > 0) { return response_data.content[0].text; } return null; } let api_url = "https://api.openai.com/v1/chat/completions"; let model = model_override || "gpt-4o-mini"; if (provider == "deepseek") { api_url = "https://api.deepseek.com/chat/completions"; model = model_override || "deepseek-chat"; } else if (provider == "openrouter") { api_url = "https://openrouter.ai/api/v1/chat/completions"; model = model_override || "openai/gpt-4o-mini"; } else if (provider == "ollama") { api_url = custom_url != "" ? custom_url : "http://192.168.1.100:11434/v1/chat/completions"; model = model_override || "llama3:latest"; } else if (provider == "lmstudio") { api_url = custom_url != "" ? custom_url : "http://192.168.1.100:1234/v1/chat/completions"; model = model_override || "local-model"; } else if (provider == "custom" && custom_url != "") { api_url = custom_url; model = model_override || "gpt-4o-mini"; } else { api_url = "https://api.openai.com/v1/chat/completions"; model = model_override || "gpt-4o-mini"; } let payload = { model: model, messages: [ { role: "user", content: prompt_text } ], temperature: 0.3 }; let payload_path = "/tmp/llm_payload.json"; common.write_json_file(payload_path, payload); let curl_args = [ "curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer " + api_key, "--connect-timeout", "10", "-m", "60", "-d", "@" + payload_path, api_url ]; // OpenRouter recommends these headers for identification and rate-limit ranking if (provider == "openrouter") { push(curl_args, "-H"); push(curl_args, "HTTP-Referer: https://github.com/Dushnilin/tachyon"); push(curl_args, "-H"); push(curl_args, "X-Title: Tachyon AI Doctor"); } let result = command_capture(command_from_args(curl_args)); remove_file(payload_path); if (result.status != 0 || result.output == "") { warn(sprintf("LLM query failed: provider=%s url=%s status=%d output_len=%d", provider, api_url, result.status, length(result.output || ""))); return null; } let response_data = parse_json_or_null(result.output); if (response_data && type(response_data.choices) == "array" && length(response_data.choices) > 0) { return response_data.choices[0].message.content; } // Log the raw response for debugging when parsing fails if (!response_data) { warn(sprintf("LLM response parse failed: provider=%s raw_start=%s", provider, substr(trim(result.output), 0, 200))); } else if (!response_data.choices) { warn(sprintf("LLM response missing choices: provider=%s keys=%s", provider, join(",", keys(response_data)))); } return null; } function doctor(format, repair) { let res; try { res = run_doctor_checks(repair == true); } catch (e) { print(sprintf("%J\n", { success: false, issues: 0, fixed: 0, report: sprintf("Doctor failed: %s", as_string(e)) })); return 1; } print(sprintf("%J\n", { success: true, busy: res.busy == true, issues: res.issues, fixed: res.fixed, planned_fixes: res.planned_fixes || [], report: res.report })); return 0; } // ─── AI Agent: structured JSON diagnostics ──────────────────────────────────── // Same checks as run_doctor_checks() but output is machine-readable JSON for // LLM agents. Each problem has: id, severity, description, suggested_fix. function compress_log_snippet(raw_snippet) { if (!raw_snippet || raw_snippet == "") return "No recent system errors logged."; let lines = split(raw_snippet, "\n"); let compressed = []; let prev_line = ""; let repeat_cnt = 1; for (let i = 0; i < length(lines); i++) { let line = trim(as_string(lines[i])); if (line == "") continue; let normalized = replace(line, /^[A-Z][a-z]{2}\s+\d+\s+\d+:\d+:\d+\s+[^\s]+\s+/, ""); normalized = replace(normalized, /^\d{4}-\d{2}-\d{2}\s+\d+:\d+:\d+\s+/, ""); if (normalized == prev_line) { repeat_cnt++; } else { if (prev_line != "") { if (repeat_cnt > 1) { push(compressed, sprintf("%s [repeated %dx]", lines[i-1], repeat_cnt)); } else { push(compressed, lines[i-1]); } } prev_line = normalized; repeat_cnt = 1; } } if (prev_line != "" && length(lines) > 0) { if (repeat_cnt > 1) { push(compressed, sprintf("%s [repeated %dx]", lines[length(lines)-1], repeat_cnt)); } else { push(compressed, lines[length(lines)-1]); } } return join("\n", compressed); } function diagnose_json() { let res = run_doctor_checks(); let problems = []; let checks = res.checks || []; if (length(checks) > 0) { // Structured path: build problems directly from checks[] for (let chk in checks) { if (chk.status == "pass") continue; let severity = (chk.status == "fail") ? "critical" : ((chk.status == "warn") ? "warning" : "info"); let raw_fix = trim(as_string(chk.fix || "")); let fixed_inline = index(raw_fix, "FIXED:") >= 0; if (fixed_inline) severity = "info"; // Strip leading arrow from fix hint let suggested_fix = raw_fix; if (length(suggested_fix) >= 3 && substr(suggested_fix, 0, 3) == "→") { suggested_fix = trim(substr(suggested_fix, 3)); } let description = as_string(chk.name); if (suggested_fix == "" && chk.status != "info") { description = trim(description + " " + as_string(chk.detail)); } push(problems, { check: chk.name, severity: severity, description: description, suggested_fix: suggested_fix, evidence: as_string(chk.detail), fixed: fixed_inline }); } } else { // Legacy fallback for recovery mode (no structured checks) for (let line in split(res.report, "\n")) { line = trim(as_string(line)); if (line == "") continue; let severity = null; let fixed_inline = false; if (index(line, "❌") >= 0) { severity = "critical"; } else if (index(line, "⚠️") >= 0) { severity = "warning"; } else if (index(line, "ℹ️") >= 0) { severity = "info"; } if (severity == null) continue; let clean = replace(replace(replace(replace( line, "❌", ""), "⚠️", ""), "ℹ️", ""), "✅", ""); clean = trim(clean); let arrow_idx = index(clean, "→"); let description = trim(arrow_idx >= 0 ? substr(clean, 0, arrow_idx) : clean); let suggested_fix = arrow_idx >= 0 ? trim(substr(clean, arrow_idx + 3)) : ""; if (index(suggested_fix, "FIXED:") >= 0) { fixed_inline = true; severity = "info"; } push(problems, { severity: severity, description: description, suggested_fix: suggested_fix, fixed: fixed_inline }); } } let ai_status_data = {}; let ai_status_raw = fs.readfile("/tmp/tachyon_ai_status.json"); if (ai_status_raw) { try { ai_status_data = json(ai_status_raw); } catch(e) {} } let raw_log_snippet = trim(command_output("logread | grep -iE 'tachyon|sing-box|dnsmasq|oom|error|fatal|byedpi|zapret|nftables' | tail -n 50 2>/dev/null")) || "No recent system errors logged."; let log_snippet = compress_log_snippet(raw_log_snippet); print(sprintf("%J\n", { success: true, timestamp: time(), issues_found: res.issues, issues_fixed: res.fixed, overall: (res.issues == 0) ? "healthy" : ((res.fixed == res.issues) ? "repaired" : "degraded"), problems: problems, log_snippet: log_snippet, watchdog_status: ai_status_data })); return 0; } // A fix that has been applied several times without changing the picture is // not going to work on the next attempt either — recommending it again is how // the doctor ends up proposing the same repairs forever (issue #31). Every // successful application is recorded, and a code that was applied 3+ times in // the last hour is withheld from the recommendations. const DOCTOR_FIXES_FILE = "/tmp/tachyon_doctor_fixes.json"; function doctor_fix_record(code) { let data = object_or_empty(read_json_file(DOCTOR_FIXES_FILE)); let rec = data[code] || { count: 0, last: 0 }; rec.count = int(rec.count) + 1; rec.last = time(); data[code] = rec; // Atomic tmp+mv write: concurrent doctor instances (LuCI, Telegram, cron) // must not be able to corrupt the tracker mid-write. let tmp_path = DOCTOR_FIXES_FILE + ".tmp." + int(clock()[0]); let f = fs.open(tmp_path, "w"); if (f) { f.write(sprintf("%J\n", data)); f.close(); fs.rename(tmp_path, DOCTOR_FIXES_FILE); } } function doctor_fix_overused(code) { let data = object_or_empty(read_json_file(DOCTOR_FIXES_FILE)); let rec = data[code]; if (!rec) return false; if (time() - int(rec.last) > 3600) return false; return int(rec.count) >= 3; } // ─── End-to-end verification: is everything actually working ────────────────── // The snapshot checks pass even when the network has been silently flapping for // hours: at the moment of the check the WAN is up, so the doctor concludes "all // OK". This routine verifies what the LAN client actually experiences — DNS // through each layer, HTTP through the proxy — and inspects the recent log // history for instability (WAN flaps, service restarts) before anyone is // allowed to declare the system healthy. function verify_system() { let checks = []; let stability = { wan_flaps: 0, singbox_restarts: 0, dnsmasq_restarts: 0, tachyon_restarts: 0 }; function add(name, status, detail, evidence) { push(checks, { name, status, detail, evidence: evidence || "" }); } // Live checks — what a client on the LAN experiences right now. let sb_pid = find_process_pid("sing-box"); add("sing-box process", sb_pid != "" ? "pass" : "fail", sb_pid != "" ? "running (pid " + sb_pid + ")" : "not running"); let lan_dns = dns_check_resolve_host("google.com", "127.0.0.1", 3); if (lan_dns == "") lan_dns = dns_check_resolve_host("cloudflare.com", "127.0.0.1", 3); if (lan_dns == "") lan_dns = dns_check_resolve_host("openwrt.org", "127.0.0.1", 3); add("LAN DNS via dnsmasq", lan_dns != "" ? "pass" : "fail", lan_dns != "" ? "resolved successfully" : "no answer from 127.0.0.1"); let up_dns = dns_check_resolve_host("google.com", "1.1.1.1", 3); if (up_dns == "") up_dns = dns_check_resolve_host("cloudflare.com", "8.8.8.8", 3); if (up_dns == "") up_dns = dns_check_resolve_host("openwrt.org", "77.88.8.8", 3); add("Upstream DNS", up_dns != "" ? "pass" : "fail", up_dns != "" ? "resolved successfully" : "no answer from upstream resolvers"); let sb_dns = dns_check_through_singbox("google.com"); if (!sb_dns) sb_dns = dns_check_through_singbox("cloudflare.com"); add("Proxy DNS via sing-box", sb_dns ? "pass" : (sb_pid != "" ? "fail" : "skip"), sb_dns ? "resolved via " + SB_DNS_INBOUND_ADDRESS : (sb_pid != "" ? "no answer" : "sing-box not running")); // HTTP through the service mixed proxy — only present when download_via_proxy // is enabled; otherwise the tproxy/tun path is covered by the checks above. let mixed_port_num = common.get_mixed_port(); let sb_cfg = fs.readfile("/etc/sing-box/config.json") || ""; let has_mixed = index(sb_cfg, '"mixed"') >= 0; if (has_mixed && sb_pid != "") { let res = command_capture("curl -sS --max-time 8 -x http://127.0.0.1:" + mixed_port_num + " -o /dev/null -w %{http_code} https://www.gstatic.com/generate_204 2>&1"); let code = trim(res.output); add("HTTP via proxy", res.status == 0 && (code == "204" || code == "200") ? "pass" : "fail", code == "204" || code == "200" ? "end-to-end OK through sing-box" : "HTTP " + code); } else { add("HTTP via proxy", "skip", "service mixed proxy not in config"); } add("WAN interface", wan_has_ip() ? "pass" : "fail", wan_has_ip() ? get_wan_interface() + " up" : "no IP"); add("Default gateway", default_gateway_exists() ? "pass" : "fail", default_gateway_exists() ? "present" : "missing"); // History — inspect recent system log tail without false triggers from normal upgrades/reloads let hist = command_capture("logread 2>/dev/null | tail -n 250").output; for (let line in split(hist, "\n")) { let l = lc(line); // Skip planned / normal upgrade and service operations if (index(l, "successful component change") >= 0 || index(l, "upgrading") >= 0 || index(l, "post-upgrade") >= 0 || index(l, "component_action") >= 0 || index(l, "component-action") >= 0 || index(l, "doctor_fix") >= 0) continue; if (index(l, "udhcpc") >= 0 && (index(l, "lease lost") >= 0 || index(l, "deconfig") >= 0)) stability.wan_flaps++; if (index(l, "wan.down") >= 0 || index(l, "watchdog: wan check failed") >= 0) stability.wan_flaps++; if (index(l, "sing-box") >= 0 && (index(l, "panic:") >= 0 || index(l, "fatal error:") >= 0 || index(l, "sigsegv") >= 0 || index(l, "died unexpectedly") >= 0)) stability.singbox_restarts++; if (index(l, "dnsmasq") >= 0 && (index(l, "failed to create listening socket") >= 0 || index(l, "address already in use") >= 0 || index(l, "failed to start") >= 0)) stability.dnsmasq_restarts++; if (index(l, "tachyon") >= 0 && index(l, "sing-box") < 0 && index(l, "watchdog: crashed") >= 0) stability.tachyon_restarts++; } let failed = 0; for (let c in checks) { if (c.status == "fail") failed++; } return { checks, stability, failed }; } function apply_quick_fix(codes_str) { if (!codes_str || codes_str == "") { print(sprintf("%J\n", { success: false, error: "No fix code provided" })); return 1; } let codes = split(replace(codes_str, /[ \[\]"]/g, ""), ","); let results = []; let all_ok = true; for (let c in codes) { c = trim(c); if (c == "") continue; let status = false; let msg = ""; if (c == "start_singbox") { let rc = command_status("/usr/bin/tachyon restore_dnsmasq 2>/dev/null; /etc/init.d/sing-box restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "sing-box restarted" : "sing-box restart failed (exit " + rc + ")"; } else if (c == "rebuild_rules") { let rc = command_status("/etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Firewall rules rebuilt" : "Firewall reload failed (exit " + rc + ")"; } else if (c == "fix_dnsmasq") { let rc = command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "dnsmasq restarted" : "dnsmasq restart failed (exit " + rc + ")"; } else if (c == "fix_resolv_symlink") { let rc = command_status("ln -sf /tmp/resolv.conf.auto /etc/resolv.conf 2>/dev/null || ln -sf /tmp/resolv.conf.d/resolv.conf.auto /etc/resolv.conf 2>/dev/null"); status = (rc == 0); msg = status ? "resolv.conf symlink fixed" : "resolv.conf symlink fix failed (exit " + rc + ")"; } else if (c == "start_watchdog") { let rc = command_status("/etc/init.d/tachyon restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Watchdog started" : "Watchdog start failed (exit " + rc + ")"; } else if (c == "restart_singbox_dns") { let rc = command_status("/etc/init.d/sing-box restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "sing-box DNS restarted" : "sing-box DNS restart failed (exit " + rc + ")"; } else if (c == "fix_uci_config") { status = uci_backup_restore(); if (!status) { status = command_status("cp /etc/config/tachyon.bak /etc/config/tachyon 2>/dev/null") == 0; } msg = status ? "UCI config restored from backup" : "UCI config restore failed (no valid backup found)"; } else if (c == "fix_wan_interface") { let rc = command_status("ubus call network.interface.wan up 2>/dev/null; ifup wan 2>/dev/null; ip route flush cache 2>/dev/null"); status = (rc == 0); msg = status ? "WAN interface re-up triggered" : "WAN interface re-up failed (exit " + rc + ")"; } else if (c == "fix_gateway") { let rc = command_status("/etc/init.d/network restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Network restarted to resolve gateway" : "Network restart failed (exit " + rc + ")"; } else if (c == "clear_dns_cache") { let rc = command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1; ip route flush cache 2>/dev/null"); status = (rc == 0); msg = status ? "DNS cache cleared and dnsmasq restarted" : "DNS cache clear failed (exit " + rc + ")"; } else if (c == "update_subscriptions") { let rc = command_status(command_from_args([ "/usr/bin/tachyon", "component_action_async", "update_subscriptions", "update" ])); status = (rc == 0); msg = status ? "Subscription update triggered" : "Subscription update trigger failed (exit " + rc + ")"; } else if (c == "reset_firewall") { let rc = command_status("/etc/init.d/firewall restart >/dev/null 2>&1; /etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Firewall restarted" : "Firewall restart failed (exit " + rc + ")"; } else if (c == "restart_network") { let rc = command_status("/etc/init.d/network restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Network service restarted" : "Network restart failed (exit " + rc + ")"; } else if (c == "restart_zapret") { let rc = command_status("/etc/init.d/zapret restart 2>/dev/null; /etc/init.d/zapret2 restart 2>/dev/null; /etc/init.d/byedpi restart 2>/dev/null"); status = (rc == 0); msg = status ? "Zapret/ByeDPI engines restarted" : "Zapret/ByeDPI restart failed (exit " + rc + ")"; } else if (c == "optimize_memory") { let rc = command_status("sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null; rm -rf /tmp/sing-box/*.tmp 2>/dev/null; /etc/init.d/sing-box restart >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Memory caches flushed and sing-box restarted" : "Memory optimization failed (exit " + rc + ")"; } else if (c == "switch_to_doh") { let rc = command_status("uci set tachyon.settings.dns_type='doh'; uci commit tachyon; /etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Switched DNS mode to DoH and reloaded firewall" : "DoH switch failed (exit " + rc + ")"; } else if (c == "heal_network_stack") { let rc = command_status("ln -sf /tmp/resolv.conf /etc/resolv.conf 2>/dev/null; /etc/init.d/dnsmasq restart >/dev/null 2>&1; /etc/init.d/tachyon restart >/dev/null 2>&1; ip route flush cache 2>/dev/null; ubus call network.interface.wan up 2>/dev/null; ifup wan 2>/dev/null"); status = (rc == 0); msg = status ? "Full network stack recovery executed" : "Network stack recovery failed (exit " + rc + ")"; } else if (c == "enable_safe_bypass") { let rc = command_status("uci set tachyon.settings.recovery_bypass='1'; uci commit tachyon; /etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Safe Direct WAN bypass enabled" : "Safe bypass enable failed (exit " + rc + ")"; } else if (c == "restore_native_internet") { let rc = command_status("/usr/bin/tachyon restore_dnsmasq 2>/dev/null; /etc/init.d/tachyon stop >/dev/null 2>&1; nft delete table inet TachyonTable 2>/dev/null; ip -4 rule del fwmark 0x04000000/0x04000000 table tachyon 2>/dev/null; ip -6 rule del fwmark 0x04000000/0x04000000 table tachyon 2>/dev/null; ip route flush table tachyon 2>/dev/null; /etc/init.d/dnsmasq restart >/dev/null 2>&1; ip route flush cache 2>/dev/null"); status = (rc == 0); msg = status ? "Tachyon stopped, stock dnsmasq and native direct internet routing restored" : "Native internet restore failed (exit " + rc + ")"; } else if (c == "fix_system_time") { let rc = command_status("ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 162.159.200.1 2>/dev/null || ntpd -q -p pool.ntp.org 2>/dev/null || rdate -s time.cloudflare.com 2>/dev/null; /etc/init.d/sysntpd restart 2>/dev/null"); status = (rc == 0); msg = status ? "System time synchronized with NTP" : "NTP sync failed (exit " + rc + ")"; } else if (c == "flush_conntrack") { let rc = command_status("sysctl -w net.netfilter.nf_conntrack_max=65536 2>/dev/null; echo 1 > /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || true; conntrack -F 2>/dev/null || true"); status = (rc == 0); msg = status ? "Conntrack table limits expanded and flushed" : "Conntrack flush failed (exit " + rc + ")"; } else if (c == "fix_bootstrap_dns") { let rc = command_status("uci set tachyon.settings.bootstrap_dns_server='77.88.8.8'; uci commit tachyon; /etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "Bootstrap DNS reset to reliable public resolver (77.88.8.8) and reloaded" : "Bootstrap DNS fix failed (exit " + rc + ")"; } else if (c == "optimize_mtu") { let rc = command_status("/usr/bin/tachyon discover-awg-mtu 2>/dev/null; /etc/init.d/tachyon reload >/dev/null 2>&1"); status = (rc == 0); msg = status ? "AWG MTU discovery executed and tunnels reloaded" : "MTU optimization failed (exit " + rc + ")"; } else { status = false; msg = "Unknown fix code: " + c; all_ok = false; } push(results, { code: c, success: status, message: msg }); doctor_fix_record(c); } let post_verify = verify_system(); print(sprintf("%J\n", { success: all_ok, verified: post_verify.failed == 0, remaining_failures: post_verify.failed, results: results })); return all_ok ? 0 : 1; } function ai_doctor_last() { let raw = fs.readfile("/tmp/ai_doctor_last.json"); if (!raw) { print(sprintf("%J\n", { success: false, error: "No previous AI Doctor report found" })); return 0; } let data = parse_json_or_null(raw); if (!data) { print(sprintf("%J\n", { success: false, error: "Corrupted AI Doctor history file" })); return 0; } print(sprintf("%J\n", data)); return 0; } const DOCTOR_FIX_PRIORITIES = { "fix_system_time": 5, "fix_wan_interface": 10, "restart_network": 15, "fix_gateway": 20, "fix_resolv_symlink": 30, "fix_dnsmasq": 35, "fix_bootstrap_dns": 38, "switch_to_doh": 40, "clear_dns_cache": 45, "start_singbox": 50, "restart_zapret": 60, "optimize_mtu": 65, "rebuild_rules": 70, "update_subscriptions": 80, "flush_conntrack": 85, "optimize_memory": 90, "enable_safe_bypass": 95, "heal_network_stack": 100, "restore_native_internet": 110 }; function doctor_fix_sort(a, b) { let pa = DOCTOR_FIX_PRIORITIES[a] || 55; let pb = DOCTOR_FIX_PRIORITIES[b] || 55; return pa < pb ? -1 : (pa > pb ? 1 : 0); } function prioritize_quick_fixes(fixes) { if (!fixes || length(fixes) <= 1) return fixes; sort(fixes, doctor_fix_sort); return fixes; } function diagnose_dpi_and_censorship(cfg, lang, causes, fn_add_fix) { if (!wan_has_ip()) return; let local_ip = dns_check_resolve_host("rutracker.org", "127.0.0.1", 2); let upstream_ip = dns_check_resolve_host("rutracker.org", "77.88.8.8", 2); if (upstream_ip == "") upstream_ip = dns_check_resolve_host("rutracker.org", "1.1.1.1", 2); if (local_ip != "" && (local_ip == "127.0.0.1" || local_ip == "0.0.0.0" || index(local_ip, "192.168.") == 0 || index(local_ip, "10.") == 0)) { push(causes, { probability: 92, cause: lang == "en" ? "DNS spoofing/poisoning detected (local resolver returns bogus or blockpage IP)" : "Обнаружена подмена DNS (DNS Spoofing): локальный резолвер возвращает адрес-заглушку", fix: "switch_to_doh" }); fn_add_fix("switch_to_doh"); } let zap_mode = trim(as_string(cfg.zapret_mode || "disabled")); let bd_mode = trim(as_string(cfg.byedpi_mode || "disabled")); if (zap_mode == "disabled" && bd_mode == "disabled" && upstream_ip != "") { let tls_check = command_capture("curl -k -sS --connect-timeout 2 -m 3 https://rutracker.org -o /dev/null -w %{http_code} 2>&1"); let tls_out = tls_check.output || ""; if (tls_check.status != 0 && (index(tls_out, "Connection reset") >= 0 || index(tls_out, "SSL_ERROR") >= 0 || index(tls_out, "OpenSSL SSL_connect") >= 0)) { push(causes, { probability: 88, cause: lang == "en" ? "ISP DPI/TSPU is blocking TLS ClientHello (Connection Reset / SNI filtering)" : "Обнаружена блокировка TLS ClientHello со стороны ТСПУ/DPI провайдера (сброс соединения по SNI)", fix: "restart_zapret" }); fn_add_fix("restart_zapret"); } } } function diagnose_proxies_health(cfg, lang, causes, fn_add_fix) { let clash_addr = clash_api_url(); let curl_res = command_capture("curl -s --max-time 2 http://" + clash_addr + "/proxies 2>/dev/null"); if (curl_res.status == 0 && curl_res.output != "") { let p_obj = parse_json_or_null(curl_res.output); if (p_obj && p_obj.proxies) { let total_nodes = 0; let dead_nodes = 0; for (let name in p_obj.proxies) { let p = p_obj.proxies[name]; if (!p) continue; let p_type = lc(as_string(p.type || "")); if (p_type == "selector" || p_type == "urltest" || p_type == "direct" || p_type == "reject" || p_type == "compatible") continue; total_nodes++; let history = type(p.history) == "array" ? p.history : []; if (length(history) > 0) { let last = history[length(history) - 1]; if (last && int(last.delay || 0) == 0) dead_nodes++; } else if (p.alive == false) { dead_nodes++; } } if (total_nodes > 0 && dead_nodes == total_nodes) { push(causes, { probability: 94, cause: lang == "en" ? sprintf("All outbound proxy servers are unreachable (100%% timeout on %d nodes; check subscription balance or protocol block)", total_nodes) : sprintf("Все прокси-серверы недоступны (100%% таймаут на %d узлах; проверьте баланс подписки или блокировку протокола)", total_nodes), fix: "update_subscriptions" }); fn_add_fix("update_subscriptions"); } } } let sub_files = fs.glob("/tmp/tachyon_subscription_cache/*.error"); if (sub_files) { for (let sub_file in sub_files) { let err_text = trim(fs.readfile(sub_file) || ""); if (index(err_text, "401") >= 0 || index(err_text, "403") >= 0) { push(causes, { probability: 96, cause: lang == "en" ? "Subscription update rejected by server (HTTP 401/403: expired token or unpaid account)" : "Обновление подписки отклонено сервером (HTTP 401/403: подписка истекла или не оплачена)", fix: "update_subscriptions" }); fn_add_fix("update_subscriptions"); break; } } } } function diagnose_system_conflicts(cfg, lang, causes, fn_add_fix) { let competing_dns = [ "adguardhome", "smartdns", "stubby", "unbound", "nextdns" ]; for (let svc in competing_dns) { let pid = find_process_pid(svc); if (pid != "") { push(causes, { probability: 82, cause: lang == "en" ? sprintf("Conflicting DNS service running: %s (PID %s) may interfere with Tachyon DNS routing", svc, pid) : sprintf("Обнаружен сторонний DNS-сервис: %s (PID %s), возможен конфликт перехвата DNS-трафика", svc, pid), fix: "fix_dnsmasq" }); fn_add_fix("fix_dnsmasq"); break; } } let competing_pkgs = [ "passwall", "openclash", "vssr", "shadowsocksr" ]; for (let pkg in competing_pkgs) { let pid = find_process_pid(pkg); if (pid != "") { push(causes, { probability: 88, cause: lang == "en" ? sprintf("Conflicting proxy framework running: %s (PID %s) causes routing/nftables rule clashes", pkg, pid) : sprintf("Обнаружен конфликтующий пакет обхода: %s (PID %s), вызывающий конфликт правил файрвола", pkg, pid), fix: "rebuild_rules" }); fn_add_fix("rebuild_rules"); break; } } let user_domains_text = cfg.user_domains_text || ""; if (user_domains_text != "") { for (let line in split(user_domains_text, "\n")) { line = trim(line); if (line == "" || index(line, "#") == 0) continue; if (index(line, "http://") >= 0 || index(line, "https://") >= 0 || index(line, " ") >= 0) { push(causes, { probability: 78, cause: lang == "en" ? sprintf("Syntax error in custom domain list: '%s' contains URL scheme or space (should be domain only)", line) : sprintf("Синтаксическая ошибка в списке доменов: '%s' содержит протокол или пробелы (требуется только домен)", line), fix: "rebuild_rules" }); fn_add_fix("rebuild_rules"); break; } } } let rs_files = fs.glob("/tmp/sing-box/rulesets/*.srs"); if (rs_files) { for (let rs_file in rs_files) { let st = fs.stat(rs_file); if (st && st.size == 0) { push(causes, { probability: 86, cause: lang == "en" ? sprintf("Corrupted binary ruleset file: %s is 0 bytes", rs_file) : sprintf("Повреждён бинарный файл правил: %s имеет размер 0 байт", rs_file), fix: "rebuild_rules" }); fn_add_fix("rebuild_rules"); break; } } } } function diagnose_system_clock_and_conntrack(cfg, lang, causes, fn_add_fix) { let current_t = time(); if (current_t < 1735689600) { push(causes, { probability: 98, cause: lang == "en" ? "System clock is not synchronized (year < 2025 causes TLS/x509 certificate validation failures)" : "Системное время роутера не синхронизировано (дата в прошлом ломает валидацию TLS/SSL сертификатов узлов)", fix: "fix_system_time" }); fn_add_fix("fix_system_time"); } let count_raw = trim(fs.readfile("/proc/sys/net/netfilter/nf_conntrack_count") || ""); let max_raw = trim(fs.readfile("/proc/sys/net/netfilter/nf_conntrack_max") || ""); if (count_raw != "" && max_raw != "") { let ct_count = int(count_raw); let ct_max = int(max_raw); if (ct_max > 0 && (ct_count * 100 / ct_max) >= 88) { push(causes, { probability: 89, cause: lang == "en" ? sprintf("NAT/Firewall connection tracking table nearly full (%d/%d sessions)", ct_count, ct_max) : sprintf("Таблица отслеживания соединений файрвола (conntrack) почти переполнена (%d/%d сессий)", ct_count, ct_max), fix: "flush_conntrack" }); fn_add_fix("flush_conntrack"); } } } function diagnose_dns_deadlock_and_mtu(cfg, lang, causes, fn_add_fix) { let raw_sb_log = trim(command_output("logread | grep -iE 'lookup.*no such host|lookup.*timeout|dns: lookup failed' | tail -n 10 2>/dev/null")); if (raw_sb_log != "" && index(raw_sb_log, "lookup") >= 0) { let b_dns = cfg.bootstrap_dns_server || ""; if (b_dns == "127.0.0.1" || b_dns == "127.0.0.42") { push(causes, { probability: 93, cause: lang == "en" ? "DNS Deadlock: Bootstrap DNS resolver points to local loopback (resolver loop)" : "DNS Deadlock: Bootstrap-DNS для sing-box указывает на локальный адрес (зацикливание)", fix: "fix_bootstrap_dns" }); fn_add_fix("fix_bootstrap_dns"); } } } function local_rule_doctor(pre_res, pre_verify) { let cfg = uci_settings(); let lang = lc(trim(cfg.ai_doctor_lang || "ru")); // Accepts precomputed doctor/verify results so callers that already ran // the full suite (ai_doctor) do not execute every probe twice. let res = (pre_res != null) ? pre_res : run_doctor_checks(); let checks = res.checks || []; let verify = (pre_verify != null) ? pre_verify : verify_system(); let causes = []; let quick_fixes = []; let fix_set = {}; function add_fix(code) { if (doctor_fix_overused(code)) return; if (!fix_set[code]) { fix_set[code] = true; push(quick_fixes, code); } } // ── 1. End-to-end live verification ── let wan_cause_added = false; for (let c in verify.checks) { if (c.status != "fail") continue; if (c.name == "sing-box process") { push(causes, { probability: 95, cause: lang == "en" ? "sing-box process is stopped or non-functional" : "Процесс sing-box остановлен или не функционирует", fix: "start_singbox" }); add_fix("start_singbox"); } else if (c.name == "LAN DNS via dnsmasq") { push(causes, { probability: 85, cause: lang == "en" ? "LAN DNS resolution failed (dnsmasq)" : "Сбой разрешения DNS на уровне локального dnsmasq", fix: "fix_dnsmasq" }); add_fix("fix_dnsmasq"); } else if (c.name == "Proxy DNS via sing-box") { push(causes, { probability: 85, cause: lang == "en" ? "Proxy DNS via sing-box failed to respond" : "Прокси-DNS через sing-box (127.0.0.42) не отвечает", fix: "clear_dns_cache" }); add_fix("clear_dns_cache"); if (cfg.dns_type != "doh") { add_fix("switch_to_doh"); } } else if ((c.name == "WAN interface" || c.name == "Default gateway") && !wan_cause_added) { wan_cause_added = true; push(causes, { probability: 90, cause: lang == "en" ? "WAN default gateway or Internet uplink is unreachable" : "Шлюз по умолчанию или внешний интернет недоступен", fix: "fix_wan_interface" }); add_fix("fix_wan_interface"); } else if (c.name == "HTTP via proxy") { push(causes, { probability: 88, cause: lang == "en" ? "HTTP through the proxy fails end-to-end (node offline or blocked)" : "HTTP через прокси не проходит (узел офлайн или заблокирован)", fix: "update_subscriptions" }); add_fix("update_subscriptions"); add_fix("restart_zapret"); } } // ── 2. Memory and Out-of-Memory pressure ── let raw_log = trim(command_output("logread | grep -iE 'oom-killer|out of memory|killed process' | tail -n 20 2>/dev/null")); if (index(raw_log, "Out of memory") >= 0 || index(raw_log, "oom-killer") >= 0 || index(raw_log, "Killed process") >= 0) { push(causes, { probability: 92, cause: lang == "en" ? "RAM pressure (Out-Of-Memory kill) detected in recent log" : "Обнаружена нехватка оперативной памяти (OOM Kill в журнале)", fix: "optimize_memory" }); add_fix("optimize_memory"); } let meminfo = fs.readfile("/proc/meminfo") || ""; let mem_avail_line = match(meminfo, /MemAvailable:\s+(\d+)\s+kB/); if (mem_avail_line) { let mem_avail_mb = int(mem_avail_line[1]) / 1024; if (mem_avail_mb < 20) { push(causes, { probability: 85, cause: lang == "en" ? sprintf("Critical RAM pressure (Available: %d MB)", mem_avail_mb) : sprintf("Критический дефицит оперативной памяти (Свободно: %d MB)", mem_avail_mb), fix: "optimize_memory" }); add_fix("optimize_memory"); } } // ── 3. Snapshot check failures not covered above ── for (let c in checks) { if (c.status != "fail") continue; let known = false; for (let v in verify.checks) { if (v.name == c.name) { known = true; break; } } if (known) continue; if (index(c.name, "nftables") >= 0 || index(c.name, "ip rule") >= 0 || index(c.name, "MSS") >= 0) { push(causes, { probability: 80, cause: lang == "en" ? "NFTables routing or firewall rules compromised" : "Нарушены правила файрвола nftables или маршрутизация", fix: "rebuild_rules" }); add_fix("rebuild_rules"); } else if (index(c.name, "resolv") >= 0) { push(causes, { probability: 80, cause: lang == "en" ? "DNS resolv.conf configuration broken" : "Конфигурация /etc/resolv.conf повреждена", fix: "fix_resolv_symlink" }); add_fix("fix_resolv_symlink"); } } // ── 4. DPI circumvention check (Zapret / ByeDPI) ── let zapret_mode = trim(as_string(cfg.zapret_mode || "disabled")); let byedpi_mode = trim(as_string(cfg.byedpi_mode || "disabled")); if (zapret_mode != "disabled" && zapret_mode != "") { let zap_pid = find_process_pid("nfqws"); if (zap_pid == "") zap_pid = find_process_pid("nfqws2"); if (zap_pid == "") { push(causes, { probability: 84, cause: lang == "en" ? "Zapret (nfqws) engine is stopped" : "Служба обхода DPI (Zapret/nfqws) остановлена", fix: "restart_zapret" }); add_fix("restart_zapret"); } } if (byedpi_mode != "disabled" && byedpi_mode != "") { let bd_pid = find_process_pid("ciadpi"); if (bd_pid == "") { push(causes, { probability: 84, cause: lang == "en" ? "ByeDPI (ciadpi) engine is stopped" : "Служба обхода DPI (ByeDPI/ciadpi) остановлена", fix: "restart_zapret" }); add_fix("restart_zapret"); } } // ── 5. Advanced DPI & Censorship Checks ── try { diagnose_dpi_and_censorship(cfg, lang, causes, add_fix); } catch(e) { warn("DPI diag error: " + as_string(e) + "\n"); } // ── 6. Proxy Nodes & Subscriptions Health ── try { diagnose_proxies_health(cfg, lang, causes, add_fix); } catch(e) { warn("Proxy diag error: " + as_string(e) + "\n"); } // ── 7. OpenWrt Service & Port Conflicts ── try { diagnose_system_conflicts(cfg, lang, causes, add_fix); } catch(e) { warn("Conflict diag error: " + as_string(e) + "\n"); } // ── 8. System Clock & Connection Tracking ── try { diagnose_system_clock_and_conntrack(cfg, lang, causes, add_fix); } catch(e) { warn("Clock/Conntrack diag error: " + as_string(e) + "\n"); } // ── 9. DNS Deadlock & MTU ── try { diagnose_dns_deadlock_and_mtu(cfg, lang, causes, add_fix); } catch(e) { warn("DNS Deadlock/MTU diag error: " + as_string(e) + "\n"); } // If multiple critical failures, provide emergency native internet restoration if (verify.failed >= 2) { add_fix("restore_native_internet"); } // Sort fixes by priority order try { quick_fixes = prioritize_quick_fixes(quick_fixes); } catch(e) { warn("Prioritize error: " + as_string(e) + "\n"); } let report_lines = []; let is_fully_healthy = verify.failed == 0 && length(causes) == 0; if (lang == "en") { push(report_lines, "### Tachyon Local AI Doctor Analysis"); if (is_fully_healthy) { push(report_lines, "✓ Verified end-to-end: network stack is fully operational."); push(report_lines, "- WAN interface is up and has an active default route."); push(report_lines, "- DNS resolution is healthy (LAN dnsmasq, upstream, proxy resolver)."); push(report_lines, "- sing-box and nftables firewall redirection rules are active."); push(report_lines, "- No active DPI drops or RAM pressure detected."); if (verify.stability.wan_flaps > 0 || verify.stability.singbox_restarts > 0 || verify.stability.dnsmasq_restarts > 0) { push(report_lines, "\nℹ️ Notice: Recent logs show historical component recovery; current live state is stable."); } } else { push(report_lines, "#### Root Cause Analysis:"); for (let c in causes) { push(report_lines, sprintf("- [%d%% Probability] %s", c.probability, c.cause)); } } } else { push(report_lines, "### Анализ Tachyon Local AI Doctor"); if (is_fully_healthy) { push(report_lines, "✓ Проверено вживую: сетевой стек работает штатно."); push(report_lines, "- WAN интерфейс активен и имеет рабочий маршрут по умолчанию."); push(report_lines, "- DNS-резолверы отвечают корректно (LAN dnsmasq, upstream, прокси-резолвер)."); push(report_lines, "- sing-box и правила файрвола nftables функционируют штатно."); push(report_lines, "- Блокировок DPI и нехватки оперативной памяти не обнаружено."); if (verify.stability.wan_flaps > 0 || verify.stability.singbox_restarts > 0 || verify.stability.dnsmasq_restarts > 0) { push(report_lines, "\nℹ️ Справка: В журнале зафиксировано восстановление компонентов после перезапуска, сейчас всё стабильно."); } } else { push(report_lines, "#### Анализ возможных причин сбоя:"); for (let c in causes) { push(report_lines, sprintf("- [%d%% вероятность] %s", c.probability, c.cause)); } } } let nodes = [ { name: "WAN", status: "OK" }, { name: "DNS", status: "OK" }, { name: "sing-box", status: "OK" }, { name: "nftables", status: "OK" } ]; for (let c in verify.checks) { if (c.status == "fail") { if (c.name == "WAN interface" || c.name == "Default gateway") { nodes[0].status = "FAIL"; } else if (index(c.name, "DNS") >= 0) { nodes[1].status = "FAIL"; } else if (index(c.name, "sing-box") >= 0) { nodes[2].status = "FAIL"; } } } for (let c in checks) { if (c.status == "fail") { if (index(c.name, "nftables") >= 0 || index(c.name, "ip rule") >= 0) { nodes[3].status = "WARN"; } } } let quick_fix = length(quick_fixes) > 0 ? join(",", quick_fixes) : ""; let doctor_res = { success: true, timestamp: time(), report: join("\n", report_lines), nodes: nodes, quick_fix: quick_fix, quick_fixes: quick_fixes, provider: "local_heuristic", model: "rule_engine_v2" }; let f = fs.open("/tmp/ai_doctor_last.json", "w"); if (f) { f.write(sprintf("%J\n", doctor_res)); f.close(); } return doctor_res; } function ai_doctor(user_query) { let cfg = uci_settings(); // Run the diagnostic suite exactly once: the same results feed both the // rule-based diagnosis and the report/verification assembly below. let res = run_doctor_checks(); let verify = verify_system(); let local_res = local_rule_doctor(res, verify); let prov = lc(trim(as_string(cfg.ai_doctor_provider || "openai"))); let has_key = (cfg.ai_doctor_api_key && cfg.ai_doctor_api_key != ""); let is_local_or_custom = (prov == "ollama" || prov == "lmstudio" || (prov == "custom" && cfg.ai_doctor_custom_url != "")); if (cfg.enable_ai_doctor != "1" || (!has_key && !is_local_or_custom)) { print(sprintf("%J\n", local_res)); return 0; } let report = res.report; let dns_type = cfg.dns_type || "doh"; let version = trim(command_output("cat /etc/tachyon/version 2>/dev/null")) || "unknown"; let singbox_running = trim(command_output("pgrep -x sing-box 2>/dev/null")) != ""; let uptime_out = trim(command_output("cat /proc/uptime 2>/dev/null")); let uptime_min = uptime_out != "" ? int(split(uptime_out, ".")[0]) / 60 : 0; let watchdog_status_raw = fs.readfile("/tmp/tachyon_ai_status.json"); let watchdog_info = "Watchdog Status: unavailable"; if (watchdog_status_raw) { let wd_json = parse_json_or_null(watchdog_status_raw); if (wd_json) { watchdog_info = sprintf( "Watchdog Status:\n- Last OOM: %s\n- WAN fail streak: %d\n- Proxy fail streak: %d\n- DNS fail streak: %d\n- Active repairs: %s", as_string(wd_json.last_oom_time || "none"), int(wd_json.wan_fail_streak || 0), int(wd_json.proxy_fail_streak || 0), int(wd_json.dns_fail_streak || 0), as_string(wd_json.active_repairs || "none") ); } } let raw_log_snippet = trim(command_output("logread | grep -iE 'tachyon|sing-box|dnsmasq|oom|error|fatal' | tail -n 25 2>/dev/null")) || "No recent system errors logged."; let log_snippet = compress_log_snippet(raw_log_snippet); // Live end-to-end verification — the part that catches "all OK on paper, // broken in practice": DNS through each layer, HTTP through the proxy, and // the recent flap/restart history a snapshot cannot see. let verify_lines = [ "Live Verification:" ]; for (let c in verify.checks) { push(verify_lines, sprintf("- %s: %s (%s)", c.name, c.status, c.detail)); } push(verify_lines, sprintf("Stability (recent log): WAN flaps: %d, sing-box restarts: %d, dnsmasq restarts: %d, tachyon reloads: %d", verify.stability.wan_flaps, verify.stability.singbox_restarts, verify.stability.dnsmasq_restarts, verify.stability.tachyon_restarts)); let verify_info = join("\n", verify_lines); let sys_context = sprintf( "Tachyon Doctor Report:\n%s\n\n" + "Local Rule Diagnosis:\n%s\n\n" + "System Parameters:\n" + "Version: %s\n" + "DNS type: %s\n" + "sing-box running: %s\n" + "Uptime: %d minutes\n\n" + "%s\n\n" + "%s\n\n" + "Recent Critical System Logs:\n%s\n", report, local_res.report, version, dns_type, singbox_running ? "yes" : "no", uptime_min, watchdog_info, verify_info, log_snippet ); // Declared before first use: the RAG call below needs the configured // chat/embedding model override, which used to be read only further down. let model_override = trim(cfg.ai_doctor_model || ""); let rag_context = ""; if (cfg.enable_rag == "1") { let rag_query = user_query || substr(sys_context, 0, 500); rag_context = rag.retrieve(rag_query, prov, cfg.ai_doctor_api_key || "", cfg.ai_doctor_custom_url || "", model_override); } if (rag_context != "") sys_context += "\n\nRelevant Documentation:\n" + rag_context; let lang = lc(trim(cfg.ai_doctor_lang || "ru")); let prompt = ""; if (lang == "en") { prompt = sprintf( "You are \"Tachyon AI Doctor\", an AI assistant for anti-censorship and proxy services on OpenWrt.\n" + "Analyze the diagnostic report and system logs below.\n\n%s\n\n" + "Formulate a concise diagnosis (in English, max 3-4 bullet points).\n" + "If automatic quick fix is possible, include at the very end of your response:\n" + "FIX: code1, code2\n\n" + "IMPORTANT: do NOT report \"everything is fine\" when the Live Verification or Stability section shows any FAIL check, WAN flaps, or repeated service restarts — those are real problems even if the point-in-time checks passed.\n\n" + "Available quick fix codes:\n" + "- start_singbox (sing-box process is stopped or missing)\n" + "- rebuild_rules (nftables or ip rules damaged)\n" + "- fix_dnsmasq (dnsmasq service not responding)\n" + "- fix_resolv_symlink (resolv.conf symlink broken)\n" + "- start_watchdog (watchdog daemon stopped)\n" + "- restart_singbox_dns (sing-box DNS failed)\n" + "- fix_uci_config (Tachyon UCI config corrupted)\n" + "- fix_wan_interface (WAN interface down)\n" + "- fix_gateway (default gateway missing)\n" + "- clear_dns_cache (clear DNS cache & restart dnsmasq)\n" + "- update_subscriptions (force update proxy subscriptions)\n" + "- reset_firewall (restart router firewall)\n" + "- restart_network (restart network service)\n" + "- restart_zapret (restart Zapret/ByeDPI engines)\n" + "- optimize_memory (flush memory caches)\n" + "- switch_to_doh (switch DNS interception to DoH)\n\n" + "If no quick fix applies, do NOT output any FIX tag.", sys_context); } else { prompt = sprintf( "Вы — ИИ-ассистент \"Tachyon AI Doctor\" для сервиса обхода блокировок на OpenWrt.\n" + "Проанализируйте диагностический отчет и логи ниже.\n\n%s\n\n" + "Сформулируйте краткий диагноз (на русском, максимум 3-4 пункта).\n" + "Если авто-исправление возможно, укажите в самом конце ответа строчку:\n" + "FIX: код1, код2\n\n" + "ВАЖНО: не пишите «всё в порядке», если в секции Live Verification или Stability есть хоть один провал, флапы WAN или повторные перезапуски сервисов — это реальные проблемы, даже если мгновенные проверки прошли.\n\n" + "Доступные коды быстрого исправления:\n" + "- start_singbox (sing-box упал или остановлен)\n" + "- rebuild_rules (nftables или ip rule правила нарушены)\n" + "- fix_dnsmasq (конфиг или сервис dnsmasq не отвечает)\n" + "- fix_resolv_symlink (resolv.conf повреждён)\n" + "- start_watchdog (watchdog остановлен)\n" + "- restart_singbox_dns (sing-box DNS не отвечает)\n" + "- fix_uci_config (конфиг Tachyon повреждён)\n" + "- fix_wan_interface (WAN интерфейс не работает)\n" + "- fix_gateway (шлюз отсутствует)\n" + "- clear_dns_cache (очистить кэш DNS и перезапустить dnsmasq)\n" + "- update_subscriptions (принудительно обновить прокси подписки)\n" + "- reset_firewall (перезапустить файрвол роутера)\n" + "- restart_network (перезапустить сетевой стек)\n" + "- restart_zapret (перезапустить службы Zapret/ByeDPI)\n" + "- optimize_memory (очистить оперативно память)\n" + "- switch_to_doh (переключить DNS на DoH)\n\n" + "Если авто-исправление не применимо, не пишите тег FIX.", sys_context); } user_query = trim(as_string(user_query || "")); if (user_query != "") { prompt += "\n\n---\nПользователь задал вопрос: " + user_query + "\nОтветьте на него, учитывая диагностический контекст выше. Если вопрос не связан с диагностикой/настройкой роутера — ответьте на него как ИИ-ассистент, но кратко упомяните текущее состояние системы.\n"; } let provider = cfg.ai_doctor_provider || "openai"; let api_key = cfg.ai_doctor_api_key || ""; let custom_url = cfg.ai_doctor_custom_url || ""; let ai_res = query_llm(provider, api_key, custom_url, prompt, model_override); if (!ai_res) { print(sprintf("%J\n", local_res)); return 0; } let quick_fixes = []; let text = ai_res; let fix_idx = index(text, "FIX:"); if (fix_idx >= 0) { let fix_part = trim(substr(text, fix_idx + 4)); let raw_codes = split(fix_part, /[,\r\n]+/); for (let code in raw_codes) { let clean_code = trim(replace(code, /[._`*]/g, "")); let first_token = split(clean_code, /[ \t]+/)[0]; if (first_token != "") { push(quick_fixes, first_token); } } text = trim(substr(text, 0, fix_idx)); } let quick_fix = length(quick_fixes) > 0 ? join(",", quick_fixes) : ""; let doctor_res = { success: true, timestamp: time(), report: text, nodes: local_res.nodes, quick_fix: quick_fix, quick_fixes: quick_fixes, provider: provider, model: model_override != "" ? model_override : "default" }; let f = fs.open("/tmp/ai_doctor_last.json", "w"); if (f) { f.write(sprintf("%J\n", doctor_res)); f.close(); } print(sprintf("%J\n", doctor_res)); return 0; } function lan_clients() { let clients = []; let seen = {}; let excluded = {}; let cfg = uci_settings(); let exc_list = cfg.excluded_clients || cfg.excluded_ips || []; if (type(exc_list) == "string") { exc_list = words(exc_list); } if (type(exc_list) == "array") { for (let item in exc_list) { excluded[trim(as_string(item))] = true; } } let lease_files = [ "/tmp/dhcp.leases", "/var/lib/misc/dnsmasq.leases", "/tmp/hosts/dhcp" ]; for (let lpath in lease_files) { let data = fs.readfile(lpath); if (!data) continue; for (let line in split(as_string(data), "\n")) { line = trim(line); if (line == "" || index(line, "#") == 0) continue; let fields = split(line, /[ \t]+/); if (length(fields) >= 4) { let mac = lc(fields[1]); let ip = fields[2]; let hostname = fields[3] != "*" ? fields[3] : ""; if (index(mac, ":") > 0 && index(ip, ".") > 0) { if (!seen[ip]) { seen[ip] = true; push(clients, { ip: ip, mac: mac, hostname: hostname != "" ? hostname : "Device-" + replace(substr(mac, length(mac) - 5), /:/g, ""), is_online: true, mode: (excluded[ip] || excluded[mac]) ? "direct" : "proxied" }); } } } } } let arp_data = fs.readfile("/proc/net/arp"); if (arp_data) { for (let line in split(as_string(arp_data), "\n")) { line = trim(line); if (line == "" || index(line, "IP address") == 0) continue; let fields = split(line, /[ \t]+/); if (length(fields) >= 4) { let ip = fields[0]; let mac = lc(fields[3]); if (mac != "00:00:00:00:00:00" && index(mac, ":") > 0 && index(ip, ".") > 0) { if (!seen[ip]) { seen[ip] = true; push(clients, { ip: ip, mac: mac, hostname: "Device-" + replace(substr(mac, length(mac) - 5), /:/g, ""), is_online: true, mode: (excluded[ip] || excluded[mac]) ? "direct" : "proxied" }); } } } } } print(sprintf("%J\n", { success: true, clients: clients, total: length(clients) })); return 0; } function toggle_client_bypass(target_ip) { target_ip = trim(as_string(target_ip)); if (target_ip == "") { print(sprintf("%J\n", { success: false, error: "IP address is required" })); return 1; } let cfg = uci_settings(); let exc_list = cfg.excluded_clients || cfg.excluded_ips || []; if (type(exc_list) == "string") { exc_list = words(exc_list); } let new_list = []; let found = false; if (type(exc_list) == "array") { for (let item in exc_list) { let str = trim(as_string(item)); if (str == target_ip) { found = true; } else if (str != "") { push(new_list, str); } } } if (!found) { push(new_list, target_ip); } uci_core.set("tachyon", "settings", "excluded_clients", new_list); uci_core.commit("tachyon"); command_status("/etc/init.d/tachyon reload >/dev/null 2>&1"); print(sprintf("%J\n", { success: true, ip: target_ip, mode: found ? "proxied" : "direct", message: found ? "Client restored to proxy routing" : "Client added to Direct WAN bypass" })); return 0; } function extract_ruleset(tag) { if (tag == "") { warn("tag is required\n"); return 1; } let db_path = "/etc/sing-box/cache.db"; let f = fs.open(db_path, "r"); if (!f) { warn("failed to open " + db_path + "\n"); return 1; } let page_size = 4096; let found_data = null; while (true) { let header = f.read(16); if (!header || length(header) < 16) { break; } let flags = (ord(header, 9) << 8) | ord(header, 8); let count = (ord(header, 11) << 8) | ord(header, 10); let overflow = (ord(header, 15) << 24) | (ord(header, 14) << 16) | (ord(header, 13) << 8) | ord(header, 12); let remaining_page_size = page_size - 16; if (overflow > 0) { remaining_page_size += overflow * page_size; } let page_body = f.read(remaining_page_size); if (!page_body || length(page_body) < remaining_page_size) { break; } let page_data = header + page_body; if (flags == 0x02 && count > 0) { let elem_offset = 16; for (let i = 0; i < count; i++) { if (elem_offset + 16 > length(page_data)) break; let pos = (ord(page_data, elem_offset + 7) << 24) | (ord(page_data, elem_offset + 6) << 16) | (ord(page_data, elem_offset + 5) << 8) | ord(page_data, elem_offset + 4); let ksize = (ord(page_data, elem_offset + 11) << 24) | (ord(page_data, elem_offset + 10) << 16) | (ord(page_data, elem_offset + 9) << 8) | ord(page_data, elem_offset + 8); let vsize = (ord(page_data, elem_offset + 15) << 24) | (ord(page_data, elem_offset + 14) << 16) | (ord(page_data, elem_offset + 13) << 8) | ord(page_data, elem_offset + 12); let key_start = elem_offset + pos; if (key_start + ksize + vsize <= length(page_data)) { let key = substr(page_data, key_start, ksize); if (key == tag) { let raw_val = substr(page_data, key_start + ksize, vsize); // Parse varint length of the ruleset to skip cache header let offset = 1; // skip cache type (0x01) let len = 0; let shift = 0; while (offset < length(raw_val)) { let b = ord(raw_val, offset); len |= ((b & 0x7F) << shift); offset++; if (!(b & 0x80)) { break; } shift += 7; } found_data = substr(raw_val, offset, len); break; } } elem_offset += 16; } } if (found_data) break; } f.close(); if (!found_data) { warn("tag " + tag + " not found in cache.db\n"); return 1; } let out_dir = "/tmp/sing-box/rulesets"; system("mkdir -p " + out_dir); let out_path = out_dir + "/community_" + tag + ".srs"; if (fs.writefile(out_path, found_data) == null) { warn("failed to write ruleset to " + out_path + "\n"); return 1; } print(out_path + "\n"); return 0; } let mode = ARGV[0] || ""; if (mode == "extract-ruleset") exit(extract_ruleset(ARGV[1] || "")); else if (mode == "check-proxy") exit(check_proxy()); else if (mode == "check-nft") exit(check_nft()); else if (mode == "check-nft-rules") exit(check_nft_rules()); else if (mode == "check-sing-box") exit(check_sing_box()); else if (mode == "sing-box-standard-ports-listening-fixture") sing_box_standard_ports_listening_fixture(); else if (mode == "check-inbounds-config") exit(check_inbounds_config()); else if (mode == "check-inbounds") exit(check_inbounds()); else if (mode == "check-logs") exit(check_logs()); else if (mode == "check-sing-box-logs") exit(check_sing_box_logs()); else if (mode == "tachyon-logs-fixture") exit(tachyon_logs_fixture()); else if (mode == "check-fakeip") exit(check_fakeip()); else if (mode == "check-zapret-runtime") exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "check" ])); else if (mode == "check-zapret2-runtime") exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "check" ])); else if (mode == "check-byedpi-runtime") exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "check" ])); else if (mode == "check-tor-runtime") { print(sprintf("%J", { success: true, data: { tor_installed: 0 } })); exit(0); } else if (mode == "neutralize-zapret-defaults") exit(neutralize_zapret_defaults()); else if (mode == "clash-api") exit(clash_api(ARGV[1], ARGV[2], ARGV[3], ARGV[4])); else if (mode == "show-config") exit(show_config(ARGV[1] || "masked")); else if (mode == "show-version") exit(show_version()); else if (mode == "show-sing-box-config") exit(show_sing_box_config(ARGV[1] || "masked")); else if (mode == "show-sing-box-version") exit(show_sing_box_version()); else if (mode == "get-status") exit(get_status()); else if (mode == "get-outbound-metadata") exit(get_outbound_metadata(ARGV[1])); else if (mode == "get-subscription-metadata") exit(get_subscription_metadata(ARGV[1])); else if (mode == "get-sing-box-status") exit(get_sing_box_status()); else if (mode == "get-zapret-status") exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "status" ])); else if (mode == "get-zapret2-status") exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "status" ])); else if (mode == "get-byedpi-status") exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "status" ])); else if (mode == "get-tailscale-status") exit(module_passthrough(TAILSCALE_RUNTIME_UC, [ "status" ])); else if (mode == "get-tailscale-peers") exit(module_passthrough(TAILSCALE_RUNTIME_UC, [ "peers" ])); else if (mode == "get-system-info") exit(get_system_info()); else if (mode == "get-server-capabilities") exit(get_server_capabilities()); else if (mode == "check-dns-available") exit(check_dns_available()); else if (mode == "global-check") exit(global_check(ARGV[1] || "", ARGV[2] || "")); else if (mode == "doctor") { // Dry-run by default: repairs require an explicit --fix flag. let fix_requested = false; let format_arg = ""; for (let i = 1; i < length(ARGV); i++) { if (ARGV[i] == "--fix") fix_requested = true; else format_arg = ARGV[i]; } exit(doctor(format_arg, fix_requested)); } else if (mode == "diagnose-json") exit(diagnose_json()); else if (mode == "ai-doctor") exit(ai_doctor(ARGV[1] || "")); else if (mode == "ai-doctor-last") exit(ai_doctor_last()); else if (mode == "apply-quick-fix") exit(apply_quick_fix(ARGV[1] || "")); else if (mode == "lan-clients") exit(lan_clients()); else if (mode == "toggle-client-bypass") exit(toggle_client_bypass(ARGV[1])); else if (mode == "service-health-check") { let args = []; for (let i = 1; i < length(ARGV); i++) { push(args, ARGV[i]); } exit(module_passthrough(LIB_DIR + "/diagnostics/service_check.uc", args)); } else if (mode == "validate-nfqws-strategy-json") exit(validate_nfqws_strategy_json(ARGV[1] || "")); else if (mode == "validate-nfqws2-strategy-json") exit(validate_nfqws2_strategy_json(ARGV[1] || "")); else if (mode == "validate-byedpi-strategy-json") exit(validate_byedpi_strategy_json(ARGV[1] || "")); else { warn("Usage: diagnostics/runtime.uc ...\n"); exit(1); }