feat(doctor): расширенная локальная диагностика DPI, нод, конфликтов и фикс зависимостей CI

This commit is contained in:
Dushnilin
2026-08-15 15:58:40 +03:00
parent 1437997153
commit 85ae733787
2 changed files with 282 additions and 81 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
shell: bash shell: bash
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential cmake git libjson-c-dev pkg-config sudo apt-get install -y build-essential cmake git libjson-c-dev pkg-config nodejs curl
git clone --depth 1 --branch v0.0.20250529 https://github.com/jow-/ucode.git /tmp/ucode git clone --depth 1 --branch v0.0.20250529 https://github.com/jow-/ucode.git /tmp/ucode
cmake -S /tmp/ucode -B /tmp/ucode/build \ cmake -S /tmp/ucode -B /tmp/ucode/build \
-DUBUS_SUPPORT=OFF \ -DUBUS_SUPPORT=OFF \
+281 -80
View File
@@ -3223,6 +3223,97 @@ function doctor_fix_overused(code) {
return int(rec.count) >= 3; 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 sb_cfg = fs.readfile("/etc/sing-box/config.json") || "";
let has_mixed = index(sb_cfg, "4534") >= 0;
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" || 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) { function apply_quick_fix(codes_str) {
if (!codes_str || codes_str == "") { if (!codes_str || codes_str == "") {
print(sprintf("%J\n", { success: false, error: "No fix code provided" })); print(sprintf("%J\n", { success: false, error: "No fix code provided" }));
@@ -3324,8 +3415,12 @@ function apply_quick_fix(codes_str) {
} }
} }
let post_verify = verify_system();
print(sprintf("%J\n", { print(sprintf("%J\n", {
success: all_ok, success: all_ok,
verified: post_verify.failed == 0,
remaining_failures: post_verify.failed,
results: results results: results
})); }));
return all_ok ? 0 : 1; return all_ok ? 0 : 1;
@@ -3352,95 +3447,189 @@ function ai_doctor_last() {
return 0; return 0;
} }
// ─── End-to-end verification: is everything actually working ────────────────── const DOCTOR_FIX_PRIORITIES = {
// The snapshot checks pass even when the network has been silently flapping for "fix_wan_interface": 10,
// hours: at the moment of the check the WAN is up, so the doctor concludes "all "restart_network": 15,
// OK". This routine verifies what the LAN client actually experiences — DNS "fix_gateway": 20,
// through each layer, HTTP through the proxy — and inspects the recent log "fix_resolv_symlink": 30,
// history for instability (WAN flaps, service restarts) before anyone is "fix_dnsmasq": 35,
// allowed to declare the system healthy. "switch_to_doh": 40,
function verify_system() { "clear_dns_cache": 45,
let checks = []; "start_singbox": 50,
let stability = { "restart_zapret": 60,
wan_flaps: 0, "rebuild_rules": 70,
singbox_restarts: 0, "update_subscriptions": 80,
dnsmasq_restarts: 0, "optimize_memory": 90,
tachyon_restarts: 0 "enable_safe_bypass": 95,
}; "heal_network_stack": 100
};
function add(name, status, detail, evidence) { function doctor_fix_sort(a, b) {
push(checks, { name, status, detail, evidence: evidence || "" }); 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, 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"
});
add_fix("switch_to_doh");
} }
// Live checks — what a client on the LAN experiences right now. let zap_mode = trim(as_string(cfg.zapret_mode || "disabled"));
let sb_pid = find_process_pid("sing-box"); let bd_mode = trim(as_string(cfg.byedpi_mode || "disabled"));
add("sing-box process", sb_pid != "" ? "pass" : "fail", if (zap_mode == "disabled" && bd_mode == "disabled" && upstream_ip != "") {
sb_pid != "" ? "running (pid " + sb_pid + ")" : "not running"); 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"
});
add_fix("restart_zapret");
}
}
}
let lan_dns = dns_check_resolve_host("google.com", "127.0.0.1", 3); function diagnose_proxies_health(cfg, lang, causes, add_fix) {
if (lan_dns == "") lan_dns = dns_check_resolve_host("cloudflare.com", "127.0.0.1", 3); let clash_addr = clash_api_url();
if (lan_dns == "") lan_dns = dns_check_resolve_host("openwrt.org", "127.0.0.1", 3); let curl_res = command_capture("curl -s --max-time 2 http://" + clash_addr + "/proxies 2>/dev/null");
add("LAN DNS via dnsmasq", lan_dns != "" ? "pass" : "fail", if (curl_res.status == 0 && curl_res.output != "") {
lan_dns != "" ? "resolved successfully" : "no answer from 127.0.0.1"); let p_obj = parse_json_or_null(curl_res.output);
if (p_obj && p_obj.proxies) {
let up_dns = dns_check_resolve_host("google.com", "1.1.1.1", 3); let total_nodes = 0;
if (up_dns == "") up_dns = dns_check_resolve_host("cloudflare.com", "8.8.8.8", 3); let dead_nodes = 0;
if (up_dns == "") up_dns = dns_check_resolve_host("openwrt.org", "77.88.8.8", 3); for (let name in p_obj.proxies) {
add("Upstream DNS", up_dns != "" ? "pass" : "fail", let p = p_obj.proxies[name];
up_dns != "" ? "resolved successfully" : "no answer from upstream resolvers"); if (!p) continue;
let p_type = lc(as_string(p.type || ""));
let sb_dns = dns_check_through_singbox("google.com"); if (p_type == "selector" || p_type == "urltest" || p_type == "direct" || p_type == "reject" || p_type == "compatible")
if (!sb_dns) sb_dns = dns_check_through_singbox("cloudflare.com"); continue;
add("Proxy DNS via sing-box", sb_dns ? "pass" : (sb_pid != "" ? "fail" : "skip"), total_nodes++;
sb_dns ? "resolved via " + SB_DNS_INBOUND_ADDRESS : (sb_pid != "" ? "no answer" : "sing-box not running")); let history = type(p.history) == "array" ? p.history : [];
if (length(history) > 0) {
// HTTP through the service mixed proxy — only present when download_via_proxy let last = history[length(history) - 1];
// is enabled; otherwise the tproxy/tun path is covered by the checks above. if (last && int(last.delay || 0) == 0)
let sb_cfg = fs.readfile("/etc/sing-box/config.json") || ""; dead_nodes++;
let has_mixed = index(sb_cfg, "4534") >= 0; } else if (p.alive == false) {
if (has_mixed && sb_pid != "") { dead_nodes++;
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" || code == "200") ? "pass" : "fail", if (total_nodes > 0 && dead_nodes == total_nodes) {
code == "204" || code == "200" ? "end-to-end OK through sing-box" : "HTTP " + code); push(causes, {
} else { probability: 94,
add("HTTP via proxy", "skip", "service mixed proxy not in config"); 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"
});
add_fix("update_subscriptions");
}
}
} }
add("WAN interface", wan_has_ip() ? "pass" : "fail", let sub_files = fs.glob("/tmp/tachyon_subscription_cache/*.error");
wan_has_ip() ? get_wan_interface() + " up" : "no IP"); if (sub_files) {
add("Default gateway", default_gateway_exists() ? "pass" : "fail", for (let sub_file in sub_files) {
default_gateway_exists() ? "present" : "missing"); 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"
});
add_fix("update_subscriptions");
break;
}
}
}
}
// History — inspect recent system log tail without false triggers from normal upgrades/reloads function diagnose_system_conflicts(cfg, lang, causes, add_fix) {
let hist = command_capture("logread 2>/dev/null | tail -n 250").output; let competing_dns = [ "adguardhome", "smartdns", "stubby", "unbound", "nextdns" ];
for (let line in split(hist, "\n")) { for (let svc in competing_dns) {
let l = lc(line); let pid = find_process_pid(svc);
// Skip planned / normal upgrade and service operations if (pid != "") {
if (index(l, "successful component change") >= 0 || index(l, "upgrading") >= 0 || push(causes, {
index(l, "post-upgrade") >= 0 || index(l, "component_action") >= 0 || probability: 82,
index(l, "component-action") >= 0 || index(l, "doctor_fix") >= 0) cause: lang == "en" ? sprintf("Conflicting DNS service running: %s (PID %s) may interfere with Tachyon DNS routing", svc, pid)
continue; : sprintf("Обнаружен сторонний DNS-сервис: %s (PID %s), возможен конфликт перехвата DNS-трафика", svc, pid),
fix: "fix_dnsmasq"
if (index(l, "udhcpc") >= 0 && (index(l, "lease lost") >= 0 || index(l, "deconfig") >= 0)) });
stability.wan_flaps++; add_fix("fix_dnsmasq");
if (index(l, "wan.down") >= 0 || index(l, "watchdog: wan check failed") >= 0) break;
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; let competing_pkgs = [ "passwall", "openclash", "vssr", "shadowsocksr" ];
for (let c in checks) { for (let pkg in competing_pkgs) {
if (c.status == "fail") failed++; 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"
});
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"
});
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"
});
add_fix("rebuild_rules");
break;
}
}
} }
return { checks, stability, failed };
} }
function local_rule_doctor() { function local_rule_doctor() {
@@ -3586,6 +3775,18 @@ function local_rule_doctor() {
} }
} }
// ── 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"); }
// 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 report_lines = [];
let is_fully_healthy = verify.failed == 0 && length(causes) == 0; let is_fully_healthy = verify.failed == 0 && length(causes) == 0;
@@ -3677,7 +3878,7 @@ function ai_doctor() {
local_res = local_rule_doctor(); local_res = local_rule_doctor();
} catch (e) { } catch (e) {
local_res = { local_res = {
report: sprintf("Local diagnosis failed: %s", as_string(e)), report: sprintf("Local diagnosis failed: %s (error: %J)", as_string(e), e),
causes: [], causes: [],
quick_fixes: [], quick_fixes: [],
summary: sprintf("Local diagnosis failed: %s", as_string(e)), summary: sprintf("Local diagnosis failed: %s", as_string(e)),