跳转至

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