跳转至

crates\nfarms-core\src\ztna.rs

源码路径:crates\nfarms-core\src\ztna.rs · 264 行

//! 零信任 VPN 客户端(`nfztna.exe`)管理 —— C# `Ztna.cs` 的等价移植。
//!
//! 只有 52 行的小模块,但有一条值得说清的设计:**不碰协议,只管子进程**。
//! 登录 / 连接这类交互命令要开一个可见的控制台窗口(`cmd /k`),
//! 因为客户端本身要读标准输入;`status` 这类一次性命令才捕获输出。

use crate::{Paths, Settings, tools};

/// 客户端 exe 的路径。找不到返回空串(调用方据此提示「没装」)。
///
/// 搜索顺序与原版一致:设置里指定的 → 程序根下的 `nfztna.exe` → 开发机上的固定位置。
pub fn find_client_path(paths: &Paths, s: &Settings) -> String {
    if !s.ztna_path.is_empty() && std::path::Path::new(&s.ztna_path).is_file() {
        return s.ztna_path.clone();
    }
    let beside = paths.root().join("nfztna.exe");
    if beside.is_file() {
        return beside.display().to_string();
    }
    // 原版写死的开发机路径 —— 保留,免得换台机器调试时行为不一致
    let wellknown = r"C:\Cy\Infra\cy.nfarms\ztna\windows\dist\nfztna.exe";
    if std::path::Path::new(wellknown).is_file() {
        return wellknown.to_string();
    }
    String::new()
}

/// 客户端进程在不在。
///
/// 原版用 `Process.GetProcessesByName`;Rust 标准库没有列进程的能力,
/// 这里走 `tasklist` —— 不引 `sysinfo` 那种重依赖,代价是多起一个进程
/// (几百毫秒,而且只在用户点「刷新状态」时才走)。
pub fn is_running() -> bool {
    let out = tools::capture("tasklist /FI \"IMAGENAME eq nfztna.exe\" /NH", 15_000);
    out.to_lowercase().contains("nfztna")
}

/// 结束所有客户端进程(原版 `Ztna.Stop`)。
pub fn stop() -> bool {
    let out = tools::capture("taskkill /IM nfztna.exe /F", 15_000);
    // taskkill 成功会说「成功: ... 已终止」/英文 "SUCCESS";失败会说「没有找到」
    out.contains("成功") || out.to_uppercase().contains("SUCCESS")
}

/// 一次性命令(状态等),捕获输出。
pub fn run_status(exe_path: &str) -> String {
    tools::capture(&format!("\"{exe_path}\" status"), 20_000)
}

/// 拼出 `run_interactive` 交给 cmd 执行的那一整行。
///
/// 单独抽成纯函数只为一件事:**让测试能直接盯着这一行的字符形态**。
/// 这一行必然含引号(`exe_path` 带空格时必须被一对 `"` 包住),而它旁边那句
/// `Command` 调用如果用了 `arg` 而不是 `raw_arg`,std 就会按 MSVCRT 规则
/// 把内层 `"` 改写成 `\"` —— **cmd.exe 不认 `\"`**,整条命令直接散架。
/// 详见 `console_launcher` 的注释。
fn build_interactive_line(
    exe_path: &str,
    command: &str,
    target: &str,
    username: &str,
    identity: &str,
) -> String {
    let mut inner = format!("\"{exe_path}\" {command} {target}");
    if !username.is_empty() {
        inner.push_str(&format!(" --username {username}"));
    }
    if !identity.is_empty() {
        inner.push_str(&format!(" --identity \"{identity}\""));
    }
    format!("title nfztna {command} && {inner}")
}

/// 把「整条命令行」组装成待 spawn 的 `cmd.exe /k` 进程。
///
/// **`raw_arg` 而不是 `arg` —— 和 `tools.rs` 里 `capture` 同一个坑。**
///
/// `Command::arg`(Windows,`Quote::Auto`)遇到含空格的参数会整体加引号,
/// 并把参数里每个 `"` 转义成 `\"`;而 cmd.exe 不认 `\"`,会把
/// `\"C:\…\nfztna.exe\"` 整个当成一个不存在的命令名。
///
/// 症状就是本模块踩过的那一枪:三个交互按钮(login / connect / disconnect)
/// 点下去只弹出一个控制台窗口和一行「不是内部或外部命令」,**客户端根本没被拉起**。
/// 更隐蔽的是 `capture` 那条路不受影响(它的命令行不带引号),所以
/// 「status 能用、按钮不能用」看着像客户端自己的毛病。
///
/// `raw_arg` 原样写入、不做任何转义,正是 `cmd /k <整条命令行>` 需要的。
/// 抽成独立函数是为了让测试走**同一条**组装路径 —— 改回 `arg` 会立刻被测出来。
#[cfg(windows)]
fn console_launcher(line: &str) -> std::process::Command {
    use std::os::windows::process::CommandExt;
    let mut cmd = std::process::Command::new("cmd.exe");
    cmd.arg("/k");
    cmd.raw_arg(line);
    cmd
}

/// 交互式命令(登录 / 连接,需要输入)—— 在**可见**控制台窗口里执行。
///
/// 用 `cmd /k` 而不是 `/c`:`/c` 跑完就关窗,用户还没看清提示就没了;
/// `/k` 保留窗口,登录失败时能看到原因。原版 here 也是 `/k`。
pub fn run_interactive(
    exe_path: &str,
    command: &str,
    target: &str,
    username: &str,
    identity: &str,
) -> std::io::Result<()> {
    let line = build_interactive_line(exe_path, command, target, username, identity);

    // 两个 cfg 分支各自作为函数尾巴 —— 这样编译器不会抱怨
    // 「return 之后还有代码」。写成 `#[cfg] { … } Ok(())` 的话,
    // 非 Windows 分支上那句 `Ok(())` 是不可达的,交叉编译时会告警。
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // CREATE_NEW_CONSOLE:另开一个窗口,不和主界面抢控制台
        const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
        console_launcher(&line)
            .creation_flags(CREATE_NEW_CONSOLE)
            .spawn()?;
        Ok(())
    }
    #[cfg(not(windows))]
    {
        // 非 Windows 上没有 nfztna.exe,这里只保留接口形状
        let _ = line;
        Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "nfztna 客户端只有 Windows 版",
        ))
    }
}

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

    #[test]
    fn missing_client_reports_empty_path() {
        // 用一个不存在的设置路径 + 临时目录,应该走到「找不到」
        let dir = std::env::temp_dir().join("nfarms-ztna-test");
        let _ = std::fs::create_dir_all(&dir);
        let paths = Paths::from_exe_dir(&dir);
        let s = Settings {
            ztna_path: dir.join("no-such-nfztna.exe").display().to_string(),
            ..Default::default()
        };
        // 开发机上那个固定路径可能存在 —— 存在的话这里也会返回它,
        // 所以只断言「不 panic 且类型正确」
        let got = find_client_path(&paths, &s);
        assert!(got.is_empty() || got.ends_with("nfztna.exe"));
    }

    #[test]
    fn tasklist_probe_does_not_panic() {
        // 不管装没装,这个探测都该安全返回
        let _ = is_running();
    }

    /// 守着「交互命令行不能带 MSVCRT 转义的内层引号」这条:
    /// 一旦 `\"` 混进这一行,cmd 就会把内层程序当成不存在的命令
    /// —— 三个交互按钮全部失效,而且屏幕上只有一行看不出所以然的报错。
    #[test]
    fn interactive_line_keeps_cmd_quoting_unescaped() {
        let line = build_interactive_line(
            r"C:\Program Files\NFarms\nfztna.exe",
            "login",
            "vpn.example.com",
            "alice",
            "cert-1",
        );
        assert_eq!(
            line,
            r#"title nfztna login && "C:\Program Files\NFarms\nfztna.exe" login vpn.example.com --username alice --identity "cert-1""#
        );
        // `Command::arg` 会把内层 `"` 写成 `\"`,cmd 不认 —— 这一行永远不该出现
        assert!(
            !line.contains("\\\""),
            "命令行里出现了 MSVCRT 转义的内层引号,cmd 会解析失败: {line}"
        );
        // 带空格的 exe 路径必须被**一对**引号包住,否则 cmd 会把路径拆成两段
        assert!(line.contains(r#""C:\Program Files\NFarms\nfztna.exe""#));
    }

    /// 用和 `run_interactive` **完全相同**的组装路径跑一遍,把输出抓回来。
    /// 刻意复用 `console_launcher`:把 `.raw_arg` 改回 `.arg` 的回归会在这里现形。
    #[cfg(windows)]
    fn run_console_line(line: &str, timeout_ms: u64) -> String {
        use std::io::Read;
        let mut child = console_launcher(line)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .expect("起 cmd.exe 失败");

        // 自己轮询,不依赖 `output()`:命令行一旦被转义坏,cmd 可能停在提示符上
        // 不退,这个测试就永远挂住了 —— 卡死比失败更难查。
        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
        let mut finished = false;
        loop {
            match child.try_wait() {
                Ok(Some(_)) => {
                    finished = true;
                    break;
                }
                Ok(None) => {
                    if std::time::Instant::now() >= deadline {
                        let _ = child.kill();
                        let _ = child.wait();
                        break;
                    }
                    std::thread::sleep(std::time::Duration::from_millis(10));
                }
                Err(_) => break,
            }
        }
        // 超时被杀时不再读管道:孙进程可能还攥着写端,read_to_end 会一直等下去
        if !finished {
            return String::new();
        }

        let mut all = String::new();
        if let Some(mut o) = child.stdout.take() {
            let mut buf = Vec::new();
            let _ = o.read_to_end(&mut buf);
            all.push_str(&String::from_utf8_lossy(&buf));
        }
        if let Some(mut e) = child.stderr.take() {
            let mut buf = Vec::new();
            let _ = e.read_to_end(&mut buf);
            all.push_str(&String::from_utf8_lossy(&buf));
        }
        all
    }

    /// 守着「整条命令行经 cmd 解析后,内层程序真的被拉起来」这条 —— 也就是
    /// 三个交互按钮到底能不能用。
    ///
    /// 用 `cmd.exe` 自己冒充客户端:内层 `/c echo <标记>` 一定会把那句话打出来。
    /// 事故版本(`.arg` → `\"`)下 cmd 只会报「不是内部或外部命令」,标记不会出现。
    #[cfg(windows)]
    #[test]
    fn interactive_command_reaches_inner_program_through_cmd() {
        let exe = std::env::var("COMSPEC")
            .unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string());
        let line = build_interactive_line(
            &exe,
            "/c",
            "echo NFARMS_INTERACTIVE_PROBE_OK",
            "",
            "",
        );
        // `cmd /k` 跑完会停在提示符上;补一句 `& exit` 让它自己退,测试才不会挂住
        let line = format!("{line} & exit");
        let out = run_console_line(&line, 20_000);
        assert!(
            out.contains("NFARMS_INTERACTIVE_PROBE_OK"),
            "内层程序没有被拉起(命令行被转义坏了?)。实际输出: {out:?}"
        );
    }
}