Улучшение ИИ-Доктора: экспертная скриптовая система диагностики и самовосстановления

This commit is contained in:
Dushnilin
2026-08-15 09:48:40 +03:00
parent 1e406c3e06
commit c8d02a917e
3 changed files with 380 additions and 298 deletions
@@ -873,42 +873,86 @@ async function handleRunAiDoctor() {
let historyEntries = getAiDoctorHistory();
let activeTab: 'diagnosis' | 'history' = 'diagnosis';
// Root Cause Analysis from Report
// Root Cause Analysis from Backend / Report
const repLower = report.toLowerCase();
const nodes = [
const backendNodes =
Array.isArray(data?.nodes) && (data!.nodes as unknown[]).length === 4
? (data!.nodes as Array<{
name: string;
status: 'OK' | 'WARN' | 'FAIL';
}>)
: null;
const nodes = backendNodes ?? [
{
name: 'WAN',
status: repLower.includes('wan interface down') ? 'FAIL' : 'OK',
status:
repLower.includes('wan interface down') ||
repLower.includes(
'шлюз по умолчанию или внешний интернет недоступен',
) ||
repLower.includes('wan interface is unreachable')
? 'FAIL'
: 'OK',
},
{
name: 'DNS',
status:
repLower.includes('dnsmasq') || repLower.includes('dns')
? repLower.includes('dns failed') ||
repLower.includes('dnsmasq failed')
? 'FAIL'
: 'OK'
repLower.includes('сбой разрешения dns') ||
repLower.includes('dns resolution failed') ||
repLower.includes('dns failed') ||
repLower.includes('dnsmasq failed')
? 'FAIL'
: 'OK',
},
{
name: 'sing-box',
status:
repLower.includes('sing-box') &&
(repLower.includes('stopped') || repLower.includes('error'))
(repLower.includes('sing-box') || repLower.includes('proxy')) &&
(repLower.includes('остановлен') ||
repLower.includes('stopped') ||
repLower.includes('не функционирует') ||
repLower.includes('error') ||
repLower.includes('crash'))
? 'FAIL'
: 'OK',
},
{
name: 'nftables',
status:
repLower.includes('nftables') || repLower.includes('rules')
? repLower.includes('damaged') || repLower.includes('corrupted')
? 'WARN'
: 'OK'
(repLower.includes('nftables') ||
repLower.includes('правила файрвола') ||
repLower.includes('firewall rules')) &&
(repLower.includes('нарушены') ||
repLower.includes('damaged') ||
repLower.includes('corrupted') ||
repLower.includes('compromised'))
? 'WARN'
: 'OK',
},
];
const FIX_LABELS: Record<string, string> = {
start_singbox: _('Start sing-box'),
rebuild_rules: _('Rebuild firewall rules'),
fix_dnsmasq: _('Restart dnsmasq'),
fix_resolv_symlink: _('Fix resolv.conf'),
start_watchdog: _('Start watchdog'),
restart_singbox_dns: _('Restart sing-box DNS'),
fix_uci_config: _('Restore config backup'),
fix_wan_interface: _('Reconnect WAN'),
fix_gateway: _('Resolve gateway'),
clear_dns_cache: _('Clear DNS cache'),
update_subscriptions: _('Update subscriptions'),
reset_firewall: _('Restart firewall'),
restart_network: _('Restart network'),
restart_zapret: _('Restart Zapret/ByeDPI'),
optimize_memory: _('Optimize RAM memory'),
switch_to_doh: _('Switch DNS to DoH'),
heal_network_stack: _('Auto-Heal Network Stack'),
enable_safe_bypass: _('Enable Direct WAN Bypass'),
};
const renderRootCauseBanner = () => {
return E(
'div',
@@ -989,13 +1033,16 @@ async function handleRunAiDoctor() {
},
quickFixes.map((code) => {
let applied = false;
const friendlyLabel = FIX_LABELS[code]
? `${FIX_LABELS[code]} (${code})`
: code;
const btn = renderButton({
classNames: ['cbi-button-apply'],
text: `⚡ ${code}`,
text: `⚡ ${friendlyLabel}`,
onClick: async () => {
if (applied) return;
showToast(
_('Applying fix') + ': ' + code + '...',
_('Applying fix') + ': ' + friendlyLabel + '...',
'success',
);
const fixRes =
@@ -1006,16 +1053,16 @@ async function handleRunAiDoctor() {
(fixRes as { success?: boolean }).success
) {
applied = true;
btn.textContent = `✓ ${code} (${_('Fixed')})`;
btn.textContent = `✓ ${friendlyLabel} (${_('Fixed')})`;
btn.classList.remove('cbi-button-apply');
btn.classList.add('cbi-button-neutral');
showToast(
_('Fix applied') + ': ' + code,
_('Fix applied') + ': ' + friendlyLabel,
'success',
);
} else {
showToast(
_('Failed to apply fix') + ': ' + code,
_('Failed to apply fix') + ': ' + friendlyLabel,
'error',
);
}
@@ -12226,24 +12226,47 @@ async function handleRunAiDoctor() {
let historyEntries = getAiDoctorHistory();
let activeTab2 = "diagnosis";
const repLower = report.toLowerCase();
const nodes = [
const backendNodes = Array.isArray(data?.nodes) && data.nodes.length === 4 ? data.nodes : null;
const nodes = backendNodes ?? [
{
name: "WAN",
status: repLower.includes("wan interface down") ? "FAIL" : "OK"
status: repLower.includes("wan interface down") || repLower.includes(
"\u0448\u043B\u044E\u0437 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E \u0438\u043B\u0438 \u0432\u043D\u0435\u0448\u043D\u0438\u0439 \u0438\u043D\u0442\u0435\u0440\u043D\u0435\u0442 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D"
) || repLower.includes("wan interface is unreachable") ? "FAIL" : "OK"
},
{
name: "DNS",
status: repLower.includes("dnsmasq") || repLower.includes("dns") ? repLower.includes("dns failed") || repLower.includes("dnsmasq failed") ? "FAIL" : "OK" : "OK"
status: repLower.includes("\u0441\u0431\u043E\u0439 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u0438\u044F dns") || repLower.includes("dns resolution failed") || repLower.includes("dns failed") || repLower.includes("dnsmasq failed") ? "FAIL" : "OK"
},
{
name: "sing-box",
status: repLower.includes("sing-box") && (repLower.includes("stopped") || repLower.includes("error")) ? "FAIL" : "OK"
status: (repLower.includes("sing-box") || repLower.includes("proxy")) && (repLower.includes("\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D") || repLower.includes("stopped") || repLower.includes("\u043D\u0435 \u0444\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0438\u0440\u0443\u0435\u0442") || repLower.includes("error") || repLower.includes("crash")) ? "FAIL" : "OK"
},
{
name: "nftables",
status: repLower.includes("nftables") || repLower.includes("rules") ? repLower.includes("damaged") || repLower.includes("corrupted") ? "WARN" : "OK" : "OK"
status: (repLower.includes("nftables") || repLower.includes("\u043F\u0440\u0430\u0432\u0438\u043B\u0430 \u0444\u0430\u0439\u0440\u0432\u043E\u043B\u0430") || repLower.includes("firewall rules")) && (repLower.includes("\u043D\u0430\u0440\u0443\u0448\u0435\u043D\u044B") || repLower.includes("damaged") || repLower.includes("corrupted") || repLower.includes("compromised")) ? "WARN" : "OK"
}
];
const FIX_LABELS = {
start_singbox: _("Start sing-box"),
rebuild_rules: _("Rebuild firewall rules"),
fix_dnsmasq: _("Restart dnsmasq"),
fix_resolv_symlink: _("Fix resolv.conf"),
start_watchdog: _("Start watchdog"),
restart_singbox_dns: _("Restart sing-box DNS"),
fix_uci_config: _("Restore config backup"),
fix_wan_interface: _("Reconnect WAN"),
fix_gateway: _("Resolve gateway"),
clear_dns_cache: _("Clear DNS cache"),
update_subscriptions: _("Update subscriptions"),
reset_firewall: _("Restart firewall"),
restart_network: _("Restart network"),
restart_zapret: _("Restart Zapret/ByeDPI"),
optimize_memory: _("Optimize RAM memory"),
switch_to_doh: _("Switch DNS to DoH"),
heal_network_stack: _("Auto-Heal Network Stack"),
enable_safe_bypass: _("Enable Direct WAN Bypass")
};
const renderRootCauseBanner = () => {
return E(
"div",
@@ -12298,28 +12321,29 @@ async function handleRunAiDoctor() {
},
quickFixes.map((code) => {
let applied = false;
const friendlyLabel = FIX_LABELS[code] ? `${FIX_LABELS[code]} (${code})` : code;
const btn = renderButton({
classNames: ["cbi-button-apply"],
text: `\u26A1 ${code}`,
text: `\u26A1 ${friendlyLabel}`,
onClick: async () => {
if (applied) return;
showToast(
_("Applying fix") + ": " + code + "...",
_("Applying fix") + ": " + friendlyLabel + "...",
"success"
);
const fixRes = await TachyonShellMethods.applyQuickFix(code);
if (fixRes && typeof fixRes === "object" && fixRes.success) {
applied = true;
btn.textContent = `\u2713 ${code} (${_("Fixed")})`;
btn.textContent = `\u2713 ${friendlyLabel} (${_("Fixed")})`;
btn.classList.remove("cbi-button-apply");
btn.classList.add("cbi-button-neutral");
showToast(
_("Fix applied") + ": " + code,
_("Fix applied") + ": " + friendlyLabel,
"success"
);
} else {
showToast(
_("Failed to apply fix") + ": " + code,
_("Failed to apply fix") + ": " + friendlyLabel,
"error"
);
}
+280 -269
View File
@@ -159,22 +159,144 @@ function uci_config_valid() {
return res == 0;
}
function command_output(command) {
let result = command_capture(command);
return result.status == 0 ? result.output : "";
}
function command_success(command) {
return command_status(command + " >/dev/null 2>&1") == 0;
}
function command_output_from_args(args) {
return command_output(command_from_args(args) + " 2>/dev/null");
}
function command_success_from_args(args) {
return command_success(command_from_args(args));
}
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 dns_check_through_singbox(domain) {
let res = command_capture("nslookup " + shell_quote(domain) + " " + SB_DNS_INBOUND_ADDRESS + " 2>&1");
return index(res.output, "Address:") >= 0 && index(res.output, "#53") >= 0 && index(res.output, "NXDOMAIN") < 0;
}
function get_wan_interface() {
let res = command_capture("uci get network.wan.device 2>/dev/null");
let res = command_capture("uci -q get tachyon.settings.output_network_interface 2>/dev/null");
let iface = (res.status == 0) ? trim(res.output) : "";
if (iface == "") iface = "eth0";
return iface;
if (iface != "") return iface;
res = command_capture("uci get network.wan.device 2>/dev/null");
iface = (res.status == 0) ? trim(res.output) : "";
if (iface != "") return iface;
res = command_capture("uci get network.wan.ifname 2>/dev/null");
iface = (res.status == 0) ? trim(res.output) : "";
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 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;
return index(out, "inet ") >= 0;
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 default_gateway_exists() {
@@ -199,27 +321,6 @@ function get_all_dns_servers(cfg, key) {
return servers;
}
function command_output(command) {
let result = command_capture(command);
return result.status == 0 ? result.output : "";
}
function command_success(command) {
return command_status(command + " >/dev/null 2>&1") == 0;
}
function command_output_from_args(args) {
return command_output(command_from_args(args) + " 2>/dev/null");
}
function command_success_from_args(args) {
return command_success(command_from_args(args));
}
function command_exists(name) {
return command_success_from_args([ "command", "-v", as_string(name) ]);
}
function module_args(module_path, args) {
let result = [ "ucode", "-L", LIB_DIR, module_path ];
for (let arg in args)
@@ -311,15 +412,6 @@ function read_json_file(path) {
}
}
function parse_json_or_null(value) {
try {
return json(as_string(value));
}
catch (e) {
return null;
}
}
function object_or_empty(value) {
return type(value) == "object" ? value : {};
}
@@ -531,98 +623,6 @@ function valid_public_ip(value) {
return valid_public_ipv4(value) || valid_public_ipv6(value);
}
function words(value) {
value = trim(as_string(value));
return value == "" ? [] : split(value, /[ \t\r\n]+/);
}
function allowed_ips_default_routes(value) {
let result = [];
for (let allowed in words(value)) {
if (allowed == "0.0.0.0/0" || allowed == "::/0")
push(result, allowed);
}
return result;
}
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]);
}
route = command_output_from_args([ "ip", "-6", "route", "show", "default" ]);
fields = words(route);
iface = "";
for (let i = 0; i + 1 < length(fields); i++) {
if (fields[i] == "dev") {
iface = fields[i + 1];
break;
}
}
if (iface != "") {
addr = command_output_from_args([ "ip", "-6", "addr", "show", "dev", iface, "scope", "global" ]);
for (let line in split(addr, "\n")) {
line = trim(as_string(line));
let matched = match(line, /^inet6[ \t]+([^\/ \t]+)\//);
if (matched != null)
push_unique(result, seen, matched[1]);
}
}
return join(" ", result);
}
function helper_output(mode, args) {
let full = [ mode ];
for (let arg in args)
@@ -1390,12 +1390,22 @@ function dns_check_resolve_host(host, resolver, timeout_seconds) {
return "";
timeout_seconds = int(timeout_seconds || 2);
for (let line in split(command_output_from_args([
"dig", "@" + resolver, host, "A", "+short", "+timeout=" + as_string(timeout_seconds), "+tries=1"
]), "\n")) {
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));
if (valid_ipv4(line))
return 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 "";
}
@@ -2833,7 +2843,7 @@ function run_doctor_checks() {
doc_check("ℹ️", "WAN interface", get_wan_interface() + " no IP (but DNS reachable — proxy working)", "");
} else {
issues++;
command_status("ifdown wan >/dev/null 2>&1 && ifup wan >/dev/null 2>&1");
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 перезапущен");
@@ -2850,7 +2860,7 @@ function run_doctor_checks() {
doc_check("ℹ️", "Default gateway", "not found (but DNS reachable — proxy working)", "");
} else {
issues++;
command_status("ifdown wan >/dev/null 2>&1 && ifup wan >/dev/null 2>&1");
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");
@@ -3227,7 +3237,7 @@ function apply_quick_fix(codes_str) {
let msg = "";
if (c == "start_singbox") {
command_status("/etc/init.d/sing-box restart >/dev/null 2>&1");
command_status("/usr/bin/tachyon restore_dnsmasq 2>/dev/null; /etc/init.d/sing-box restart >/dev/null 2>&1");
status = true;
msg = "sing-box restarted";
} else if (c == "rebuild_rules") {
@@ -3239,7 +3249,7 @@ function apply_quick_fix(codes_str) {
status = true;
msg = "dnsmasq restarted";
} else if (c == "fix_resolv_symlink") {
command_status("ln -sf /tmp/resolv.conf.auto /etc/resolv.conf");
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 = true;
msg = "resolv.conf symlink fixed";
} else if (c == "start_watchdog") {
@@ -3255,7 +3265,7 @@ function apply_quick_fix(codes_str) {
status = true;
msg = "UCI config restored from backup";
} else if (c == "fix_wan_interface") {
command_status("ifup wan 2>/dev/null");
command_status("ubus call network.interface.wan up 2>/dev/null; ifup wan 2>/dev/null; ip route flush cache 2>/dev/null");
status = true;
msg = "WAN interface re-up triggered";
} else if (c == "fix_gateway") {
@@ -3263,8 +3273,7 @@ function apply_quick_fix(codes_str) {
status = true;
msg = "Network restarted to resolve gateway";
} else if (c == "clear_dns_cache") {
command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1");
command_status("rm -f /etc/sing-box/cache.db 2>/dev/null");
command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1; ip route flush cache 2>/dev/null");
status = true;
msg = "DNS cache cleared and dnsmasq restarted";
} else if (c == "update_subscriptions") {
@@ -3272,7 +3281,7 @@ function apply_quick_fix(codes_str) {
status = true;
msg = "Subscription update triggered";
} else if (c == "reset_firewall") {
command_status("/etc/init.d/firewall restart >/dev/null 2>&1");
command_status("/etc/init.d/firewall restart >/dev/null 2>&1; /etc/init.d/tachyon reload >/dev/null 2>&1");
status = true;
msg = "Firewall restarted";
} else if (c == "restart_network") {
@@ -3280,17 +3289,25 @@ function apply_quick_fix(codes_str) {
status = true;
msg = "Network service restarted";
} else if (c == "restart_zapret") {
command_status("/etc/init.d/zapret restart 2>/dev/null; /etc/init.d/byedpi restart 2>/dev/null");
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 = true;
msg = "Zapret/ByeDPI engines restarted";
} else if (c == "optimize_memory") {
command_status("sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null; /etc/init.d/sing-box restart >/dev/null 2>&1");
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 = true;
msg = "Memory caches flushed and sing-box restarted";
} else if (c == "switch_to_doh") {
command_status("uci set tachyon.settings.dns_type='doh'; uci commit tachyon; /etc/init.d/tachyon reload >/dev/null 2>&1");
status = true;
msg = "Switched DNS mode to DoH and reloaded firewall";
} else if (c == "heal_network_stack") {
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 = true;
msg = "Full network stack recovery executed";
} else if (c == "enable_safe_bypass") {
command_status("uci set tachyon.settings.recovery_bypass='1'; uci commit tachyon; /etc/init.d/tachyon reload >/dev/null 2>&1");
status = true;
msg = "Safe Direct WAN bypass enabled";
} else {
status = false;
msg = "Unknown fix code: " + c;
@@ -3357,14 +3374,19 @@ function verify_system() {
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 != "" ? "google.com resolved" : "no answer from 127.0.0.1");
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 != "" ? "google.com resolved" : "no answer from 1.1.1.1");
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"));
@@ -3375,8 +3397,8 @@ function verify_system() {
if (has_mixed && sb_pid != "") {
let res = command_capture("curl -sS --max-time 8 -x http://127.0.0.1:4534 -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" ? "pass" : "fail",
code == "204" ? "end-to-end 204 through sing-box" : "HTTP " + code);
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");
}
@@ -3386,26 +3408,27 @@ function verify_system() {
add("Default gateway", default_gateway_exists() ? "pass" : "fail",
default_gateway_exists() ? "present" : "missing");
// History — the flapping the snapshot cannot see. Counted over the last
// ~2000 log lines (logd ring buffer), enough to catch a repeating flap.
let hist = command_capture("logread 2>/dev/null | tail -n 2000").output;
// 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);
if (index(l, "udhcpc") >= 0 &&
(index(l, "deconfig") >= 0 || index(l, "release") >= 0 || index(l, "sigterm") >= 0))
// 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, "ifup wan") >= 0 || index(l, "ifdown wan") >= 0)
stability.wan_flaps++;
if (index(l, "sing-box") >= 0 &&
(index(l, "start") >= 0 || index(l, "restart") >= 0 || index(l, "procd") >= 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, "start") >= 0 || index(l, "restart") >= 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, "restart") >= 0 || index(l, "reload") >= 0))
if (index(l, "tachyon") >= 0 && index(l, "sing-box") < 0 && index(l, "watchdog: crashed") >= 0)
stability.tachyon_restarts++;
}
@@ -3435,7 +3458,7 @@ function local_rule_doctor() {
}
}
// ── end-to-end verification: what the LAN client actually experiences ──
// ── 1. End-to-end live verification ──
let wan_cause_added = false;
for (let c in verify.checks) {
if (c.status != "fail") continue;
@@ -3446,10 +3469,17 @@ function local_rule_doctor() {
fix: "start_singbox"
});
add_fix("start_singbox");
} else if (c.name == "LAN DNS via dnsmasq" || c.name == "Proxy DNS via sing-box") {
} else if (c.name == "LAN DNS via dnsmasq") {
push(causes, {
probability: 85,
cause: lang == "en" ? "DNS resolution failed (LAN or proxy layer)" : "Сбой разрешения DNS (слой LAN или прокси)",
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");
@@ -3475,39 +3505,32 @@ function local_rule_doctor() {
}
}
// ── instability history: the flapping a snapshot cannot see ──────────────
if (verify.stability.wan_flaps >= 3) {
// ── 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"
? sprintf("WAN is unstable: %d flaps/restarts in the recent log", verify.stability.wan_flaps)
: sprintf("WAN нестабилен: %d переподключений в свежих логах", verify.stability.wan_flaps),
fix: "fix_wan_interface"
cause: lang == "en" ? "RAM pressure (Out-Of-Memory kill) detected in recent log" : "Обнаружена нехватка оперативной памяти (OOM Kill в журнале)",
fix: "optimize_memory"
});
add_fix("fix_wan_interface");
}
if (verify.stability.singbox_restarts >= 2) {
push(causes, {
probability: 80,
cause: lang == "en"
? sprintf("sing-box restarted %d times in the recent log — crash loop", verify.stability.singbox_restarts)
: sprintf("sing-box перезапускался %d раз в свежих логах — цикл падений", verify.stability.singbox_restarts),
fix: "start_singbox"
});
add_fix("start_singbox");
}
if (verify.stability.dnsmasq_restarts >= 3) {
push(causes, {
probability: 75,
cause: lang == "en"
? sprintf("dnsmasq restarted %d times in the recent log — DNS instability", verify.stability.dnsmasq_restarts)
: sprintf("dnsmasq перезапускался %d раз в свежих логах — нестабильность DNS", verify.stability.dnsmasq_restarts),
fix: "fix_dnsmasq"
});
add_fix("fix_dnsmasq");
add_fix("optimize_memory");
}
// ── snapshot check failures not covered above ────────────────────────────
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;
@@ -3522,97 +3545,56 @@ function local_rule_doctor() {
fix: "rebuild_rules"
});
add_fix("rebuild_rules");
} else if (index(c.name, "DNS") >= 0 || index(c.name, "dnsmasq") >= 0 || index(c.name, "resolv") >= 0) {
} else if (index(c.name, "resolv") >= 0) {
push(causes, {
probability: 85,
cause: lang == "en" ? "DNS resolution failed or is hijacked by ISP" : "Сбой разрешения DNS-имён или перехват оператором",
fix: "clear_dns_cache"
probability: 80,
cause: lang == "en" ? "DNS resolv.conf configuration broken" : "Конфигурация /etc/resolv.conf повреждена",
fix: "fix_resolv_symlink"
});
add_fix("clear_dns_cache");
if (cfg.dns_type != "doh") {
add_fix("switch_to_doh");
}
} else if (index(c.name, "Clash API") >= 0 || index(c.name, "sing-box") >= 0) {
push(causes, {
probability: 88,
cause: lang == "en" ? "sing-box is not answering its API" : "sing-box не отвечает на свой API",
fix: "start_singbox"
});
add_fix("start_singbox");
add_fix("fix_resolv_symlink");
}
}
let watchdog_status_raw = fs.readfile("/tmp/tachyon_ai_status.json");
if (watchdog_status_raw) {
let wd_json = parse_json_or_null(watchdog_status_raw);
if (wd_json) {
if (int(wd_json.proxy_fail_streak || 0) > 3) {
push(causes, {
probability: 88,
cause: lang == "en" ? "Proxy fail streak (ISP SNI reset or proxy node offline)" : "Серия сбоев прокси (сброс SNI провайдером или узел офлайн)",
fix: "update_subscriptions"
});
add_fix("update_subscriptions");
add_fix("restart_zapret");
}
if (int(wd_json.dns_fail_streak || 0) > 3) {
push(causes, {
probability: 82,
cause: lang == "en" ? "DNS fail streak (ISP plaintext DNS tampering)" : "Серия сбоев DNS (подмена DNS оператором)",
fix: "switch_to_doh"
});
if (cfg.dns_type != "doh") {
add_fix("switch_to_doh");
}
}
// ── 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");
}
}
let raw_log = trim(command_output("logread | grep -iE 'tachyon|sing-box|dnsmasq|oom|reset|handshake|kill' | tail -n 30 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" : "Обнаружена нехватка оперативной памяти (OOM Kill)",
fix: "optimize_memory"
});
add_fix("optimize_memory");
}
if (index(raw_log, "connection reset by peer") >= 0 || index(raw_log, "TLS handshake timeout") >= 0) {
push(causes, {
probability: 86,
cause: lang == "en" ? "DPI TCP RST / TLS Handshake drop signature detected" : "Сигнатура блокировки DPI (TCP RST / TLS Handshake Drop)",
fix: "restart_zapret"
});
add_fix("restart_zapret");
}
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 < 24) {
if (byedpi_mode != "disabled" && byedpi_mode != "") {
let bd_pid = find_process_pid("ciadpi");
if (bd_pid == "") {
push(causes, {
probability: 78,
cause: lang == "en" ? sprintf("Critical RAM pressure (Available: %d MB)", mem_avail_mb) : sprintf("Дефицит оперативной памяти (Свободно: %d MB)", mem_avail_mb),
fix: "optimize_memory"
probability: 84,
cause: lang == "en" ? "ByeDPI (ciadpi) engine is stopped" : "Служба обхода DPI (ByeDPI/ciadpi) остановлена",
fix: "restart_zapret"
});
add_fix("optimize_memory");
add_fix("restart_zapret");
}
}
let report_lines = [];
let verify_clean = verify.failed == 0 &&
verify.stability.wan_flaps < 3 &&
verify.stability.singbox_restarts < 2 &&
verify.stability.dnsmasq_restarts < 3;
let is_fully_healthy = verify.failed == 0 && length(causes) == 0;
if (lang == "en") {
push(report_lines, "### Tachyon Local AI Doctor Analysis");
if (length(causes) == 0) {
if (verify_clean) {
push(report_lines, "✓ Verified end-to-end: WAN up, DNS resolves (LAN, upstream, via proxy), sing-box answers.");
push(report_lines, "✓ No WAN flaps or service crash loops in the recent log. No DPI drops or RAM pressure detected.");
} else {
push(report_lines, "⚠️ Point-in-time checks pass, but live verification or recent history is not clean — not declaring the system healthy.");
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:");
@@ -3622,12 +3604,14 @@ function local_rule_doctor() {
}
} else {
push(report_lines, "### Анализ Tachyon Local AI Doctor");
if (length(causes) == 0) {
if (verify_clean) {
push(report_lines, "✓ Проверено вживую: WAN поднят, DNS резолвится (LAN, upstream, через прокси), sing-box отвечает.");
push(report_lines, "✓ Флапов WAN и циклов перезапуска сервисов в свежих логах нет. Блокировок DPI и нехватки RAM не обнаружено.");
} else {
push(report_lines, "⚠️ Проверки «здесь и сейчас» проходят, но живая проверка или история нестабильности нечистые — система не признаётся здоровой.");
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, "#### Анализ возможных причин сбоя:");
@@ -3637,11 +3621,37 @@ function local_rule_doctor() {
}
}
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",
@@ -3824,6 +3834,7 @@ function ai_doctor() {
success: true,
timestamp: time(),
report: text,
nodes: local_res.nodes,
quick_fix: quick_fix,
quick_fixes: quick_fixes,
provider: provider,