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);
}
}