跳转至

crates\nfarms-core\src\topo.rs

源码路径:crates\nfarms-core\src\topo.rs · 1470 行

//! 拓扑验证器 —— C# `TopoValidator.cs` 的等价移植。
//!
//! 口径与网页版 `apps/web/tools/topo.html` 完全一致:
//! 26 条边 = 14 条本机实测 + 12 条需服务端探测(不计入通过数),
//! 三态判定(通过 / 警告 / 失败),每条结果都带「依据原文」。
//!
//! `--topo-test --offline` 的输出与 C# 原版**逐字节一致**(oracle 验收),
//! 所以本文件里所有面向用户的字符串都照着 C# 原文转写,一个标点都不改。
//! 真实网络探测部分(HTTP 状态码 + 响应体 + 耗时,TCP 三次握手)用
//! ureq + rustls 与 std TcpStream 实现;TLS 叶子证书的提取在 Rust 侧
//! 暂未实现(`TlsNote` 如实标注),这是与 C# 桌面端的唯一能力差距。

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use serde_json::Value;

pub const VERSION: &str = "1.0";
pub const TIMEOUT_MS: i32 = 4000;
pub const FORBID_TIMEOUT_MS: i32 = 5000;
pub const WAVE: usize = 4;

/// `tls_days_left` 的「没取到」哨兵。
///
/// 为什么不用 0:0 是「今天到期」这个**真实**天数,拿它当哨兵会让 judge 的 ⑥
/// 把「压根没读到证书」判成「证书已过期」—— 凭空造出一个失败结论。
const TLS_DAYS_UNKNOWN: i32 = -9999;
/// 判定「确实取到了天数」的阈值:大于它才算真天数(负数 = 已过期)。
/// 取 -9000(而不是 `!= TLS_DAYS_UNKNOWN`)与 C# 原版一致:原版就是这么判的,
/// 任何比它更小的值一律当「没取到」。中间的 -9999..=-9001 是留给哨兵的安全带。
const TLS_DAYS_MISSING_CEIL: i32 = -9000;
/// 证书剩余天数 ≤ 这个值就判「警告,需要安排续签」。
const TLS_WARN_DAYS: i32 = 14;

// ---------------------------------------------------------------- 数据结构

/// 一条拓扑边(实测边 / 需服务端探测的边共用)。
#[derive(Clone, Debug)]
pub struct TopoEdge {
    pub id: String,
    pub kind: &'static str,   // live=本机实测;server=需服务端探测
    pub assert: &'static str, // status | forbid | health | manual
    pub label: String,
    pub url: String,
    pub want: i32,
    pub max_ms: i32,
    pub note: String,
    pub how: String,
    pub port: String,

    // 运行时状态
    pub state: &'static str, // idle | busy | pass | warn | fail | unverified
    pub evidence: String,
    pub why: String,
    pub judged: bool,
}

impl Default for TopoEdge {
    fn default() -> Self {
        Self {
            id: String::new(),
            kind: "live",
            assert: "status",
            label: String::new(),
            url: String::new(),
            want: 0,
            max_ms: 0,
            note: String::new(),
            how: String::new(),
            port: String::new(),
            state: "idle",
            evidence: String::new(),
            why: String::new(),
            judged: false,
        }
    }
}

/// 单边探测的原始证据(拿不到就如实置空,绝不推断)。
#[derive(Clone, Debug, Default)]
pub struct TopoProbeResult {
    pub url: String,
    pub scheme: String, // http | https | tcp
    /// 探测目标主机(按 URL 语法解析;IPv6 字面量已剥掉方括号)。
    pub host: String,
    /// 探测目标端口;URL 里没写端口时按 scheme 补 443/80,解析失败时为 0。
    pub port: u16,
    pub tcp_tried: bool,
    pub tcp_ok: bool,
    pub tcp_ms: i64,
    pub tcp_note: String,
    pub http_tried: bool,
    pub http_status: i32, // 0 = 没拿到状态码
    pub http_ms: i64,
    pub body_text: String,
    pub error: String,
    pub timed_out: bool,
    pub timeout_ms: i32,
    pub tls_subject: String,
    pub tls_issuer: String,
    pub tls_expires: String,
    pub tls_days_left: i32, // TLS_DAYS_UNKNOWN = 没取到
    pub tls_note: String,
}

impl TopoProbeResult {
    pub fn new() -> Self {
        Self {
            tcp_ms: -1,
            http_ms: -1,
            tls_days_left: TLS_DAYS_UNKNOWN,
            ..Default::default()
        }
    }

    pub fn latency_ms(&self) -> i64 {
        if self.http_ms >= 0 {
            self.http_ms
        } else {
            self.tcp_ms
        }
    }
}

// ---------------------------------------------------------------- 清单

/// 与网页版 SERVER_EDGES 一一对应(12 条,不计入通过数)。
pub fn server_edges() -> Vec<TopoEdge> {
    let raw: [(&str, &str, &str, &str); 12] = [
        ("S01", "nginx → api:9990(回环反代)", "9990 nfarmsd api",
         "ss -lntp | grep 9990;curl -sS -o /dev/null -w \"%{http_code}\" http://127.0.0.1:9990/health"),
        ("S02", "nginx → gateway:9991", "9991 nfarmsd gateway",
         "curl -sS -o /dev/null -w \"%{http_code}\" http://127.0.0.1:9991/health"),
        ("S03", "worker → NATS(消费在推进)", "9996/8222 NATS",
         "sudo bash /opt/Cy/services/system.d/nfos/check-pipeline.sh(第 2 关:订阅级投递计数是否增长)"),
        ("S04", "Prometheus 抓取 api/metrics", "9090 Prometheus",
         "curl -sS http://127.0.0.1:9090/api/v1/targets | python3 -m json.tool | grep -A2 health"),
        ("S05", "metrics:9992 → Prometheus", "9992 nfarmsd metrics",
         "curl -sS http://127.0.0.1:9992/metrics | head"),
        ("S06", "Prometheus → Alertmanager", "9093 Alertmanager",
         "curl -sS -o /dev/null -w \"%{http_code}\" http://127.0.0.1:9093/-/ready"),
        ("S07", "Alertmanager → alert-adapter:9095", "10.89.0.1:9095 alert-adapter",
         "curl -sS http://10.89.0.1:9095/health"),
        ("S08", "alert-adapter → AI 引擎:9997", "9997 AI 引擎",
         "curl -sS -o /dev/null -w \"%{http_code}\" http://127.0.0.1:9997/health"),
        ("S09", "总线 → TimescaleDB(容器)", "5432/容器 TimescaleDB",
         "docker exec <pg容器> pg_isready -U <用户>"),
        ("S10", "主 ↔ 备 跨云链路(IPsec/隧道 + ZTNA)", "跨云隧道",
         "ping -c3 172.27.142.182 / ipsec status;curl 备节点内网端口"),
        ("S11", "安全/定时任务全部在位", "systemd timer",
         "systemctl list-timers \"nfos-*\" --all"),
        ("S12", "TLS 证书链 / 有效期(完整链与 OCSP)", "443 证书链",
         "echo | openssl s_client -connect nfarms.cn:443 -servername nfarms.cn 2>/dev/null | openssl x509 -noout -dates -issuer"),
    ];
    raw.iter()
        .map(|(id, label, port, how)| TopoEdge {
            id: id.to_string(),
            kind: "server",
            assert: "manual",
            label: label.to_string(),
            port: port.to_string(),
            how: how.to_string(),
            state: "unverified",
            note: "该边本机(外网)发不出请求:回环地址 / 内网地址 / 容器内 / 跨云隧道。判定口径一律为「需服务端探测」,不计入通过数。".to_string(),
            ..Default::default()
        })
        .collect()
}

/// 与网页版 PORTS 一一对应(12 个内网端口,外网不可达是设计如此)。
pub const PORTS: [&str; 12] = [
    "主节点 123.57.85.171 · 9990 · nfarmsd api · 绑定 127.0.0.1(只绑回环) · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:9990/health",
    "主节点 123.57.85.171 · 9991 · nfarmsd gateway · 绑定 本机(nginx 反代) · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:9991/health",
    "主节点 123.57.85.171 · 9992 · nfarmsd metrics · 绑定 127.0.0.1(只绑回环) · 公网:不可达(按设计) · 探测: curl -sS http://127.0.0.1:9992/metrics | head",
    "备节点 43.156.170.157 · 9996 · NATS 客户端 · 绑定 备节点内网/本机 · 公网:不可达(按设计) · 探测: ss -lntp | grep 9996",
    "备节点 43.156.170.157 · 8222 · NATS 监控 + 健康 · 绑定 备节点本机 · 公网:不可达(按设计) · 探测: curl -sS http://127.0.0.1:8222/varz ; curl -sS http://127.0.0.1:8222/healthz",
    "备节点 43.156.170.157 · 9090 · Prometheus · 绑定 备节点本机 · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:9090/-/ready",
    "备节点 43.156.170.157 · 9093 · Alertmanager · 绑定 备节点本机 · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:9093/-/ready",
    "备节点 43.156.170.157 · 3000 · Grafana · 绑定 备节点本机/容器 · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:3000/api/health",
    "备节点 43.156.170.157 · 9115 · blackbox_exporter · 绑定 备节点本机/容器 · 公网:不可达(按设计) · 探测: curl -sS http://127.0.0.1:9115/metrics | head",
    "备节点 43.156.170.157 · 9095 · alert-adapter · 绑定 10.89.0.1(内网) · 公网:不可达(按设计) · 探测: curl -sS http://10.89.0.1:9095/health",
    "备节点 43.156.170.157 · 9997 · AI 引擎 · 绑定 备节点本机 · 公网:不可达(按设计) · 探测: curl -sS -o /dev/null -w \"%{http_code}\\n\" http://127.0.0.1:9997/health",
    "备节点 43.156.170.157 · 5432/容器 · TimescaleDB · 绑定 容器内 · 公网:不可达(按设计) · 探测: docker exec <pg容器> pg_isready",
];

/// 与网页版 SERVER_CMDS 一一对应。
pub const SERVER_CMDS: [(&str, &str); 8] = [
    ("一条命令体检告警/MQ 链路(六道闸门,输出 PASS/FAIL/WARN)", "sudo bash /opt/Cy/services/system.d/nfos/check-pipeline.sh"),
    ("同上,机器可读输出(便于归档/比对)", "sudo bash /opt/Cy/services/system.d/nfos/check-pipeline.sh --json"),
    ("nfarmsd 四模块监听与进程", "ss -lntp | grep -E \"9990|9991|9992\" ; systemctl status nfarmsd --no-pager | head -20"),
    ("主节点 7 个站点 + docs 的 server 块与门禁位", "grep -rn \"server_name\" /opt/Cy/services/nginx.d/ | sort -u"),
    ("安全 / 定时任务全部到期时间", "systemctl list-timers \"nfos-*\" --all --no-pager"),
    ("TLS 证书有效期与签发者(桌面端只看到叶子证书,链与 OCSP 要在这里看)", "for h in nfarms.cn console.nfarms.cn online.nfarms.cn store.nfarms.cn hub.nfarms.cn mcp.nfarms.cn tools.nfarms.cn docs.nfarms.cn; do echo \"== $h\"; echo | openssl s_client -connect $h:443 -servername $h 2>/dev/null | openssl x509 -noout -dates -issuer; done"),
    ("跨云链路(主 ↔ 备)隧道与连通性", "ipsec status 2>/dev/null | head -20 ; ping -c3 172.27.142.182"),
    ("备节点服务与容器(需先经 ZTNA/隧道登到备节点)", "curl -sS http://127.0.0.1:8222/varz | head -c 400 ; echo ; docker ps --format \"{{.Names}}\\t{{.Status}}\""),
];

fn forbid_note(svc: &str) -> String {
    format!("{svc}未对公网开放;从外网探测「不可达」才是符合预期的结果。注意:这条边只证明「公网连不上」,不等于该服务在服务器上没跑 —— 服务是否健康必须在服务器本机验证(见「内网端口清单」与「服务端深度探测」)。")
}

/// 往列表里加一条实测边,**返回它的下标**。
///
/// 为什么不返回 `&mut TopoEdge`:实测边里有四条 forbid 边要在加完之后
/// 一起把 `max_ms` 清零。返回引用的话这四次 `add` 会同时持有对 `list` 的
/// 可变借用,Rust 直接拒绝(C# 那边是引用语义,天然写得出来)。
/// 返回下标最省事,也让借用关系一目了然。
fn add(
    list: &mut Vec<TopoEdge>,
    assert: &'static str,
    label: &str,
    url: &str,
    want: i32,
    note: &str,
) -> usize {
    let e = TopoEdge {
        kind: "live",
        assert,
        label: label.to_string(),
        url: url.to_string(),
        want,
        note: note.to_string(),
        max_ms: if assert == "status" || assert == "health" { TIMEOUT_MS } else { FORBID_TIMEOUT_MS },
        id: format!("E{:02}", list.len() + 1),
        ..Default::default()
    };
    list.push(e);
    list.len() - 1
}

/// 本机实测边(14 条:9 个 HTTPS 站点 + 1 条同源健康检查 + 4 个备节点公网端口)。
pub fn live_edges() -> Vec<TopoEdge> {
    let mut list = Vec::new();

    // —— 公开站点:HTTPS 实测(跨源状态码,与网页 fetch 口径一致) ——
    add(&mut list, "status", "浏览器/本机 → 官网 nfarms.cn", "https://nfarms.cn/", 200, "公开官网,未登录也应为 200。");
    add(&mut list, "status", "本机 → 工具站 tools.nfarms.cn", "https://tools.nfarms.cn/", 200, "本页所在站点;200 说明静态资源服务正常。");
    add(&mut list, "status", "本机 → 文档站 docs.nfarms.cn", "https://docs.nfarms.cn/", 200, "公开文档站,应为 200。");
    add(&mut list, "status", "本机 → console.nfarms.cn(受门禁)", "https://console.nfarms.cn/", 401, "受统一登录门禁:401 = 门禁在位且服务正常,属「符合预期」。");
    add(&mut list, "status", "本机 → online.nfarms.cn(受门禁)", "https://online.nfarms.cn/", 401, "受统一登录门禁:401 符合预期。");
    add(&mut list, "status", "本机 → store.nfarms.cn(受门禁)", "https://store.nfarms.cn/", 401, "受统一登录门禁:401 符合预期。");
    add(&mut list, "status", "本机 → hub.nfarms.cn(受门禁)", "https://hub.nfarms.cn/", 401, "受统一登录门禁:401 符合预期。");
    add(&mut list, "status", "本机 → mcp.nfarms.cn(受门禁)", "https://mcp.nfarms.cn/", 401, "受统一登录门禁:401 符合预期。");
    add(&mut list, "status", "本机 → 零信任入口 /ztna/", "https://nfarms.cn/ztna/", 200, "只验证入口可达;8 条路由各自的鉴权行为需服务端/登录态验证。");

    // —— 主节点 api 模块:读 JSON 业务字段 ——
    let h = add(&mut list, "health", "本机 → api 模块健康 /api/health", "https://nfarms.cn/api/health", 200,
        "nginx 把 /api/ 反代到回环 127.0.0.1:9990;能校验 service/status/module 三个业务字段。");
    list[h].max_ms = 4000;

    // —— 备节点公开端口:期望不可达(真实 TCP 连接) ——
    let f1 = add(&mut list, "forbid", "本机 TCP → 备节点 NATS 9996(期望不可达)", "tcp://43.156.170.157:9996", 0, &forbid_note("NATS 客户端端口"));
    let f2 = add(&mut list, "forbid", "本机 TCP → 备节点 Prometheus 9090(期望不可达)", "tcp://43.156.170.157:9090", 0, &forbid_note("Prometheus"));
    let f3 = add(&mut list, "forbid", "本机 TCP → 备节点 Grafana 3000(期望不可达)", "tcp://43.156.170.157:3000", 0, &forbid_note("Grafana"));
    let f4 = add(&mut list, "forbid", "本机 TCP → 备节点 blackbox 9115(期望不可达)", "tcp://43.156.170.157:9115", 0, &forbid_note("blackbox_exporter"));
    // C# 里这四条显式把 MaxMs 归零(期望不可达的边没有「太慢」这回事)
    for i in [f1, f2, f3, f4] {
        list[i].max_ms = 0;
    }

    list
}

// ---------------------------------------------------------------- 清单自校验

/// 允许的边类型(`TopoEdge::assert`)。
///
/// 为什么要有这张白名单:这个字段是字符串,写错一个字面量不会有编译错误,
/// 只会让 `judge` / `probe_one` 静默退化成 `status` 语义(不读响应体、
/// 只比状态码),而且没有任何测试会发现。`inventory_issues()` 在自检里兜住它。
const KNOWN_ASSERTS: [&str; 4] = ["status", "forbid", "health", "manual"];

/// 允许的状态名(`TopoEdge::state`)。同理:拼错的后果是界面与汇总行落到
/// `state_name` / `state_badge` / `topo_state_color` 的 `_ =>` 兜底,
/// 显示成「未验证」而不是编译失败。
const KNOWN_STATES: [&str; 6] = ["idle", "busy", "pass", "warn", "fail", "unverified"];

/// 把 `PORTS` 的每一行拆成 `(节点 IP, 端口)`。
///
/// 行格式固定为 `"备节点 43.156.170.157 · 9996 · 服务 · …"`:第一段末词是 IP、
/// 第二段是端口。格式一变这里就解析不出来,下面的比对随之报错 —— 那正是想要的,
/// 两处清单必须一起改。
fn ports_entries() -> Vec<(String, String)> {
    PORTS
        .iter()
        .map(|line| {
            let mut f = line.split('·').map(str::trim);
            let ip = f.next().unwrap_or("").rsplit(' ').next().unwrap_or("").to_string();
            let port = f.next().unwrap_or("").to_string();
            (ip, port)
        })
        .collect()
}

/// 清单自身的口径校验,返回明细(空 = 没问题)。由 `self_test` 汇总成 [FAIL] 行。
///
/// 兜住两件原来没人管的事:
/// 1. `assert` / `state` 只是字符串,拼错没有编译错误,只有静默退化(见上面的白名单);
/// 2. 备节点那 4 个端口在 `PORTS` 与 `live_edges()` 的 forbid 边里各写了一遍,
///    而原自检只检查 `PORTS.len() == 12`:改了边却忘改清单,自检一样报 OK。
fn inventory_issues() -> Vec<String> {
    let mut issues = Vec::new();
    let live = live_edges();
    let server = server_edges();
    for e in live.iter().chain(server.iter()) {
        if !KNOWN_ASSERTS.contains(&e.assert) {
            issues.push(format!(
                "{} 的 assert=\"{}\" 不在允许的四种之内 {KNOWN_ASSERTS:?}",
                e.id, e.assert
            ));
        }
        if !KNOWN_STATES.contains(&e.state) {
            issues.push(format!(
                "{} 的 state=\"{}\" 不在允许的六种之内 {KNOWN_STATES:?}",
                e.id, e.state
            ));
        }
    }
    let entries = ports_entries();
    for e in live.iter().filter(|e| e.assert == "forbid") {
        match forbid_target(&e.url) {
            None => issues.push(format!(
                "{} 的地址不是合法的 tcp://主机:端口:{} —— 按这个地址探不到任何证据",
                e.id, e.url
            )),
            Some((host, port)) => {
                let port = port.to_string();
                if !entries.iter().any(|(ip, p)| ip == &host && p == &port) {
                    issues.push(format!(
                        "{} 的 {host}:{port} 不在内网端口清单(PORTS)里 —— 两处清单已经不一致",
                        e.id
                    ));
                }
            }
        }
    }
    issues
}

// ---------------------------------------------------------------- 小工具

/// C# `(x).ToString("0.#")`:整数不带小数点,一位小数时不补零。
fn fmt_0_dot_hash(x: f64) -> String {
    let r = x.round();
    if (x - r).abs() < 1e-9 {
        format!("{}", r as i64)
    } else {
        format!("{:.1}", x)
    }
}

/// C# `Clip`:把 \r \n 换成空格、trim,超长截断 + 「…」。
///
/// `pub(crate)`:只有本模块与自检在用(`scripts` / `wireshark` 各有自己的同名
/// 私有函数,不需要共用这一个)。
pub(crate) fn clip(s: &str, max: usize) -> String {
    let s: String = s.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect::<String>().trim().to_string();
    let n = s.chars().count();
    if n <= max {
        s
    } else {
        s.chars().take(max).collect::<String>() + "…"
    }
}

fn common_name(dn: &str) -> &str {
    for p in dn.split(',') {
        let p = p.trim();
        if p.len() >= 3 && p[..3].eq_ignore_ascii_case("CN=") {
            return &p[3..];
        }
    }
    dn
}

fn parse_json_object(text: &str) -> Option<Value> {
    let t = text.trim();
    if t.is_empty() {
        return None;
    }
    match serde_json::from_str::<Value>(t) {
        Ok(v @ Value::Object(_)) => Some(v),
        _ => None,
    }
}

fn js(body: &Value, key: &str) -> String {
    body.get(key)
        .map(|v| match v {
            Value::String(s) => s.clone(),
            Value::Null => String::new(),
            other => other.to_string(),
        })
        .unwrap_or_default()
}

fn check_key(body: &Value, key: &str, want: &str, bad: &mut Vec<String>) {
    let got = js(body, key);
    if got != want {
        bad.push(format!("{key}=\"{got}\"≠{want}"));
    }
}

// ---------------------------------------------------------------- 判定

fn tls_evidence(r: &TopoProbeResult) -> String {
    if r.scheme != "https" {
        return String::new();
    }
    if r.tls_subject.is_empty() {
        let note = if !r.tls_note.is_empty() { r.tls_note.as_str() } else { "取不到证书" };
        return format!("(TLS: {note})");
    }
    let mut s = format!("(TLS 叶子证书 {}", clip(common_name(&r.tls_subject), 60));
    if !r.tls_expires.is_empty() {
        s.push_str(&format!(",到期 {}({} 天)", r.tls_expires, r.tls_days_left));
    }
    s.push(')');
    s
}

/// 三态判定,与网页版 judge() 同口径。证据原文逐字照抄 C# 版。
///
/// `pub(crate)`:判定只由 `run_all_blocking` 调用,界面不直接判(它只读
/// `TopoEdge::state` / `evidence`);crate 外没有调用者。
pub(crate) fn judge(e: &mut TopoEdge, r: TopoProbeResult) {
    if e.assert == "manual" {
        e.state = "unverified";
        e.judged = false;
        e.evidence = "需服务端探测 —— 本机(外网)发不出该请求,未验证".to_string();
        e.why = "回环地址/内网地址/容器内/跨云隧道:公网侧拿不到任何证据,不做推断。".to_string();
        return;
    }

    e.judged = true;
    let ev = format!("{} → ", e.url);

    let lat = r.latency_ms();
    let lat_txt = if lat < 0 { "耗时未知".to_string() } else { format!("耗时 {lat}ms") };
    let tmo_base = if r.timeout_ms > 0 {
        r.timeout_ms
    } else if e.max_ms > 0 {
        e.max_ms
    } else {
        TIMEOUT_MS
    };
    let tmo_txt = format!(">{}s", fmt_0_dot_hash(tmo_base as f64 / 1000.0));

    // ① 期望不可达的边:真实 TCP 结果就是最直接的证据(必须先于超时判定)。
    if e.assert == "forbid" {
        // 先排除「压根没探出去」:地址形状不合法时 probe_one 直接返回
        // tcp_tried = false。这种结果**绝不能**落入下面的 `!r.tcp_ok` 分支 ——
        // 那会把一个写错的地址静默判成「通过」,证据里还写着「TCP 不可达」,
        // 而 tcp_note 是空的:一条没有任何证据的合格结论。
        //
        // 判 warn 而不是 unverified:summary_line 只统计 pass/warn/fail 和
        // idle+busy,unverified 不在任何一个计数器里,会从汇总行里凭空消失。
        if !r.tcp_tried {
            let note = if r.tcp_note.is_empty() { "地址无法解析" } else { r.tcp_note.as_str() };
            e.state = "warn";
            e.evidence = format!("{ev}未做探测:{note}(地址形状不合法,不能判为通过)");
            e.why = "期望不可达的边必须写成 tcp://主机:端口。地址写错时本机连都没连上,拿不到任何证据,只能报警告并请人工改正清单 —— 判「通过」等于用假依据掩盖拼写错误。".to_string();
            return;
        }
        if !r.tcp_ok {
            e.state = "pass";
            e.evidence = format!("{ev}TCP 不可达:{},{lat_txt}(公网未开放,符合预期;该端口是否在服务器上健康需在服务器本机验证)", r.tcp_note);
            e.why = "该端口/服务按设计不暴露公网,公网不可达就是正确结果;本判定只覆盖「公网不可达」这一条,不代表内网服务健康。".to_string();
            return;
        }
        e.state = "warn";
        e.evidence = format!("{ev}TCP 可达(三次握手成功,{lat_txt})—— 公网可直接访问");
        e.why = "按设计这些端口不应暴露公网,实测却可达:建议复核安全组/防火墙。注意这不代表服务本身有问题,只说明暴露面与设计不符。".to_string();
        return;
    }

    // ② 超时(期望可达的边)
    if r.timed_out {
        e.state = "fail";
        e.evidence = format!("{ev}请求超时({tmo_txt})无响应");
        e.why = "在超时时间内没有拿到任何响应(不是状态码偏差)。".to_string();
        return;
    }

    // ③ 网络层失败:期望可达却拿不到响应 = 失败
    if r.http_status == 0 {
        e.state = "fail";
        let extra = if lat >= 0 { format!(",{lat_txt}") } else { String::new() };
        let err = if !r.error.is_empty() { r.error.clone() } else { "未取得状态码".to_string() };
        e.evidence = format!("{ev}网络层失败:{err}{extra}");
        e.why = "期望可达却拿不到响应(DNS/连接被拒/TLS 握手失败,系统 API 不细分)。".to_string();
        return;
    }

    // ④ 状态码与期望不一致
    let tls = tls_evidence(&r);
    let ev_base = format!("{ev}{}{tls},{lat_txt}", r.http_status);
    if r.http_status != e.want {
        if r.http_status >= 500 {
            e.state = "fail";
            e.evidence = format!("{ev_base},期望 {}", e.want);
            e.why = format!("服务端错误:期望 {},实测 {}。", e.want, r.http_status);
            return;
        }
        if e.want == 200 && r.http_status == 404 {
            e.state = "fail";
            e.evidence = format!("{ev_base},期望 200");
            e.why = "路径不存在或站点根未正确配置。".to_string();
            return;
        }
        e.state = "warn";
        e.evidence = format!("{ev_base},期望 {}", e.want);
        e.why = "状态码与期望不一致,需人工确认是否为门禁/路由变更。".to_string();
        return;
    }

    // ⑤ 耗时超阈值
    if e.max_ms > 0 && lat > e.max_ms as i64 {
        e.state = "warn";
        e.evidence = format!("{ev_base}(超过阈值 {}ms)", e.max_ms);
        e.why = "可达但明显偏慢,可能是链路或后端压力问题。".to_string();
        return;
    }

    // ⑥ 证书缺陷:已过期 → 失败;14 天内到期 → 警告
    //
    // 这一段目前**只有自检走得到**:生产路径上 tls_days_left 恒为
    // TLS_DAYS_UNKNOWN,因为 TLS 叶子证书的提取在 Rust 侧还没实现
    // (http_probe 不去读 rustls 的 peer_certificates,见文件头的能力差距说明)。
    // 接上证书提取之前,这里由 self_test 的两组假数据守着;别看到阈值没生效就删。
    if r.tls_days_left > TLS_DAYS_MISSING_CEIL {
        if r.tls_days_left < 0 {
            e.state = "fail";
            e.evidence = format!("{ev_base},但 TLS 证书已于 {} 过期", r.tls_expires);
            e.why = "HTTP 通了(浏览器可能仍能访问或被拦),但证书已过期属于确定的链路缺陷。".to_string();
            return;
        }
        if r.tls_days_left <= TLS_WARN_DAYS {
            e.state = "warn";
            e.evidence = format!("{ev_base},TLS 证书 {} 到期(剩 {} 天)", r.tls_expires, r.tls_days_left);
            e.why = "状态码与期望一致,但证书即将到期,需要安排续签。".to_string();
            return;
        }
    }

    // ⑦ health 边:校验 JSON 业务字段
    if e.assert == "health" {
        match parse_json_object(&r.body_text) {
            None => {
                e.state = "warn";
                e.evidence = format!("{ev_base},但响应体不是可解析 JSON:{}", clip(&r.body_text, 160));
                e.why = "期望服务返回 JSON 信封,解析失败说明响应与预期不符。".to_string();
                return;
            }
            Some(body) => {
                let mut bad = Vec::new();
                check_key(&body, "service", "nfarmsd", &mut bad);
                check_key(&body, "status", "ok", &mut bad);
                check_key(&body, "module", "api", &mut bad);
                let brief = format!(
                    "{{\"service\":\"{}\",\"status\":\"{}\",\"module\":\"{}\",\"version\":\"{}\",\"database\":\"{}\"}}",
                    js(&body, "service"), js(&body, "status"), js(&body, "module"), js(&body, "version"), js(&body, "database")
                );
                if !bad.is_empty() {
                    e.state = "warn";
                    e.evidence = format!("{ev_base},业务字段不符:{}", bad.join(" "));
                    e.why = "HTTP 通了但业务字段与期望不一致,需要复核版本/配置。".to_string();
                    return;
                }
                e.state = "pass";
                e.evidence = format!("{ev_base},响应体 {brief}(service/status/module 与期望一致)");
                e.why = "api 模块健康:状态码、耗时、业务字段三项都对得上。".to_string();
                return;
            }
        }
    }

    // ⑧ 受门禁站点 401 = 符合预期
    if e.want == 401 {
        e.state = "pass";
        e.evidence = format!("{ev_base}(受统一登录门禁,401 符合预期)");
        e.why = "401 = 门禁在位且站点正常,未登录就该被挡。".to_string();
        return;
    }

    e.state = "pass";
    e.evidence = ev_base;
    e.why = "状态码与期望一致,耗时在阈值内。".to_string();
}

// ---------------------------------------------------------------- 汇总

/// 按状态计数。`pub(crate)`:全仓只有本模块的自检与 summary_line 在用,
/// 界面侧只用 `summary_line` 和 `state_badge`(crate 外没有调用者)。
pub(crate) fn count_of(edges: &[TopoEdge], state: &str) -> usize {
    edges.iter().filter(|e| e.state == state).count()
}

// 这里原来有个 `pub fn any_running(edges)` —— 全仓没有任何调用者:GUI 的「探测中」
// 用的是 `TopoState::running` 自己那个标志(见 farms-gui/src/plugins/core_pages.rs),
// 不是从边列表里数出来的。留着它只会让「谁在用这个」变成一个查不出答案的问题,
// 所以删掉;将来真需要,应当直接接进 TopoState 的那个标志,而不是再留一个孤儿函数。

/// 顶部一行汇总,格式与网页版 summaryHtml 对齐。
pub fn summary_line(edges: &[TopoEdge], server_count: usize) -> String {
    let pass = count_of(edges, "pass");
    let warn = count_of(edges, "warn");
    let fail = count_of(edges, "fail");
    let idle = count_of(edges, "idle") + count_of(edges, "busy");
    let tail = if idle > 0 { format!(",未验证 {idle}") } else { String::new() };
    format!(
        "{} 条边:通过 {} / 警告 {} / 失败 {}{tail}(另 {} 条需服务端探测)",
        edges.len() + server_count, pass, warn, fail, server_count
    )
}

/// 状态名的中文说法(自检报告用)。`pub(crate)`:crate 外没有调用者,
/// 界面用的是 `state_badge`。
pub(crate) fn state_name(state: &str) -> &'static str {
    match state {
        "pass" => "通过",
        "warn" => "警告",
        "fail" => "失败",
        "busy" => "进行中",
        "unverified" => "需服务端探测",
        _ => "未验证",
    }
}

pub fn state_badge(state: &str) -> &'static str {
    match state {
        "pass" => "● 通过",
        "warn" => "▲ 警告",
        "fail" => "✕ 失败",
        "busy" => "… 进行中",
        "unverified" => "○ 需服务端",
        _ => "○ 未验证",
    }
}

pub fn want_text(e: &TopoEdge) -> String {
    if e.assert == "forbid" {
        return "不可达(公网未开放)".to_string();
    }
    if e.assert == "health" {
        return "200 + service/status/module 业务字段".to_string();
    }
    if e.assert == "manual" {
        return "需服务端探测".to_string();
    }
    if e.want == 401 {
        return "401(受门禁,符合预期)".to_string();
    }
    format!("{}", e.want)
}

// ---------------------------------------------------------------- 探测

/// C# SocketError 的语义映射(连接被拒 / 超时 / DNS 失败……)。
fn describe_io_error(e: &std::io::Error) -> String {
    use std::io::ErrorKind::*;
    match e.kind() {
        ConnectionRefused => "连接被拒(RST,端口没有监听)".to_string(),
        TimedOut | WouldBlock => "超时(防火墙丢包 / 端口不存在)".to_string(),
        NotFound => "DNS 解析失败(主机名不存在)".to_string(),
        _ => {
            let raw = e.raw_os_error().unwrap_or(0);
            match raw {
                10061 => "连接被拒(RST,端口没有监听)".to_string(), // WSAECONNREFUSED
                10060 => "超时(防火墙丢包 / 端口不存在)".to_string(), // WSAETIMEDOUT
                11001 => "DNS 解析失败(主机名不存在)".to_string(),  // WSAHOST_NOT_FOUND
                10049 => "主机不可达(no route to host)".to_string(),
                10051 => "网络不可达".to_string(),
                10054 => "连接被重置(RST)".to_string(),
                10013 => "被本机策略拒绝(防火墙/安全软件)".to_string(),
                _ => format!("socket 错误 {raw}"),
            }
        }
    }
}

/// 本机真实 TCP 连接探测:能区分「连接被拒」与「超时」。
/// `pub(crate)`:只有 `probe_one` 在用(界面/命令行只经 `probe_one`/`run_all_blocking`)。
pub(crate) fn tcp_probe(host: &str, port: u16, timeout_ms: i32) -> TopoProbeResult {
    let mut r = TopoProbeResult::new();
    r.scheme = "tcp".to_string();
    r.host = host.to_string();
    r.port = port;
    r.url = format!("tcp://{host}:{port}");
    r.tcp_tried = true;
    r.timeout_ms = timeout_ms;

    let start = Instant::now();
    let addr_str = format!("{host}:{port}");
    let result = std::net::TcpStream::connect_timeout(
        &match addr_str.parse::<std::net::SocketAddr>() {
            Ok(a) => a,
            Err(_) => {
                // 不是字面 IP —— 先解析 DNS(rust std 在 Windows 上解析失败会给
                // 对应的 io::Error),再按解析出的第一个地址连。
                match lookup_host(&addr_str) {
                    Ok(a) => a,
                    Err(e) => {
                        r.tcp_ok = false;
                        r.tcp_note = describe_io_error(&e);
                        r.tcp_ms = start.elapsed().as_millis() as i64;
                        return r;
                    }
                }
            }
        },
        Duration::from_millis(timeout_ms.max(1) as u64),
    );
    match result {
        Ok(_) => {
            r.tcp_ok = true;
            r.tcp_note = "TCP 三次握手成功".to_string();
        }
        Err(e) => {
            r.tcp_ok = false;
            if e.kind() == std::io::ErrorKind::TimedOut {
                r.timed_out = true;
                r.tcp_note = format!("超时(>{})无响应,握手被丢弃", fmt_0_dot_hash(timeout_ms as f64 / 1000.0));
            } else {
                r.tcp_note = describe_io_error(&e);
            }
        }
    }
    r.tcp_ms = start.elapsed().as_millis() as i64;
    r
}

/// DNS 解析:成功返回第一个 SocketAddr,失败返回 io::Error(NotFound 语义)。
fn lookup_host(addr: &str) -> std::io::Result<std::net::SocketAddr> {
    use std::net::ToSocketAddrs;
    match addr.to_socket_addrs() {
        Ok(mut it) => match it.next() {
            Some(a) => Ok(a),
            None => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no address")),
        },
        Err(e) => Err(e),
    }
}

/// 取 URL 的 scheme(`"https://x/y"` → `"https"`;没有 `"://"` 就返回空串)。
fn url_scheme(url: &str) -> &str {
    match url.split_once("://") {
        Some((s, _)) => s,
        None => "",
    }
}

/// 取 URL 的授权段(host[:port]):从 scheme 之后到第一个 `/`、`?`、`#` 为止。
///
/// 为什么把 `?` / `#` 也算边界:原来那段按 `"://"` 切一次、再取
/// `split('/').next()` 的手写解析,遇到 `https://example.com?x=1` 会把
/// `example.com?x=1` 整个当成主机名。查询串和片段不属于授权段。
fn url_authority(url: &str) -> &str {
    let rest = match url.split_once("://") {
        Some((_, r)) => r,
        None => url,
    };
    match rest.find(['/', '?', '#']) {
        Some(i) => &rest[..i],
        None => rest,
    }
}

/// 解析一个 u16 端口;`""` / 非数字 / 超出范围 / 0 一律算畸形。
///
/// 为什么把 0 也算畸形:`u16` 能解析 0,但 0 不是可连接的目标端口,
/// 而调用方要靠 None 区分「地址写错了」和「探过但连不上」。
fn parse_port(p: &str) -> Option<u16> {
    match p.parse::<u16>() {
        Ok(0) | Err(_) => None,
        Ok(v) => Some(v),
    }
}

/// 把授权段拆成 `(host, port)`:port 为 None = 地址里没写端口(调用方按 scheme 补默认值)。
///
/// 返回 None = 形状不合法(没有主机名 / 端口不是合法数字 / IPv6 没写方括号),
/// 这时**必须**按「没探成」处理,不能退化成 0 端口或空主机名(见 probe_one)。
///
/// 为什么不继续按 `split_once(':')` 切:`https://[::1]:9990/` 会切出
/// host=`"["`、端口回落 443 —— IPv6 字面量的方括号必须先剥掉再找端口。
fn split_host_port(authority: &str) -> Option<(String, Option<u16>)> {
    if let Some(rest) = authority.strip_prefix('[') {
        // IPv6 字面量:[::1] 或 [::1]:9990
        let (host, tail) = rest.split_once(']')?;
        if host.is_empty() {
            return None;
        }
        let port = match tail.strip_prefix(':') {
            Some(p) => Some(parse_port(p)?),
            None if tail.is_empty() => None,
            None => return None, // `]` 后面跟了别的东西
        };
        return Some((host.to_string(), port));
    }
    match authority.rsplit_once(':') {
        Some((host, p)) => {
            // host 里还有冒号 = 没写方括号的 IPv6 或畸形地址,不能当主机名用。
            if host.is_empty() || host.contains(':') {
                return None;
            }
            Some((host.to_string(), Some(parse_port(p)?)))
        }
        None => {
            if authority.is_empty() {
                return None;
            }
            Some((authority.to_string(), None))
        }
    }
}

/// HTTP(S) 探测目标的解析结果:`(scheme, Option<(host, port)>)`。
/// port 缺省按 scheme 补 443 / 80;None = URL 形状不合法(主机名/端口没法解析)。
///
/// 从 `http_probe` 里提出来单独一个函数,是为了不起网络就能把 URL 的语法边界
/// (查询串、IPv6、缺端口)测住。
fn parse_http_target(url: &str) -> (String, Option<(String, u16)>) {
    let scheme = url_scheme(url).to_string();
    let target = split_host_port(url_authority(url))
        .map(|(host, port)| (host, port.unwrap_or(if scheme == "https" { 443 } else { 80 })));
    (scheme, target)
}

/// 期望不可达的边 → `(host, port)`,端口必须显式写出来。
///
/// 返回 None = 地址畸形:scheme 不是 tcp(大小写不敏感)、缺主机名、缺端口、
/// 端口不是合法数字。`probe_one` 拿 None 直接判「没探成」。
fn forbid_target(url: &str) -> Option<(String, u16)> {
    if !url_scheme(url).eq_ignore_ascii_case("tcp") {
        return None;
    }
    let (host, port) = split_host_port(url_authority(url))?;
    Some((host, port?))
}

/// HTTPS / HTTP 实测:状态码 + 响应体 + 耗时。
/// 网络失败一律吞掉转成结果字段,绝不抛出。
///
/// `pub(crate)`:只有 `probe_one` 在用。
pub(crate) fn http_probe(url: &str, timeout_ms: i32, read_body: bool) -> TopoProbeResult {
    let mut r = TopoProbeResult::new();
    r.url = url.to_string();
    r.http_tried = true;
    r.timeout_ms = timeout_ms;

    let (scheme, target) = parse_http_target(url);
    r.scheme = scheme;
    if let Some((host, port)) = target {
        r.host = host;
        r.port = port;
    }

    let start = Instant::now();
    let agent: ureq::Agent = ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_millis(timeout_ms.max(1) as u64)))
        .user_agent(format!(
            "NFarmsTools-Topo/{VERSION} (NFarms HUB desktop topology validator)"
        ))
        .build()
        .into();
    let resp = agent.get(url).call();
    match resp {
        Ok(resp) => {
            r.http_status = resp.status().as_u16() as i32;
            if read_body && (200..300).contains(&r.http_status) {
                match resp.into_body().read_to_string() {
                    Ok(body) => {
                        // C# 只读前 1024 个 char
                        r.body_text = body.chars().take(1024).collect();
                    }
                    Err(_) => r.body_text = String::new(),
                }
            }
        }
        Err(ureq::Error::StatusCode(code)) => {
            // 4xx/5xx:状态码本身就是证据(401 就靠这条路径)。
            // ureq 的这个变体直接带 u16,不是 http::StatusCode。
            r.http_status = code as i32;
        }
        Err(e) => {
            if matches!(e, ureq::Error::Timeout(_)) {
                r.timed_out = true;
            }
            r.error = describe_ureq_error(&e);
        }
    }
    r.http_ms = start.elapsed().as_millis() as i64;
    r
}

fn describe_ureq_error(e: &ureq::Error) -> String {
    match e {
        // ureq 的 `impl Display for Timeout`(ureq-3.4.2/src/timings.rs:224-239)
        // 对全部 9 个变体都写死了非空标签("connect" / "receive response" / …),
        // 所以这里没有「标签为空」这一支可兜 —— 原先那句
        // `if format!("{t}").is_empty()` 是一段永远走不到的代码。
        // 万一 ureq 哪天真的换了空标签,`timeout_error_always_carries_a_label`
        // 会先红,而不是让这里悄悄输出「请求超时()」。
        ureq::Error::Timeout(t) => format!("请求超时({t})"),
        ureq::Error::HostNotFound => "DNS 解析失败".to_string(),
        ureq::Error::ConnectionFailed => {
            "连接失败(DNS/连接被拒/TLS 握手失败,系统 API 不细分)".to_string()
        }
        ureq::Error::Tls(m) => format!("TLS 安全通道建立失败(协议/加密套件不兼容):{m}"),
        ureq::Error::Io(io) => format!("IO 错误:{}", describe_io_error(io)),
        ureq::Error::Protocol(p) => format!("协议错误:{p}"),
        ureq::Error::BadUri(u) => format!("URL 解析失败: {u}"),
        ureq::Error::TooManyRedirects => "重定向次数过多".to_string(),
        other => format!("{other}"),
    }
}

/// 单边探测:按 assert 选 TCP 或 HTTP(全部吞异常,不抛)。
///
/// `pub(crate)`:界面与命令行只经 `run_all_blocking`;自检也不直接调它。
pub(crate) fn probe_one(e: &TopoEdge, timeout_ms: i32) -> TopoProbeResult {
    if e.assert == "forbid" {
        let Some((host, port)) = forbid_target(&e.url) else {
            // 地址畸形(缺端口 / 端口不是数字 / scheme 不是 tcp / 没有主机名)。
            //
            // 这里**不能**返回一个「看起来像探过」的空结果:原来那条路径在解析
            // 失败时返回 `TopoProbeResult::new()`(tcp_ok=false、tcp_note 空、
            // tcp_tried=false),judge 的 forbid 分支只认 tcp_ok=false,于是把
            // 一个写错的地址静默判成「通过」,证据里还写着「TCP 不可达」。
            // 现在把 tcp_tried 明确留在 false,由 judge 改判「警告」。
            let mut r = TopoProbeResult::new();
            r.url = e.url.clone();
            r.scheme = "tcp".to_string();
            r.timeout_ms = if timeout_ms > 0 { timeout_ms } else { FORBID_TIMEOUT_MS };
            r.tcp_note = format!(
                "地址无法解析(期望 tcp://主机:端口,清单里写的是 \"{}\")",
                e.url
            );
            return r;
        };
        return tcp_probe(&host, port, if timeout_ms > 0 { timeout_ms } else { FORBID_TIMEOUT_MS });
    }
    let read_body = e.assert == "health";
    http_probe(&e.url, if timeout_ms > 0 { timeout_ms } else { TIMEOUT_MS }, read_body)
}

/// 把列表里的边全部探测 + 判定(阻塞直到全部完成)。on_done 可为 None。
pub fn run_all_blocking<F: FnMut(&TopoEdge) + Send>(edges: &mut [TopoEdge], timeout_ms: i32, on_done: Option<F>) {
    for e in edges.iter_mut() {
        e.state = "busy";
        e.judged = false;
    }
    let next = Arc::new(AtomicUsize::new(0));
    let edges = Arc::new(Mutex::new(edges));
    let workers = WAVE.min(edges.lock().map(|v| v.len()).unwrap_or(1).max(1));
    let on_done = Arc::new(Mutex::new(on_done));

    std::thread::scope(|s| {
        for _ in 0..workers {
            let next = next.clone();
            let edges = edges.clone();
            let on_done = on_done.clone();
            s.spawn(move || loop {
                let idx = next.fetch_add(1, Ordering::SeqCst);
                let guard = match edges.lock() {
                    Ok(g) => g,
                    Err(_) => return,
                };
                if idx >= guard.len() {
                    return;
                }
                // 先取出这一边需要的输入,再单独判(避免持锁做网络)
                let edge_snapshot = guard[idx].clone();
                drop(guard);

                let mut edge = edge_snapshot.clone();
                let result = probe_one(&edge, timeout_ms);
                judge(&mut edge, result);

                let mut guard = match edges.lock() {
                    Ok(g) => g,
                    Err(_) => return,
                };
                guard[idx] = edge;
                let done_edge = guard[idx].clone();
                drop(guard);

                if let Ok(mut cb) = on_done.lock()
                    && let Some(cb) = cb.as_mut()
                {
                    cb(&done_edge);
                }
            });
        }
    });
}

// ---------------------------------------------------------------- 自检

fn make_edge(assert: &'static str, want: i32) -> TopoEdge {
    TopoEdge {
        kind: "live",
        assert,
        label: "(样例)".to_string(),
        url: "https://example.invalid/".to_string(),
        want,
        max_ms: if assert == "forbid" { 0 } else { TIMEOUT_MS },
        note: "(样例)".to_string(),
        ..Default::default()
    }
}

fn make_probe(status: i32, ms: i64, scheme: &str) -> TopoProbeResult {
    let mut r = TopoProbeResult::new();
    r.url = "https://example.invalid/".to_string();
    r.scheme = scheme.to_string();
    r.http_tried = true;
    r.http_status = status;
    r.http_ms = ms;
    r
}

fn make_timeout() -> TopoProbeResult {
    let mut r = TopoProbeResult::new();
    r.url = "https://example.invalid/".to_string();
    r.scheme = "https".to_string();
    r.http_tried = true;
    r.timed_out = true;
    r.http_ms = TIMEOUT_MS as i64;
    r
}

fn judge_case(out: &mut String, name: &str, e: &mut TopoEdge, r: TopoProbeResult, want_state: &str) -> i32 {
    judge(e, r);
    let ok = e.state == want_state;
    out.push_str(&format!(
        "    [{}] {name} → 判定={}  依据={}\r\n",
        if ok { "OK" } else { "FAIL" },
        state_name(e.state),
        clip(&e.evidence, 150)
    ));
    if ok { 0 } else { 1 }
}

/// `--topo-test` 自检输出。离线部分与 C# 原版逐字节一致。
pub fn self_test(run_network: bool, timeout_ms: i32) -> String {
    let mut out = String::new();
    let mut issues: i32 = 0;
    let live = live_edges();
    let server = server_edges();

    out.push_str(&format!(
        "拓扑验证器自检(--topo-test)  版本 v{VERSION}{}\r\n",
        if run_network { "  [含真实网络探测]" } else { "  [离线:只校验口径定义]" }
    ));
    out.push_str(&format!(
        "  端点清单: 本机实测 {} 条 + 需服务端探测 {} 条 = {} 条\r\n",
        live.len(),
        server.len(),
        live.len() + server.len()
    ));
    if live.len() != 14 {
        out.push_str(&format!("  [FAIL] 本机实测边应为 14 条,实际 {}\r\n", live.len()));
        issues += 1;
    }
    if server.len() != 12 {
        out.push_str(&format!("  [FAIL] 需服务端探测边应为 12 条,实际 {}\r\n", server.len()));
        issues += 1;
    }
    if live.len() + server.len() != 26 {
        out.push_str("  [FAIL] 边总数应为 26 条\r\n");
        issues += 1;
    }
    if PORTS.len() != 12 {
        out.push_str(&format!("  [FAIL] 内网端口清单应为 12 个,实际 {}\r\n", PORTS.len()));
        issues += 1;
    }
    // 边类型/状态名是否都是白名单里的字面量 + PORTS 与 forbid 边是否逐项一致。
    // 只在出错时输出 —— 全对的时候这一行什么都不打,`--topo-test --offline` 的
    // 逐字节基准(scripts\verify-oracle.ps1 的 topo-test 用例)不受影响。
    for m in inventory_issues() {
        out.push_str(&format!("  [FAIL] {m}\r\n"));
        issues += 1;
    }

    // 编号唯一
    let mut seen: Vec<&str> = Vec::new();
    for e in &live {
        if seen.contains(&e.id.as_str()) {
            out.push_str(&format!("  [FAIL] 边编号重复: {}\r\n", e.id));
            issues += 1;
        }
        seen.push(&e.id);
        if e.label.is_empty() || e.url.is_empty() {
            out.push_str(&format!("  [FAIL] {} 缺少标题或地址\r\n", e.id));
            issues += 1;
        }
        if e.note.is_empty() {
            out.push_str(&format!("  [FAIL] {} 缺少口径说明(依据原文要求每条都带)\r\n", e.id));
            issues += 1;
        }
    }
    for e in &server {
        if seen.contains(&e.id.as_str()) {
            out.push_str(&format!("  [FAIL] 边编号重复: {}\r\n", e.id));
            issues += 1;
        }
        seen.push(&e.id);
        if e.how.is_empty() {
            out.push_str(&format!("  [FAIL] {} 缺少服务端探测命令\r\n", e.id));
            issues += 1;
        }
    }

    // 判定口径:构造样例,验证三态与门禁 401 的判定原文
    out.push_str("  判定口径抽查(用构造的探测结果,不联网):\r\n");
    issues += judge_case(&mut out, "门禁站点 401 → 通过", &mut make_edge("status", 401), make_probe(401, 210, "https"), "pass");
    issues += judge_case(&mut out, "公开站点 200 → 通过", &mut make_edge("status", 200), make_probe(200, 180, "https"), "pass");
    issues += judge_case(&mut out, "公开站点 404(期望 200)→ 失败", &mut make_edge("status", 200), make_probe(404, 150, "https"), "fail");
    issues += judge_case(&mut out, "公开站点 502 → 失败", &mut make_edge("status", 200), make_probe(502, 150, "https"), "fail");
    issues += judge_case(&mut out, "公开站点 403(期望 200)→ 警告", &mut make_edge("status", 200), make_probe(403, 150, "https"), "warn");
    issues += judge_case(&mut out, "请求超时 → 失败", &mut make_edge("status", 200), make_timeout(), "fail");
    let mut bad = make_probe(0, 90, "https");
    bad.error = "连接失败(连接被拒/TLS 握手失败)".to_string();
    issues += judge_case(&mut out, "网络层失败 → 失败", &mut make_edge("status", 200), bad, "fail");
    let mut tcp_closed = TopoProbeResult::new();
    tcp_closed.scheme = "tcp".to_string();
    tcp_closed.tcp_tried = true;
    tcp_closed.tcp_ok = false;
    tcp_closed.tcp_ms = 60;
    tcp_closed.tcp_note = "连接被拒(RST,端口没有监听)".to_string();
    issues += judge_case(&mut out, "备节点端口不可达(期望不可达)→ 通过", &mut make_edge("forbid", 0), tcp_closed, "pass");
    let mut tcp_open = TopoProbeResult::new();
    tcp_open.scheme = "tcp".to_string();
    tcp_open.tcp_tried = true;
    tcp_open.tcp_ok = true;
    tcp_open.tcp_ms = 40;
    issues += judge_case(&mut out, "备节点端口竟然可达 → 警告", &mut make_edge("forbid", 0), tcp_open, "warn");
    let mut health_ok = make_probe(200, 120, "https");
    health_ok.body_text = "{\"service\":\"nfarmsd\",\"status\":\"ok\",\"module\":\"api\",\"version\":\"1.0.0\",\"database\":\"ok\"}".to_string();
    issues += judge_case(&mut out, "health 业务字段一致 → 通过", &mut make_edge("health", 200), health_ok, "pass");
    let mut health_bad = make_probe(200, 120, "https");
    health_bad.body_text = "{\"service\":\"nfarmsd\",\"status\":\"degraded\",\"module\":\"api\"}".to_string();
    issues += judge_case(&mut out, "health 业务字段不符 → 警告", &mut make_edge("health", 200), health_bad, "warn");
    let slow = make_probe(200, 9000, "https");
    issues += judge_case(&mut out, "耗时超阈值 → 警告", &mut make_edge("status", 200), slow, "warn");
    let mut expiring = make_probe(200, 100, "https");
    expiring.tls_days_left = 5;
    expiring.tls_expires = "2026-10-01".to_string();
    expiring.tls_subject = "CN=nfarms.cn".to_string();
    issues += judge_case(&mut out, "证书即将到期 → 警告", &mut make_edge("status", 200), expiring, "warn");
    let mut expired = make_probe(200, 100, "https");
    expired.tls_days_left = -3;
    expired.tls_expires = "2026-09-20".to_string();
    expired.tls_subject = "CN=nfarms.cn".to_string();
    issues += judge_case(&mut out, "证书已过期 → 失败", &mut make_edge("status", 200), expired, "fail");

    // 汇总口径
    let mut demo: Vec<TopoEdge> = (0..14).map(|_| TopoEdge { state: "pass", ..Default::default() }).collect();
    demo[0].state = "warn";
    demo[1].state = "warn";
    for d in demo.iter_mut().take(12).skip(2) {
        d.state = "fail";
    }
    demo[12].state = "idle";
    demo[13].state = "idle";
    let line = summary_line(&demo, server.len());
    out.push_str(&format!("  汇总行样例: {line}\r\n"));
    if !line.contains("26 条边:通过 0 / 警告 2 / 失败 10,未验证 2(另 12 条需服务端探测)") {
        out.push_str("  [FAIL] 汇总行格式与预期不符\r\n");
        issues += 1;
    }
    let demo2: Vec<TopoEdge> = (0..14)
        .map(|i| TopoEdge { state: if i < 12 { "pass" } else { "warn" }, ..Default::default() })
        .collect();
    let line2 = summary_line(&demo2, server.len());
    out.push_str(&format!("  汇总行样例: {line2}\r\n"));
    if !line2.contains("26 条边:通过 12 / 警告 2 / 失败 0(另 12 条需服务端探测)") {
        out.push_str("  [FAIL] 汇总行格式与预期不符\r\n");
        issues += 1;
    }

    if issues == 0 {
        out.push_str("  口径自检: OK(边数/编号/依据原文/三态判定/汇总行 全部符合网页版口径)\r\n");
    } else {
        out.push_str(&format!("  口径自检: {issues} 处不符\r\n"));
    }

    // 真实网络探测(可选)
    if run_network {
        out.push_str("\r\n");
        out.push_str(&format!(
            "  真实探测(本机 → 公网,超时 {}s/条,并发 {WAVE}):\r\n",
            fmt_0_dot_hash(timeout_ms as f64 / 1000.0)
        ));
        let mut live = live;
        run_all_blocking(&mut live, timeout_ms, None::<fn(&TopoEdge)>);
        for e in &live {
            out.push_str(&format!(
                "    {} [{}] {}{}\r\n",
                e.id,
                state_badge(e.state),
                e.label,
                if e.assert == "forbid" { "(期望不可达:不可达=符合预期)" } else { "" }
            ));
            out.push_str(&format!("        期望: {}\r\n", want_text(e)));
            out.push_str(&format!("        依据: {}\r\n", e.evidence));
            out.push_str(&format!("        口径: {}\r\n", e.why));
        }
        out.push_str(&format!("  汇总: {}\r\n", summary_line(&live, server.len())));
    } else {
        out.push_str("\r\n");
        out.push_str("  离线模式:未发起任何网络请求(去掉 --offline 即做真实探测)。\r\n");
        out.push_str("  判定口径(每条边):\r\n");
        for e in &live {
            out.push_str(&format!("    {} {}\r\n", e.id, e.label));
            out.push_str(&format!("        期望: {}   地址: {}\r\n", want_text(e), e.url));
        }
    }

    out.push_str("\r\n");
    out.push_str("  需服务端探测的边(12 条,不计入通过数):\r\n");
    for e in &server {
        out.push_str(&format!("    {} {} → {}\r\n", e.id, e.label, e.how));
    }

    out.push_str("\r\n");
    out.push_str(&format!("  内网端口清单({} 个,公网不可达是设计如此,需在服务器上验证):\r\n", PORTS.len()));
    for p in PORTS {
        out.push_str(&format!("    {p}\r\n"));
    }

    out.push_str("\r\n");
    out.push_str("  服务端深度探测命令:\r\n");
    for (title, cmd) in SERVER_CMDS {
        // 注意这里内层是**纯 \n**,不是 \r\n —— C# 原文写的是
        //   AppendLine("    " + title + "\n        " + cmd)
        // 只有 AppendLine 自己补的那一个换行是 CRLF。改成 \r\n 就会整段 diff。
        out.push_str(&format!("    {title}\n        {cmd}\r\n"));
    }
    out.push_str("  提示: check-pipeline.sh 的第 4/5/6 关要在备节点跑(Prometheus / Alertmanager 在备节点 43.156.170.157)。\r\n");

    out
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 离线自检的关键行必须与 C# 原版逐字一致(oracle 验收的镜像测试,
    /// 改了一个字就会在这里立刻红,而不是等跑 oracle 才暴露)。
    #[test]
    fn offline_selftest_matches_csharp_wording() {
        let out = self_test(false, TIMEOUT_MS);
        assert!(out.contains("拓扑验证器自检(--topo-test)  版本 v1.0  [离线:只校验口径定义]"));
        assert!(out.contains("  端点清单: 本机实测 14 条 + 需服务端探测 12 条 = 26 条"));
        assert!(out.contains("[OK] 门禁站点 401 → 通过 → 判定=通过"));
        assert!(out.contains("401(TLS: 取不到证书),耗时 210ms(受统一登录门禁,401 符合预期)"));
        assert!(out.contains("[OK] health 业务字段一致 → 通过 → 判定=通过"));
        assert!(out.contains("  汇总行样例: 26 条边:通过 12 / 警告 2 / 失败 0(另 12 条需服务端探测)"));
        assert!(out.contains("  口径自检: OK(边数/编号/依据原文/三态判定/汇总行 全部符合网页版口径)"));
        assert!(out.contains("  离线模式:未发起任何网络请求(去掉 --offline 即做真实探测)。"));
        assert!(out.ends_with("在备节点 43.156.170.157)。\r\n"));
    }

    #[test]
    fn edge_inventory_counts_are_fixed() {
        assert_eq!(live_edges().len(), 14);
        assert_eq!(server_edges().len(), 12);
        assert_eq!(PORTS.len(), 12);
        assert_eq!(SERVER_CMDS.len(), 8);
    }

    #[test]
    fn judge_forbid_timeout_is_pass_not_fail() {
        let mut e = make_edge("forbid", 0);
        let mut r = TopoProbeResult::new();
        r.scheme = "tcp".to_string();
        r.tcp_tried = true;
        r.tcp_ok = false;
        r.timed_out = true;
        r.tcp_ms = 5001;
        r.tcp_note = "超时(>5s)无响应,握手被丢弃".to_string();
        judge(&mut e, r);
        assert_eq!(e.state, "pass", "期望不可达的边超时 = 符合预期,不能判失败");
    }

    #[test]
    fn judge_health_json_mismatch_is_warn() {
        let mut e = make_edge("health", 200);
        let mut r = make_probe(200, 120, "https");
        r.body_text = "{\"service\":\"nfarmsd\",\"status\":\"degraded\",\"module\":\"api\"}".to_string();
        judge(&mut e, r);
        assert_eq!(e.state, "warn");
        assert!(e.evidence.contains("status=\"degraded\"≠ok"));
    }

    #[test]
    fn want_text_and_badges() {
        assert_eq!(want_text(&make_edge("forbid", 0)), "不可达(公网未开放)");
        assert_eq!(want_text(&make_edge("status", 401)), "401(受门禁,符合预期)");
        assert_eq!(state_badge("pass"), "● 通过");
        assert_eq!(state_name("unverified"), "需服务端探测");
    }

    /// 畸形 forbid 地址**不能**判成通过。
    ///
    /// 回归点:`probe_one` 原来在解析失败时返回 `TopoProbeResult::new()`
    /// (tcp_ok=false、tcp_note 空),`judge` 的 forbid 分支只认 `!tcp_ok`,
    /// 于是把写错的地址判成 `pass`,证据里还写着「TCP 不可达:」+ 一个空 note。
    /// 这几条地址全是「解析不出来」的形状,都不会真的去连(所以测试不联网)。
    #[test]
    fn forbid_edge_with_unparseable_address_is_warn_not_pass() {
        for bad in [
            "tcp://43.156.170.157",       // 漏写端口
            "tcp://43.156.170.157:",      // 端口是空串
            "tcp://43.156.170.157:abc",   // 端口不是数字
            "tcp://43.156.170.157:0",     // 0 不是可连接的目标端口
            "http://43.156.170.157:9996", // scheme 不是 tcp(原实现会照连)
            "tcp://:9996",                // 没有主机名
            "tcp://::1:9996",             // IPv6 没写方括号
        ] {
            let mut e = make_edge("forbid", 0);
            e.url = bad.to_string();
            let r = probe_one(&e, FORBID_TIMEOUT_MS);
            assert!(!r.tcp_tried, "{bad}:地址非法时不能真去连,要留成「没探成」");
            judge(&mut e, r);
            assert_eq!(e.state, "warn", "{bad}:没有任何证据,不能判通过");
            assert!(
                e.evidence.contains("未做探测"),
                "{bad}:依据要说明「为什么没有证据」,实际是 {}",
                e.evidence
            );
        }
    }

    /// 合法地址仍然要真的去探(别为了防畸形把正常路径也堵死)。
    /// 连的是本机 1 号端口:连上或拒绝都算「探过了」,不断言 tcp_ok。
    #[test]
    fn forbid_edge_with_valid_address_still_probes() {
        let mut e = make_edge("forbid", 0);
        e.url = "tcp://127.0.0.1:1".to_string();
        let r = probe_one(&e, 300);
        assert!(r.tcp_tried, "合法地址必须走真实 TCP 探测");
        assert_eq!(r.host, "127.0.0.1");
        assert_eq!(r.port, 1);
        judge(&mut e, r);
        assert!(
            e.state == "pass" || e.state == "warn",
            "判定必须落在有依据的两种里,实际 {}:{}",
            e.state,
            e.evidence
        );
    }

    /// 期望不可达的边:端口必须显式写出来;scheme 大小写不敏感。
    ///
    /// 回归点:原实现 `e.url.replacen("tcp://", "http://", 1)` 区分大小写,
    /// `TCP://…` 不会被替换,于是整段被当成主机名、端口回落 0 → 静默判 pass。
    #[test]
    fn forbid_target_needs_an_explicit_port() {
        let ok = forbid_target;
        assert_eq!(ok("tcp://43.156.170.157:9996"), Some(("43.156.170.157".to_string(), 9996)));
        assert_eq!(ok("TCP://43.156.170.157:9996"), Some(("43.156.170.157".to_string(), 9996)));
        assert_eq!(ok("tcp://[::1]:9996"), Some(("::1".to_string(), 9996)));
        assert_eq!(ok("tcp://43.156.170.157"), None);
        assert_eq!(ok("tcp://43.156.170.157:abc"), None);
        assert_eq!(ok("tcp://43.156.170.157:0"), None);
        assert_eq!(ok("https://43.156.170.157:9996"), None);
        assert_eq!(ok("tcp://::1:9996"), None);
    }

    /// URL 解析按语法边界走,不再手切字符串。
    ///
    /// 回归点:原来按 `"://"` 切一次再取 `split('/').next()`,
    /// `https://example.com?x=1` 会把 `example.com?x=1` 当主机名;
    /// `https://[::1]:9990/` 会切出 host=`"["`、端口回落 443。
    #[test]
    fn http_target_parsing_follows_url_syntax() {
        let t = |u: &str| parse_http_target(u);
        assert_eq!(t("https://nfarms.cn/"), ("https".to_string(), Some(("nfarms.cn".to_string(), 443))));
        assert_eq!(
            t("https://example.com?x=1"),
            ("https".to_string(), Some(("example.com".to_string(), 443)))
        );
        assert_eq!(
            t("https://example.com:8443/a/b?x=1#f"),
            ("https".to_string(), Some(("example.com".to_string(), 8443)))
        );
        assert_eq!(t("http://example.com"), ("http".to_string(), Some(("example.com".to_string(), 80))));
        assert_eq!(t("https://[::1]:9990/"), ("https".to_string(), Some(("::1".to_string(), 9990))));
        assert_eq!(t("https://[::1]/"), ("https".to_string(), Some(("::1".to_string(), 443))));
        // 解析不出来时不猜:调用方按「没解析出目标」处理
        assert_eq!(t("https://example.com:abc/").1, None);
        assert_eq!(t("https:///").1, None);
        assert_eq!(t("https://[::1:9990/").1, None);
    }

    /// `PORTS` 与 forbid 边是同一个 IP:端口的两处拷贝,必须逐项对得上;
    /// 边类型/状态名也必须是白名单里的字面量。哪一边被单独改掉,这里就红
    /// (self_test 也用同一份明细,所以 oracle 那关同样会拦住)。
    #[test]
    fn inventory_lists_stay_in_sync() {
        let issues = inventory_issues();
        assert!(issues.is_empty(), "清单自校验不该报错:{issues:?}");
        let entries = ports_entries();
        assert_eq!(entries.len(), PORTS.len(), "每个端口行都应能解析出 (IP, 端口)");
        let forbid: Vec<(String, u16)> = live_edges()
            .iter()
            .filter(|e| e.assert == "forbid")
            .map(|e| forbid_target(&e.url).expect("forbid 边的地址必须合法"))
            .collect();
        assert_eq!(forbid.len(), 4, "备节点公开端口就 4 个");
        for (host, port) in forbid {
            assert!(
                entries.iter().any(|(ip, p)| ip == &host && p == &port.to_string()),
                "forbid 边 {host}:{port} 不在 PORTS 清单里"
            );
        }
    }

    /// ureq 的 `impl Display for Timeout` 对 9 个变体全部写死非空标签
    /// (ureq-3.4.2/src/timings.rs:224-239),所以 `describe_ureq_error` 里
    /// 不需要「标签为空」的兜底分支。依赖哪天真给出空串,这里先红。
    #[test]
    fn timeout_error_always_carries_a_label() {
        for t in [
            ureq::Timeout::Global,
            ureq::Timeout::Connect,
            ureq::Timeout::RecvResponse,
        ] {
            let msg = describe_ureq_error(&ureq::Error::Timeout(t));
            let inner = msg
                .strip_prefix("请求超时(")
                .and_then(|s| s.strip_suffix(')'))
                .unwrap_or_else(|| panic!("超时文案的形状变了:{msg}"));
            assert!(!inner.trim().is_empty(), "超时原因不能是空串:{msg}");
        }
    }

    /// 哨兵不是天数:没取到证书信息时既不能判「已过期」,也不能落进
    /// 「14 天内到期」的警告;14 天这个边界本身也要包含在内。
    #[test]
    fn tls_sentinel_and_warn_threshold() {
        assert_eq!(TopoProbeResult::new().tls_days_left, TLS_DAYS_UNKNOWN);

        let mut e = make_edge("status", 200);
        judge(&mut e, make_probe(200, 120, "https")); // tls_days_left 保持哨兵
        assert_eq!(e.state, "pass", "没取到证书 ≠ 证书有问题:{}", e.evidence);
        assert!(!e.evidence.contains("过期"), "哨兵不能被当成过期:{}", e.evidence);
        assert!(!e.evidence.contains("到期"), "哨兵不能被当成快到期:{}", e.evidence);

        let mut warn_edge = make_edge("status", 200);
        let mut warn_probe = make_probe(200, 120, "https");
        warn_probe.tls_days_left = TLS_WARN_DAYS; // 正好 14 天:要警告
        warn_probe.tls_expires = "2026-10-30".to_string();
        warn_probe.tls_subject = "CN=nfarms.cn".to_string();
        judge(&mut warn_edge, warn_probe);
        assert_eq!(warn_edge.state, "warn", "{}", warn_edge.evidence);

        let mut ok_edge = make_edge("status", 200);
        let mut ok_probe = make_probe(200, 120, "https");
        ok_probe.tls_days_left = TLS_WARN_DAYS + 1; // 15 天:不警告
        judge(&mut ok_edge, ok_probe);
        assert_eq!(ok_edge.state, "pass", "{}", ok_edge.evidence);
    }
}