crates\nfarms-core\src\wireshark.rs¶
源码路径:
crates\nfarms-core\src\wireshark.rs· 547 行
//! Wireshark / dumpcap 集成 —— Zero Trust VPN › Packet capture。
//!
//! 设计上**不重新实现抓包**:Wireshark 自带的 `dumpcap` 就是干这个的,
//! 而且它处理权限(需要管理员装 Npcap 驱动)、环形缓冲、多接口这些脏活。
//! 这里只做三件事:找到它、列出网卡、把选中的网卡抓下来的包落到文件,
//! 然后交给 Wireshark 打开。
//!
//! 退出码值得单独说:dumpcap 没装 Npcap 驱动时会以 **1** 退出并打印
//! 「no interfaces found」;普通用户直接跑也常常是权限问题。
//! 这两种情况必须**分开报**,否则用户只会看到「抓包失败」四个字。
//!
//! 因此 `interfaces()` 会读 `RunResult.code` 与 stderr 来下结论
//! (见 [`diagnose_list_failure`]),识别不出来才把两条常见原因都列出来。
//!
//! 抓包是**常驻进程**,这里只负责起进程:返回的 `Child` 由调用方持有并 kill
//! (GUI 侧是 `CaptureState::stop()`)。停抓包不放在本模块,是因为它必须由
//! 持有 `Child` 的那一方做 —— 模块内再存一份句柄只会多出一份状态。
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use crate::ffmpeg::{RunResult, run};
/// 一块网卡(`dumpcap -D` 的一行)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Interface {
pub index: u32,
/// 设备名,如 `\Device\NPF_{GUID}`
pub device: String,
/// 括号里的友好名,如 `Ethernet` / `Wi-Fi`
pub description: String,
}
impl Interface {
/// 给界面显示的一行。
pub fn label(&self) -> String {
if self.description.is_empty() {
format!("{} {}", self.index, self.device)
} else {
format!("{} {}", self.index, self.description)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Wireshark {
/// 真正用来抓包的命令行工具
pub dumpcap: Option<PathBuf>,
/// 命令行分析(可选的加分项)
pub tshark: Option<PathBuf>,
/// 图形界面;抓到包之后用它打开
pub gui: Option<PathBuf>,
}
impl Wireshark {
/// 找 Wireshark。搜常见安装位置 + `PATH`。
///
/// 只找 **Wireshark**(dumpcap/tshark),不找 Npcap —— Npcap 是驱动,
/// 装没装只能由 `dumpcap -D` 自己报出来(见 [`Wireshark::interfaces`]),
/// 猜文件名猜不出来。
pub fn detect() -> Self {
let mut dirs: Vec<PathBuf> = Vec::new();
for base in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
if let Ok(p) = std::env::var(base) {
dirs.push(PathBuf::from(p).join("Wireshark"));
}
}
dirs.push(PathBuf::from(r"C:\Program Files\Wireshark"));
dirs.push(PathBuf::from(r"C:\Program Files (x86)\Wireshark"));
let mut me = Self::default();
for d in &dirs {
me.dumpcap = me.dumpcap.or_else(|| existing(d.join("dumpcap.exe")));
me.tshark = me.tshark.or_else(|| existing(d.join("tshark.exe")));
me.gui = me.gui.or_else(|| existing(d.join("Wireshark.exe")));
}
me.dumpcap = me.dumpcap.or_else(|| find_on_path("dumpcap.exe"));
me.tshark = me.tshark.or_else(|| find_on_path("tshark.exe"));
me.gui = me.gui.or_else(|| find_on_path("Wireshark.exe"));
me
}
pub fn available(&self) -> bool {
self.dumpcap.is_some()
}
pub fn status_text(&self) -> String {
match (&self.dumpcap, &self.gui) {
(Some(d), Some(g)) => format!("Wireshark 可用\ndumpcap : {}\n界面 : {}", d.display(), g.display()),
(Some(d), None) => format!(
"找到了 dumpcap({}),但没找到 Wireshark 界面 —— 能抓包,抓到之后要用别的工具打开",
d.display()
),
(None, _) => "Wireshark 未安装:装一份 Wireshark(自带 dumpcap 与 Npcap)之后这一页就能用。抓包本身是 dumpcap 在做,这里不自己实现。".to_string(),
}
}
/// 列网卡。失败时返回一句**能指向下一步**的话,而不是空列表。
pub fn interfaces(&self) -> Result<Vec<Interface>, String> {
let Some(dumpcap) = &self.dumpcap else {
return Err(self.status_text());
};
let r = run(dumpcap, &["-D".into()], 20_000);
let text = format!("{}\n{}", r.out, r.err);
let list = parse_interfaces(&text);
if list.is_empty() {
// 这里必须把 dumpcap 自己的话带出来:绝大多数失败是「没装 Npcap」
// 或者「当前用户没有抓包权限」,两者处理方式完全不同。
// 注意:用 r.code / r.err 下结论,不能把两条原因无条件一起列出来 ——
// 那样等于没诊断,用户还是得自己猜是哪一种。
let d = diagnose_list_failure(r.code, &r.err, &r.out);
let detail = if text.trim().is_empty() {
"(dumpcap 没输出任何东西)".to_string()
} else {
clip(text.trim(), 600)
};
return Err(format!(
"没读到任何网卡。dumpcap 说:\n{}\n\n{}",
detail,
d.footer()
));
}
Ok(list)
}
/// 起一个**长期运行**的抓包进程。返回的 `Child` 由调用方持有并负责 kill
/// (本模块不保存句柄,也不提供 stop —— 停抓包必须由持有 `Child` 的一方做,
/// GUI 侧是 `CaptureState::stop()`,见 `crates/nfarms-gui/src/lib.rs`)。
///
/// **诊断输出**:dumpcap 的 stderr 落到 `out` 旁边的 `<文件名>.dumpcap.log`。
/// 抓包中途失败(选错网卡、没权限)时进程会立刻退出,而界面只看 `spawn()`
/// 是否成功,所以那句报错必须留在文件里,否则用户只会看到「文件没变大」。
/// 不在这里 `try_wait()` 探活:那会把已经退出的子进程**收割掉**,
/// 调用方随后的 `kill()` + `wait()` 就可能拿到失效句柄并卡住整个界面。
///
/// 参数:
/// * `iface` —— `dumpcap -D` 里的**序号**(比设备名稳,设备名里有 GUID),
/// **从 1 开始**;`0` 表示「还没选」,直接报错而不是去跑 `dumpcap -i 0`
/// * `out` —— 落盘的 pcapng 文件(所在目录由调用方先建好)
/// * `filter` —— BPF 过滤表达式,留空表示全抓
pub fn start_capture(
&self,
iface_index: u32,
out: &Path,
filter: &str,
) -> Result<Child, String> {
let Some(dumpcap) = &self.dumpcap else {
return Err(self.status_text());
};
// 序号 0 不是合法网卡:`CaptureState::iface_index` 默认就是 0,
// 界面上没点过任何一块网卡时它会原样传下来,不拦就会真的去跑
// `dumpcap -i 0`(静默失败)。判据放在核心层,任何调用方都拦得住。
if iface_index == 0 {
return Err(
"还没选网卡 —— 先在上面「网卡」列表里点一块(序号从 1 开始)。".to_string(),
);
}
let args = capture_args(iface_index, out, filter);
let log = dumpcap_log_path(out);
let mut cmd = Command::new(dumpcap);
cmd.args(&args).stdin(Stdio::null()).stdout(Stdio::null());
// stderr 追加到日志(追加而不是截断:万一两个抓包同时开着,两条 stderr
// 都留着,总比互相截断好)。日志开不出来就退回 null:留不下诊断也比
// 抓不了包强。
let log_out = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log);
match &log_out {
Ok(f) => {
cmd.stderr(Stdio::from(f.try_clone().map_err(|e| {
format!("给 dumpcap 接 stderr 失败: {e}")
})?));
}
Err(_) => {
cmd.stderr(Stdio::null());
}
}
// CREATE_NO_WINDOW:抓包是常驻进程,别弹一个黑框出来
cmd.creation_flags_no_window();
match cmd.spawn() {
Ok(child) => Ok(child),
Err(e) => {
let mut msg = format!("起 dumpcap 失败: {e}");
if log_out.is_ok() {
msg.push_str(&format!(
"\n(上面的命令是:dumpcap {},诊断输出见 {})",
args.join(" "),
log.display()
));
}
Err(msg)
}
}
}
/// 用 Wireshark 界面打开一个 pcapng。
///
/// **故意不等待**:Wireshark 是 GUI 程序,我们只要把 `CreateProcess` 起起来
/// 就算完成;`spawn()` 返回的 `Child` 立即 drop(不等待、不 kill),
/// 所以 `Ok` 只代表"进程起得来",不代表 Wireshark 真的把文件读进去了。
/// 本函数只有界面一个调用点,它不关心 Wireshark 何时退出,因此没有返回句柄。
pub fn open_in_wireshark(&self, pcap: &Path) -> Result<(), String> {
let Some(gui) = &self.gui else {
return Err("没找到 Wireshark.exe —— 文件已经抓好了,可以用别的工具打开".to_string());
};
// 同样挂 CREATE_NO_WINDOW:否则从 GUI 里拉起 Wireshark 时会先闪一个黑框
Command::new(gui)
.arg(pcap)
.creation_flags_no_window()
.spawn()
.map_err(|e| format!("打开 Wireshark 失败: {e}"))?;
Ok(())
}
/// `tshark -r <file>` 的摘要,给界面「抓到之后先看一眼」用。没有 tshark 就返回 None。
///
/// `max_packets == 0` 表示**不限条数**:此时不加 `-c`。不这么做的话会拼出
/// `tshark -c 0`,`-c 0` 要么被当成非法参数、要么被理解成"抓 0 条",
/// 两种都不是调用方想要的"不限"。
pub fn summary(&self, pcap: &Path, max_packets: u32) -> Option<RunResult> {
let tshark = self.tshark.as_ref()?;
Some(run(tshark, &summary_args(pcap, max_packets), 60_000))
}
}
/// `dumpcap -D` 没读出网卡时,给用户看的下一步。
///
/// 原来是两条写死的原因无条件一起附上 —— 那样没装驱动和没权限对用户是同一句话,
/// 等于没诊断。这里按退出码 + stderr 内容分开报,认不出来才退化成两条都列。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ListFailure {
/// 退出码 1 且 stderr 说「no interfaces found」→ Npcap 驱动不在
NoDriver,
/// stderr 说「Access is denied」(Windows 也可能写成小写)
Denied,
/// 认不出来:退出码 / 文本都不是已知形态
Unknown,
}
/// 用 `dumpcap -D` 的退出码与输出判定失败类型。
///
/// 两个流都要看:dumpcap 的提示在某些版本上走 stdout(所以调用方是把
/// `out` 和 `err` 拼起来解析的),只看 `err` 会漏。
fn diagnose_list_failure(code: i32, err: &str, out: &str) -> ListFailure {
let err = err.to_ascii_lowercase();
let out = out.to_ascii_lowercase();
if err.contains("access is denied") || out.contains("access is denied") {
return ListFailure::Denied;
}
if err.contains("no interfaces found") || out.contains("no interfaces found") {
return ListFailure::NoDriver;
}
// 退出码 1 且没有任何可辨认文本:dumpcap 装没装 Npcap 只看得到这一句,
// 所以这种"哑失败"按最常见的驱动缺失处理。
if code == 1 && err.trim().is_empty() && out.trim().is_empty() {
return ListFailure::NoDriver;
}
ListFailure::Unknown
}
impl ListFailure {
fn footer(self) -> &'static str {
match self {
ListFailure::NoDriver => {
"判断:没装 Npcap 驱动。装一份 Npcap(重跑 Wireshark 安装程序,\
勾上 Npcap,或单独下 Npcap 安装包),装完重开本页。"
}
ListFailure::Denied => {
"判断:当前用户没有抓包权限。Npcap 安装时若选了「仅管理员可抓包」,\
就要用管理员身份运行,或把当前用户加进 Npcap 的抓包用户组后重新登录。"
}
ListFailure::Unknown => {
"常见原因:① 没装 Npcap 驱动(Wireshark 安装时会问,漏勾了就要重装);\
② 当前用户不在 Npcap 的抓包用户组里,需要管理员。"
}
}
}
}
/// `dumpcap` 抓包命令行。抽成纯函数是为了能单测参数拼装
/// (过滤为空时**不能**加 `-f ""`,否则 dumpcap 会当成非法表达式)。
fn capture_args(iface: u32, out: &Path, filter: &str) -> Vec<String> {
let mut args: Vec<String> = vec![
"-i".into(),
iface.to_string(),
"-w".into(),
out.display().to_string(),
];
if !filter.trim().is_empty() {
args.push("-f".into());
args.push(filter.trim().to_string());
}
args
}
/// dumpcap 的 stderr 落到哪:`xxx.pcapng` → `xxx.dumpcap.log`(同目录)。
/// 没有扩展名时直接加后缀,避免把文件名本身改掉。
fn dumpcap_log_path(out: &Path) -> PathBuf {
let stem = match out.file_stem() {
Some(s) if !s.is_empty() => s.to_os_string(),
_ => return out.with_extension("dumpcap.log"),
};
let mut name = stem;
name.push(".dumpcap.log");
out.with_file_name(name)
}
/// `tshark` 摘要的参数。`max_packets == 0` 表示不限条数 → 不加 `-c`。
fn summary_args(pcap: &Path, max_packets: u32) -> Vec<String> {
let mut args: Vec<String> = vec!["-r".into(), pcap.display().to_string()];
if max_packets > 0 {
args.push("-c".into());
args.push(max_packets.to_string());
}
args
}
/// `dumpcap -D` 的输出解析。
///
/// 格式:`1. \Device\NPF_{...} (Ethernet)`,描述可能没有括号。
pub fn parse_interfaces(text: &str) -> Vec<Interface> {
let mut out = Vec::new();
for line in text.lines() {
let l = line.trim();
let Some((idx_part, rest)) = l.split_once('.') else {
continue;
};
let Ok(index) = idx_part.trim().parse::<u32>() else {
continue;
};
let rest = rest.trim();
// 只认 `\Device\NPF_` 前缀:光看"数字. + 非空"会把 dumpcap 自己的告警行
// (例如 `1. no interfaces found`)也当成一块网卡,列表里于是多出一条
// 点了就抓不到任何东西的假网卡。真正的设备名一定带这个前缀。
if !rest.starts_with(r"\Device\NPF_") {
continue;
}
let (device, description) = match rest.rfind('(') {
Some(p) if rest.ends_with(')') => (
rest[..p].trim().to_string(),
rest[p + 1..rest.len() - 1].trim().to_string(),
),
_ => (rest.to_string(), String::new()),
};
out.push(Interface {
index,
device,
description,
});
}
out
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect::<String>() + "…"
}
}
fn existing(p: PathBuf) -> Option<PathBuf> {
if p.is_file() { Some(p) } else { None }
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.filter(|d| !d.as_os_str().is_empty())
.map(|d| d.join(name))
.find(|c| c.is_file())
}
/// `CREATE_NO_WINDOW` 的小封装 —— 只有 Windows 有这个东西。
///
/// 非 Windows 目标(工作区里的 `nfarms-mobile`)上它是个**显式的空操作**:
/// 以前是同一个函数体里 `#[cfg(windows)]` 一段 + 一句 `self`,非 Windows 编译
/// 出来就是静默什么都不做,读代码的人会以为窗口被藏住了。分成两个 impl 之后,
/// "本平台没有这个能力"在编译单元里写得明明白白。
trait NoWindow {
fn creation_flags_no_window(&mut self) -> &mut Self;
}
#[cfg(windows)]
impl NoWindow for Command {
fn creation_flags_no_window(&mut self) -> &mut Self {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
self.creation_flags(CREATE_NO_WINDOW);
self
}
}
#[cfg(not(windows))]
impl NoWindow for Command {
fn creation_flags_no_window(&mut self) -> &mut Self {
// 非 Windows 没有 CREATE_NO_WINDOW;这里不需要它 —— 那种平台上
// dumpcap 不存在(`detect()` 返回 None),调用点根本到不了。
self
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 把 `["-i", "2"]` 变成 `vec!["-i".to_string(), "2".to_string()]`,
/// 省得每条断言后面挂一串 `.to_string()`。
fn sv(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_dumpcap_interface_list() {
let text = "1. \\Device\\NPF_{AAAA-BBBB} (Ethernet)\n\
2. \\Device\\NPF_{CCCC-DDDD} (Wi-Fi)\n\
3. \\Device\\NPF_{EEEE-FFFF}\n";
let got = parse_interfaces(text);
assert_eq!(got.len(), 3);
assert_eq!(got[0].index, 1);
assert_eq!(got[0].device, "\\Device\\NPF_{AAAA-BBBB}");
assert_eq!(got[0].description, "Ethernet");
assert_eq!(got[1].label(), "2 Wi-Fi");
assert_eq!(got[2].description, "", "没有括号时描述留空,不能把设备名吞掉");
assert_eq!(got[2].device, "\\Device\\NPF_{EEEE-FFFF}");
}
#[test]
fn ignores_garbage_lines() {
assert!(parse_interfaces("no interfaces found\n\n").is_empty());
assert!(parse_interfaces("").is_empty());
}
/// 复发点:以「数字.」开头的告警行以前会被当成一块网卡。
#[test]
fn numeric_warning_lines_are_not_interfaces() {
let text = "1. no interfaces found\n\
2. Access is denied\n\
3. \\Device\\NPF_{XXXX-YYYY} (Ethernet)\n";
let got = parse_interfaces(text);
assert_eq!(got.len(), 1, "只有带 \\Device\\NPF_ 前缀的那行才算网卡");
assert_eq!(got[0].index, 3);
assert_eq!(got[0].device, "\\Device\\NPF_{XXXX-YYYY}");
}
/// 复发点:退出码/stderr 以前完全没被读,两种原因无条件一起报。
#[test]
fn list_failure_reasons_are_told_apart() {
assert_eq!(
diagnose_list_failure(1, "dumpcap: no interfaces found\r\n", ""),
ListFailure::NoDriver
);
assert_eq!(
diagnose_list_failure(1, "dumpcap: Access is denied. (code 5)", ""),
ListFailure::Denied
);
// 权限提示走 stdout 的版本也要认出来
assert_eq!(
diagnose_list_failure(1, "", "Error: access is denied"),
ListFailure::Denied
);
// 哑失败(退出码 1 且一句话没有)= 最常见的是驱动没装
assert_eq!(diagnose_list_failure(1, "", ""), ListFailure::NoDriver);
// 认不出来时才把两条都列出来,而且不能谎报成某一种
assert_eq!(
diagnose_list_failure(0, "some other complaint", ""),
ListFailure::Unknown
);
let unknown = ListFailure::Unknown.footer();
assert!(unknown.contains("没装 Npcap") && unknown.contains("抓包用户组"));
assert_ne!(
ListFailure::NoDriver.footer(),
ListFailure::Denied.footer(),
"两种原因必须给出不同的下一步"
);
}
/// 复发点:没选网卡时 `iface_index` 默认 0,以前会真的去跑 `dumpcap -i 0`。
#[test]
fn start_capture_rejects_unselected_interface() {
let ws = Wireshark {
dumpcap: Some(PathBuf::from(r"C:\nope\dumpcap.exe")),
..Default::default()
};
let err = ws
.start_capture(0, Path::new("cap.pcapng"), "")
.expect_err("序号 0 必须在起进程之前就被拒掉");
assert!(err.contains("还没选网卡"), "错误要说清下一步: {err}");
}
/// 复发点:`-f` 以前无条件跟着过滤串,空过滤会拼出 `-f ""`。
#[test]
fn capture_args_shape() {
let out = Path::new(r"C:\tmp\cap.pcapng");
assert_eq!(
capture_args(2, out, " "),
sv(&["-i", "2", "-w", r"C:\tmp\cap.pcapng"]),
"过滤为空(含全空白)不能加 -f"
);
assert_eq!(
capture_args(2, out, " tcp port 443 "),
sv(&["-i", "2", "-w", r"C:\tmp\cap.pcapng", "-f", "tcp port 443"]),
"过滤串要去掉首尾空白,不能原样塞给 dumpcap"
);
}
/// 复发点:抓包中途失败时 stderr 被丢进 null,用户看不到原因。
#[test]
fn capture_diagnostics_get_a_sibling_log_file() {
assert_eq!(
dumpcap_log_path(Path::new(r"C:\tmp\cap.pcapng")),
PathBuf::from(r"C:\tmp\cap.dumpcap.log")
);
assert_eq!(
dumpcap_log_path(Path::new(r"C:\tmp\cap")),
PathBuf::from(r"C:\tmp\cap.dumpcap.log"),
"没有扩展名时只加后缀,不能把文件名本身改掉"
);
}
/// 复发点:`max_packets == 0` 以前会拼出 `tshark -c 0`。
#[test]
fn summary_args_zero_means_no_limit() {
let p = Path::new(r"C:\tmp\cap.pcapng");
assert_eq!(
summary_args(p, 0),
sv(&["-r", r"C:\tmp\cap.pcapng"]),
"0 表示不限条数 → 不能出现 -c"
);
assert_eq!(
summary_args(p, 25),
sv(&["-r", r"C:\tmp\cap.pcapng", "-c", "25"])
);
}
#[test]
fn missing_wireshark_says_what_to_install() {
let ws = Wireshark::default();
assert!(!ws.available());
assert!(ws.status_text().contains("未安装"));
assert!(ws.interfaces().is_err());
}
}