从零实现一款 Windows 下的 SSH 批量运维工具:LinuxSshTools 技术详解

从零实现一款 Windows 下的 SSH 批量运维工具:LinuxSshTools 技术详解

摘要:本文介绍一款基于 C# .NET Framework 4.8 + WinForms + SSH.NET 实现的 Windows 端 SSH 批量运维工具。该工具以 JSON 配置文件驱动,支持批量并发执行命令、SFTP 文件上下传、Shell 脚本远程执行、变量模板替换等能力,解决了工业场景中向数十台乃至上百台 Linux 服务器批量部署和运维的问题。

📦 开源地址https://github.com/PascalMing/LinuxSshTools


目录


一、背景与动机

在工业互联网和企业 IT 运维场景中,工程师常常面临如下痛点:

  1. 批量设备运维:数十台甚至上百台 Linux 服务器同时需要配置变更、软件升级、时间同步等操作,逐台手工操作效率极低。
  2. 持续集成部署:开发机是 Windows,目标服务器是 Linux,每次改动后需要将构建产物(JAR 包、前端 dist 等)推送到服务器并重启服务。
  3. 临时脚本执行:针对特定场景需要批量执行一段 Shell 脚本,不想每次都手动 SSH 上去敲命令。
  4. 现有工具笨重:Ansible 等工具功能强大但学习曲线陡峭;PuTTY/XShell 缺乏批量编排能力;Python 脚本在 Windows 上环境配置繁琐。

基于此,LinuxSshTools 的设计目标是:

  • 轻量:单一 EXE,无需安装,双击即用。
  • 配置驱动:所有操作写在 JSON 配置文件中,切换场景只需换一个 JSON 文件。
  • 可视化:WinForms 界面,实时看到每台机器的执行结果。
  • 并发:勾选一个复选框即可从串行切换到并发模式。

二、技术选型

层面 选型 说明
开发语言 C# (.NET Framework 4.8) Windows 原生,打包为单 EXE,部署简单
UI 框架 Windows Forms (WinForms) 成熟稳定,快速开发可视化界面
SSH/SFTP 客户端库 SSH.NET (Renci.SshNet) 2024.2.0 纯托管代码,功能完善,支持 SSH2 全特性
加密支持 BouncyCastle.Cryptography 2.4.0 SSH.NET 的加密依赖,支持更多密钥类型
JSON 解析 Newtonsoft.Json 13.0.3 功能丰富,容错性强
并发模型 Task.Run + Interlocked TPL 任务并行,原子计数追踪进度
配置格式 JSON(类 JSONC,支持注释) 直观易读,非技术人员也能上手修改

为什么用 .NET Framework 4.8 而非 .NET 8?

.NET Framework 4.8 是 Windows 内置的,目标机器上不需要额外安装运行时。.NET 8 的 AOT 编译虽然也能做到类似效果,但对于此类工具软件,net48 发布的单 EXE 依赖更少、兼容性更好。


三、整体架构设计

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                        Form1 (UI 线程)                           │
│                                                                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────┐   │
│  │ 加载配置  │  │ 单机执行  │  │ 批量执行  │  │  清除日志    │   │
│  │ button1  │  │ button2  │  │ button3  │  │   button4    │   │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └──────────────┘   │
│       │              │              │                             │
│  LoadConfig()   Task.Run()    foreach Task.Run()                 │
│       │              │              │                             │
│  listBox1        (串行/并发)    (串行/并发)                       │
│  comboBox1              \          /                              │
│                          \        /                               │
└───────────────────────────\──────/──────────────────────────────┘
                              \    /
                         ┌─────────────┐
                         │  SshExec()  │  (原子计数 + 异常包装)
                         └──────┬──────┘
                                │
                         ┌──────▼──────┐
                         │  _SshExec() │  (核心逻辑)
                         └──────┬──────┘
                                │
              ┌─────────────────┼───────────────────┐
              │                 │                   │
       ┌──────▼───────┐  ┌──────▼──────┐  ┌────────▼──────┐
       │  SftpClient  │  │  SshClient  │  │  SftpClient   │
       │  (文件上传)   │  │  (命令执行)  │  │  (文件下载)    │
       └──────────────┘  └─────────────┘  └───────────────┘
              │                 │                   │
       ftpUp/           cmds/shellScript         ftpDown
       shellScript 上传      执行                下载

项目文件结构如下:

复制代码
LinuxSshTools/
├── LinuxSshTools.sln          # Visual Studio 解决方案
├── LinuxSshTools.csproj       # 项目文件(依赖定义)
├── Program.cs                 # 入口点
├── AppConfig.cs               # 配置数据模型(POCO)
├── Form1.cs                   # 主窗体 + 全部业务逻辑
├── Form1.Designer.cs          # UI 布局(设计器生成)
├── App.config                 # 运行时配置(指定 .NET 4.8)
├── jsconfig.json              # 默认配置文件(可替换)
├── jsconfig - *.json          # 各场景配置文件
├── upload/                    # 待上传文件存放目录
│   ├── myScript.sh            # Shell 脚本示例
│   ├── ntp.conf               # NTP 客户端配置示例
│   └── timesyncd.conf         # systemd 时间同步配置示例
└── download/                  # 远程文件下载目录

四、核心数据模型:AppConfig

AppConfig.cs 是整个系统的"骨架",定义了 JSON 配置文件的数据结构:

csharp 复制代码
/// <summary>
/// JSON 配置文件的数据模型(POCO 类),由 Newtonsoft.Json 反序列化填充。
/// 对应 jsconfig.json 的根对象结构。
/// </summary>
public class AppConfig
{
    /// <summary>
    /// 变量替换字典。
    /// key   = 变量名(不含 %),在配置中以 %key% 形式引用
    /// value = 替换值
    /// 内置变量:%HOST% = 当前执行的目标主机 IP(无需在此定义)
    /// 特殊 key:sshUser / sshPassword / sshPort / scriptPath 会被直接读取为连接参数
    /// </summary>
    public Dictionary<string, string> variables;

    /// <summary>
    /// 本地 → 远程的文件/目录上传映射。
    /// key   = 本地路径(支持:绝对路径、相对于 upload/ 的相对路径、通配符、整个目录)
    /// value = 远程目标路径(以 / 结尾表示目录,否则表示目标文件名)
    /// </summary>
    public Dictionary<string, string> ftpUp;

    /// <summary>
    /// 要上传并执行的 Shell 脚本文件名(不含路径,在 upload/ 目录下查找)。
    /// 脚本会被上传到 scriptPath 目录,然后用 bash 执行。
    /// </summary>
    public string shellScript;

    /// <summary>
    /// 要依次执行的 SSH 命令数组。
    /// 以 "sudo" 开头的命令会自动转换为管道密码注入形式:echo {password} | sudo -S {cmd}
    /// </summary>
    public string[] cmds;

    /// <summary>
    /// 远程 → 本地的文件下载映射。
    /// key   = 下载后保存到 download/ 目录下的相对路径
    /// value = 远程源文件完整路径
    /// </summary>
    public Dictionary<string, string> ftpDown;

    /// <summary>
    /// 目标主机 IP 地址数组,批量执行时遍历此列表。
    /// </summary>
    public string[] hosts;
}

设计亮点 :所有字段均为可选,不填写的字段对应功能步骤会被跳过(null 判断),使得配置文件按需填写即可,非常灵活。


五、JSON 配置文件详解

配置文件采用 JSON 格式,且支持类似 JSONC 的行注释风格(需在加载时预处理过滤 // 注释行)。

5.1 最简配置:执行单条命令

json 复制代码
{
  "variables": {
    "sshUser": "ubuntu",
    "sshPassword": "your_password_here",
    "scriptPath": "/home/ubuntu"
  },
  "cmds": [
    "date",
    "df -h",
    "free -m"
  ],
  "hosts": ["192.168.1.100"]
}

5.2 文件上传 + 命令执行(部署场景)

json 复制代码
{
  "variables": {
    "sshUser": "deploy_user",
    "sshPassword": "your_password",
    "scriptPath": "/home/deploy_user"
  },
  "ftpUp": {
    "D:\\build\\output\\app.jar": "/opt/myapp/app.jar",
    "D:\\build\\output\\web\\dist": "/opt/myapp/nginx/html/web/"
  },
  "cmds": [
    "sudo docker compose -f /opt/myapp/docker-compose.yml restart",
    "sudo docker compose -f /opt/nginx/docker-compose.yml restart"
  ],
  "hosts": [
    "192.168.1.100",
    "192.168.1.101"
  ]
}

说明ftpUp 的 key 为目录时(dist),工具会递归上传整个目录结构,保持子目录层级不变。

5.3 Shell 脚本远程执行

json 复制代码
{
  "variables": {
    "sshUser": "root",
    "sshPassword": "your_password",
    "scriptPath": "/tmp/scripts"
  },
  "shellScript": "setup_env.sh",
  "hosts": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
}

说明 :工具会先将 upload/setup_env.sh 上传至远程 /tmp/scripts/setup_env.sh,再执行 bash /tmp/scripts/setup_env.sh

5.4 批量时间同步(百台设备)

json 复制代码
{
  "variables": {
    "sshUser": "root",
    "sshPassword": "your_password"
  },
  "ftpUp": {
    "ntp.conf": "/etc/ntp.conf"
  },
  "cmds": [
    "sudo systemctl stop ntp",
    "sudo ntpdate -u ntp_server_ip",
    "sudo systemctl start ntp",
    "sudo systemctl enable ntp",
    "date"
  ],
  "hosts": [
    "10.0.1.1", "10.0.1.2", "10.0.1.3",
    "10.0.2.1", "10.0.2.2",
    "..."
  ]
}

5.5 文件下载

json 复制代码
{
  "variables": {
    "sshUser": "root",
    "sshPassword": "your_password"
  },
  "ftpDown": {
    "logs/app.log": "/opt/myapp/logs/app.log",
    "configs/server.xml": "/etc/myapp/server.xml"
  },
  "hosts": ["192.168.1.100"]
}

下载后文件保存在本地 download/logs/app.logdownload/configs/server.xml

5.6 变量引用示例

json 复制代码
{
  "variables": {
    "sshUser": "deploy",
    "sshPassword": "pass",
    "appVersion": "2.1.0",
    "deployPath": "/opt/myapp",
    "scriptPath": "/tmp"
  },
  "ftpUp": {
    "D:\\build\\app-%appVersion%.jar": "%deployPath%/app-%appVersion%.jar"
  },
  "cmds": [
    "sudo systemctl stop myapp",
    "sudo cp %deployPath%/app-%appVersion%.jar %deployPath%/app.jar",
    "sudo systemctl start myapp",
    "echo Deployed %appVersion% to %HOST%"
  ],
  "hosts": ["server-01", "server-02"]
}

内置变量 %HOST% :在命令和路径中使用 %HOST%,运行时自动替换为当前处理的主机 IP/域名。这在需要按主机生成不同日志名或路径时特别有用。


六、核心执行流程:_SshExec

_SshExec 方法是整个工具最核心的部分,约 210 行,完整实现了一次对单台主机的操作流程:

复制代码
输入: ubuntuHost (目标主机 IP)

步骤 1: 变量替换 + 反序列化
├── 将配置 JSON 文本中的 %HOST% 替换为 ubuntuHost
└── 再次处理 variables 字典,将所有 %KEY% 替换为对应值
    └── 重新 JSON 反序列化,得到最终 AppConfig 对象

步骤 2: 建立 SSH 连接
├── new SshClient(host, port, user, password)
├── client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(OperationTimeout)
└── client.Connect()

步骤 3: SFTP 上传(若 ftpUp 或 shellScript 不为空)
├── new SftpClient(host, port, user, password)
├── sftpClient.Connect()
├── 处理 ftpUp(见第九节详述)
│   ├── 通配符文件匹配
│   ├── 整目录上传
│   └── 单文件上传
├── 处理 shellScript
│   ├── sftpClient.UploadFile(stream, remotePath)
│   └── sshClient.RunCommand($"bash {scriptPath}/{shellScript}")
└── sftpClient.Disconnect()

步骤 4: 命令执行(若 cmds 不为空)
├── foreach cmd in cmds:
│   ├── sudo 命令自动添加管道密码:echo {pwd} | sudo -S {cmd}
│   ├── sshClient.RunCommand(execCmd)
│   ├── 输出 cmd.Result 到 UI
│   └── Thread.Sleep(100)  // 命令间隔,避免过快
└── sshClient.Disconnect()

步骤 5: SFTP 下载(若 ftpDown 不为空)
├── new SftpClient(host, port, user, password)
├── sftpClient.Connect()
├── foreach kv in ftpDown:
│   ├── 确保本地目录存在(Directory.CreateDirectory)
│   └── sftpClient.DownloadFile(remotePath, localStream)
└── sftpClient.Disconnect()

输出: 实时更新 listBox2 和 label3 (via BeginInvoke)

以下是核心代码骨架:

csharp 复制代码
private void _SshExec(string ubuntuHost, int idx = 0,
                       bool showIdx = false, bool isParallel = false)
{
    // ── 1. 变量替换 ──────────────────────────────────────────────
    string appText1 = appConfigText.Replace("%HOST%", ubuntuHost);
    AppConfig appConfig1 = JsonConvert.DeserializeObject<AppConfig>(appText1);

    string appText2 = appText1;
    if (appConfig1.variables != null)
    {
        foreach (var kv in appConfig1.variables)
            appText2 = appText2.Replace($"%{kv.Key}%", kv.Value);
    }
    AppConfig appConfig = JsonConvert.DeserializeObject<AppConfig>(appText2);

    // ── 2. SSH 连接 ───────────────────────────────────────────────
    using var client = new SshClient(ubuntuHost, ubuntuPort, ubuntuUser, ubuntuPassword);
    client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(OperationTimeout);
    client.Connect();

    // ── 3. SFTP 上传 ──────────────────────────────────────────────
    if (appConfig.ftpUp != null || appConfig.shellScript != null)
    {
        using var sftp = new SftpClient(ubuntuHost, ubuntuPort, ubuntuUser, ubuntuPassword);
        sftp.Connect();
        // ... 上传逻辑 ...
        sftp.Disconnect();
    }

    // ── 4. 命令执行 ───────────────────────────────────────────────
    if (appConfig.cmds != null)
    {
        foreach (var cmdText in appConfig.cmds)
        {
            string execCmd = cmdText.StartsWith("sudo")
                ? $"echo {ubuntuPassword} | sudo -S " + cmdText.Substring(4)
                : cmdText;

            var cmd = client.RunCommand(execCmd);
            UpdateListBoxResult(cmd.Result);
            Thread.Sleep(100);
        }
    }

    // ── 5. SFTP 下载 ──────────────────────────────────────────────
    if (appConfig.ftpDown != null)
    {
        using var sftp = new SftpClient(ubuntuHost, ubuntuPort, ubuntuUser, ubuntuPassword);
        sftp.Connect();
        foreach (var kv in appConfig.ftpDown)
        {
            string localFile = Path.Combine(downloadPath, kv.Key);
            Directory.CreateDirectory(Path.GetDirectoryName(localFile));
            using var fs = File.Create(localFile);
            sftp.DownloadFile(kv.Value, fs);
        }
        sftp.Disconnect();
    }

    client.Disconnect();
}

七、并发模型设计

7.1 串行 vs 并发模式

工具通过一个 CheckBox("批量并发")切换串行/并发两种执行模式:

csharp 复制代码
// 批量执行按钮点击事件
private void button3_Click(object sender, EventArgs e)
{
    // 防重入:上次未完成时拒绝新的批量任务
    if (dwTaskStart != dwTaskEnd)
    {
        MessageBox.Show("上次执行未完成,请等待...");
        return;
    }

    bool isParallel = checkBox1.Checked; // 是否并发
    int totalExecCount = 0;

    foreach (var item in comboBox1.Items)
    {
        string host = item.ToString();
        int idx = totalExecCount++;
        Task task = Task.Run(() => SshExec(host, idx, true, isParallel));

        if (!isParallel)
            task.Wait(); // 串行:等待当前主机完成再处理下一台
        // 并发:不 Wait,所有主机几乎同时启动
    }
}
模式 特点 适用场景
串行(默认) 一台一台地等待完成,日志顺序清晰 调试、小批量、需要关注每台结果
并发(勾选) 所有主机同时开始,整体耗时大幅缩短 大批量操作(百台时间同步等)

7.2 原子计数 + 进度追踪

csharp 复制代码
private int dwTaskStart = 0; // 已发起任务数(Interlocked 原子操作)
private int dwTaskEnd   = 0; // 已完成任务数(Interlocked 原子操作)

private void SshExec(string ubuntuHost, int idx = 0,
                      bool showIdx = false, bool isParallel = false)
{
    Interlocked.Increment(ref dwTaskStart);
    UpdateExecStat($"任务数:{dwTaskStart}, 完成数:{dwTaskEnd}");
    try
    {
        _SshExec(ubuntuHost, idx, showIdx, isParallel);
    }
    catch (Exception ex)
    {
        UpdateListBoxResult($"[{ubuntuHost}] 执行异常: {ex.Message}");
    }
    finally
    {
        Interlocked.Increment(ref dwTaskEnd);
        UpdateExecStat($"任务数:{dwTaskStart}, 完成数:{dwTaskEnd}");
    }
}

Interlocked.Increment 是无锁原子操作,多线程并发更新计数时不需要加 lock,性能更好、避免死锁。

7.3 并发时的线程池行为

Task.Run 使用 .NET 线程池,对于 SSH 这类 I/O 密集型任务,线程池会根据 I/O 完成情况动态调整活跃线程数。100 台机器并发时:

  • 每台机器对应一个工作线程(来自线程池)
  • 每个线程独占一个 SshClient + SftpClient 连接(无共享资源)
  • 唯一的共享资源是 listBox2,通过 BeginInvoke 安全序列化到 UI 线程

八、变量替换机制

变量替换是配置文件"参数化"的核心,采用 %变量名% 格式,执行两轮替换:

复制代码
原始 JSON 文本 (appConfigText)
        │
        ▼
【第一轮】替换内置变量 %HOST%
        │  appText1 = appConfigText.Replace("%HOST%", ubuntuHost)
        │  临时反序列化,读取 variables 字典
        ▼
appText1 + AppConfig1 (仅用于读 variables)
        │
        ▼
【第二轮】替换用户自定义变量
        │  foreach kv in appConfig1.variables:
        │      appText2 = appText1.Replace($"%{kv.Key}%", kv.Value)
        ▼
appText2 (最终 JSON 文本)
        │
        ▼
【最终反序列化】
        appConfig = JsonConvert.DeserializeObject<AppConfig>(appText2)

为什么要做两轮替换而非一轮?

因为 %HOST% 在 JSON 文本层面替换,而 variables 字典本身也可能包含对 %HOST% 的引用,分两轮可以保证 %HOST% 先被展开,再由用户变量二次处理。

实际示例

json 复制代码
// 配置文件中
{
  "variables": {
    "logFile": "log_%HOST%.txt",
    "deployPath": "/opt/app"
  },
  "cmds": ["cat %deployPath%/%logFile%"]
}

// 对主机 10.0.0.5 执行时:
// 第一轮:%HOST% → 10.0.0.5
//   logFile = "log_10.0.0.5.txt"
// 第二轮:%deployPath% → /opt/app,%logFile% → log_10.0.0.5.txt
// 最终命令:cat /opt/app/log_10.0.0.5.txt

九、文件上传路径处理

ftpUp 字段支持四种本地路径形式,上传逻辑需逐一处理:

复制代码
ftpUp 的 key 形式:
┌────────────────────────────────────┬──────────────────────────────────────────┐
│ 形式                                │ 示例                                      │
├────────────────────────────────────┼──────────────────────────────────────────┤
│ 绝对路径 + 通配符                    │ D:\build\*.jar                           │
│ 绝对路径 + 整个目录                  │ D:\frontend\dist                         │
│ 绝对路径 + 单文件                    │ D:\config\app.properties                 │
│ 相对路径(相对 upload/)             │ ntp.conf                                 │
└────────────────────────────────────┴──────────────────────────────────────────┘

核心处理逻辑(简化版):

csharp 复制代码
foreach (var kv in appConfig.ftpUp)
{
    string localKey = SanitizeJsonPath(kv.Key);   // 清理 JSON 转义字符
    string remoteBase = kv.Value;
    
    List<string> filesToUpload = new List<string>();
    string baseLocal = "";

    bool hasWildcard = localKey.Contains("*") || localKey.Contains("?");
    
    if (hasWildcard)
    {
        // 通配符:提取目录和 pattern,递归搜索
        string dir = Path.GetDirectoryName(localKey);
        string pattern = Path.GetFileName(localKey);
        filesToUpload.AddRange(Directory.GetFiles(dir, pattern,
                                                   SearchOption.AllDirectories));
        baseLocal = dir + Path.DirectorySeparatorChar;
    }
    else if (Directory.Exists(localKey))
    {
        // 整个目录:递归获取所有文件
        filesToUpload.AddRange(Directory.GetFiles(localKey, "*",
                                                   SearchOption.AllDirectories));
        baseLocal = localKey + Path.DirectorySeparatorChar;
    }
    else
    {
        // 单文件:若非绝对路径,拼接 uploadPath
        string fullPath = Path.IsPathRooted(localKey)
            ? localKey
            : Path.Combine(uploadPath, localKey);
        filesToUpload.Add(fullPath);
        baseLocal = Path.GetDirectoryName(fullPath) + Path.DirectorySeparatorChar;
    }

    // 上传每个文件
    foreach (string localFile in filesToUpload)
    {
        // 计算远程目标路径
        string relativePath = "";
        if (filesToUpload.Count > 1 || !remoteBase.EndsWith("/") == false)
        {
            // 保留相对目录结构
            relativePath = localFile.Substring(baseLocal.Length)
                                    .Replace('\\', '/');
        }
        string remoteTarget = remoteBase.TrimEnd('/') + "/" + relativePath;
        remoteTarget = remoteTarget.TrimEnd('/');

        // 递归创建远程目录
        EnsureRemoteDirectory(sftp, Path.GetDirectoryName(remoteTarget).Replace('\\', '/'));

        // 执行上传
        using var fs = File.OpenRead(localFile);
        sftp.UploadFile(fs, remoteTarget);
        UpdateListBoxResult($"已上传: {localFile} → {remoteTarget}");
    }
}

// 递归创建远程目录
private void EnsureRemoteDirectory(SftpClient sftp, string remotePath)
{
    string[] parts = remotePath.Split('/');
    string cur = "";
    foreach (string part in parts)
    {
        if (string.IsNullOrEmpty(part)) continue;
        cur += "/" + part;
        try
        {
            if (!sftp.Exists(cur))
                sftp.CreateDirectory(cur);
        }
        catch { /* 目录可能已存在,忽略异常 */ }
    }
}

十、sudo 密码自动注入

Linux 系统的 sudo 默认需要交互式密码输入,但 SSH 程序化执行不支持 TTY 交互。解决方案是利用 sudo -S 选项------它会从标准输入(stdin)读取密码:

csharp 复制代码
// 原始命令
string cmdText = "sudo systemctl restart nginx";

// 自动转换
string execCmd = cmdText.StartsWith("sudo")
    ? $"echo {ubuntuPassword} | sudo -S " + cmdText.Substring(4)
    : cmdText;

// 最终执行的命令
// echo your_password | sudo -S  systemctl restart nginx

sudo -S 的工作原理:

复制代码
用户空间               内核空间
────────────────────   ──────────────────
echo your_password  →  管道(pipe)  →  sudo -S 从 stdin 读取密码
                                       ↓
                                    PAM 验证密码
                                       ↓
                                    以 root 身份执行命令

⚠️ 安全注意 :此方式会将密码明文出现在进程命令行中,在多用户共享系统上存在泄露风险(其他用户可通过 ps aux 看到)。该方法适用于受控的内网自动化运维环境。生产环境建议改用 SSH 密钥认证 + sudoers 文件配置无密码 sudo。


十一、跨线程 UI 安全更新

WinForms 严格要求所有 UI 控件操作必须在 UI 线程(主线程)执行。工作线程(Task.Run)中更新 listBox2 等控件时,必须通过 Control.Invoke / Control.BeginInvoke 切换到 UI 线程:

csharp 复制代码
// 通用的线程安全 ListBox 更新方法
private void _UpdateListBox(ListBox lb, string text)
{
    if (lb.InvokeRequired)
    {
        // 当前不在 UI 线程,BeginInvoke 异步切换到 UI 线程执行
        lb.BeginInvoke(new Action(() => _UpdateListBox(lb, text)));
    }
    else
    {
        // 已在 UI 线程,直接操作
        lb.Items.Add(text);
        // 自动滚动到最新一条
        lb.SelectedIndex = lb.Items.Count - 1;
        lb.SelectedIndex = -1;
    }
}

// 批量更新(按换行符拆分)
private void UpdateListBoxResult(string result)
{
    foreach (string line in result.Split('\n'))
    {
        string trimmed = line.Trim('\r', '\n', ' ');
        if (!string.IsNullOrEmpty(trimmed))
            _UpdateListBox(listBox2, trimmed);
    }
}

// 更新状态 label
private void UpdateExecStat(string text)
{
    if (label3.InvokeRequired)
        label3.BeginInvoke(new Action(() => label3.Text = text));
    else
        label3.Text = text;
}

BeginInvoke vs Invoke 的选择:

  • Invoke:同步,工作线程会阻塞等待 UI 线程处理完毕
  • BeginInvoke:异步,工作线程不等待,发送消息后立即继续

对于日志输出这类非关键 UI 更新,选择 BeginInvoke 不会阻塞工作线程,并发模式下性能更好。


十二、实际使用场景

场景一:物联网设备批量时间同步

问题:工厂内部署了约 100 台运行 Ubuntu/OpenEuler 的工业控制设备,因断网维护后系统时间漂移,导致日志时间戳错乱,需要统一同步到内网 NTP 服务器。

配置方案

json 复制代码
{
  "variables": {
    "sshUser": "root",
    "sshPassword": "device_password",
    "ntpServer": "10.0.0.1"
  },
  "ftpUp": {
    "ntp.conf": "/etc/ntp.conf"
  },
  "cmds": [
    "sudo systemctl stop ntp",
    "sudo ntpdate -u %ntpServer%",
    "sudo systemctl start ntp",
    "sudo systemctl enable ntp",
    "date"
  ],
  "hosts": [
    "10.0.1.1", "10.0.1.2", "10.0.1.3", "..."
  ]
}

效果:勾选"批量并发",约 100 台设备同时开始操作,总耗时从串行的 30+ 分钟缩短至 2-3 分钟(受限于网络延迟和设备响应速度)。


场景二:Java + Nginx 持续部署

问题 :每次后端或前端代码修改后,需要将 Maven 构建的 JAR 包和 npm build 生成的 dist 目录部署到测试/生产服务器,并重启 Docker Compose 中的服务。

配置方案

json 复制代码
{
  "variables": {
    "sshUser": "deploy",
    "sshPassword": "deploy_pass",
    "appVersion": "1.0.0",
    "deployRoot": "/opt/myapp"
  },
  "ftpUp": {
    "D:\\projects\\backend\\target\\app-%appVersion%.jar":
      "%deployRoot%/app.jar",
    "D:\\projects\\frontend\\dist":
      "%deployRoot%/nginx/html/"
  },
  "cmds": [
    "sudo docker compose -f %deployRoot%/docker-compose.yml restart backend",
    "sudo docker compose -f %deployRoot%/docker-compose.yml restart nginx",
    "sudo docker compose -f %deployRoot%/docker-compose.yml ps"
  ],
  "hosts": ["192.168.1.200"]
}

工作流

复制代码
本地开发机 (Windows)
    │
    ├── mvn package         → target/app-1.0.0.jar
    ├── npm run build       → dist/
    │
    └── 打开 LinuxSshTools,点击"单个执行"
            │
            ├── SFTP 上传 app-1.0.0.jar → /opt/myapp/app.jar
            ├── SFTP 上传 dist/ → /opt/myapp/nginx/html/(保留目录结构)
            ├── SSH 执行 docker compose restart backend
            ├── SSH 执行 docker compose restart nginx
            └── SSH 执行 docker compose ps (验证)

场景三:批量 Shell 脚本执行

问题:需要在所有服务器上收集系统信息(磁盘使用、进程列表、网络配置)并返回结果。

配置方案

json 复制代码
{
  "variables": {
    "sshUser": "root",
    "sshPassword": "root_pass",
    "scriptPath": "/tmp"
  },
  "shellScript": "collect_info.sh",
  "hosts": ["server1", "server2", "server3"]
}

upload/collect_info.sh 内容:

bash 复制代码
#!/bin/bash
echo "=== 主机信息 ==="
hostname
echo "=== 磁盘使用 ==="
df -h
echo "=== 内存使用 ==="
free -m
echo "=== 网络配置 ==="
ip addr show
echo "=== 系统版本 ==="
cat /etc/os-release

脚本执行结果会实时显示在 listBox2 中,可通过 Ctrl+A 全选后 Ctrl+C 复制。


十三、JSON 路径转义问题与解决方案

这是一个容易踩坑的细节。Windows 路径使用反斜杠 \,而 JSON 字符串中 \ 是转义字符前缀。某些特定路径前缀会导致静默错误:

Windows 路径片段 JSON 中 实际被解析为
D:\target\... \t Tab 字符(\t
D:\network\... \n 换行符
D:\backup\... \b 退格符
D:\resource\... \r 回车符
D:\files\... \f 换页符

正确写法(JSON 中需双重转义):

json 复制代码
// ❌ 错误:\t 被解析为 Tab
"D:\\Develop\target\\app.jar"

// ✅ 正确:\\ 被解析为单个 \
"D:\\Develop\\target\\app.jar"

// ✅ 也可以用正斜杠(Windows 也支持)
"D:/Develop/target/app.jar"

工具中通过 SanitizeJsonPath 方法进行后处理,将已经被错误解析的控制字符还原为可见的转义序列字符串,以便于日志输出时可读:

csharp 复制代码
private string SanitizeJsonPath(string s)
{
    var sb = new StringBuilder();
    foreach (char c in s)
    {
        switch (c)
        {
            case '\t': sb.Append("\\t"); break;  // 实际 Tab → 字面 "\t"
            case '\n': sb.Append("\\n"); break;
            case '\r': sb.Append("\\r"); break;
            case '\b': sb.Append("\\b"); break;
            case '\f': sb.Append("\\f"); break;
            default:
                // 过滤其他不可打印控制字符(U+0000--U+001F)
                if (c >= 0x20) sb.Append(c);
                break;
        }
    }
    return sb.ToString();
}

根本解决方案 :在 JSON 配置文件中,Windows 绝对路径应使用双反斜杠 \\ 或正斜杠 /


十四、关键代码片段完整呈现

14.1 SSH.NET 客户端连接与命令执行

csharp 复制代码
using Renci.SshNet;

// 密码认证
using var client = new SshClient(
    hostname: "192.168.1.100",
    port: 22,
    username: "root",
    password: "your_password"
);

// 设置连接超时
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(5);
client.Connect();

// 执行命令并获取结果
SshCommand cmd = client.RunCommand("ls -la /opt");
Console.WriteLine($"stdout: {cmd.Result}");
Console.WriteLine($"stderr: {cmd.Error}");
Console.WriteLine($"exit code: {cmd.ExitStatus}");

client.Disconnect();

14.2 SFTP 文件上传(含目录递归创建)

csharp 复制代码
using Renci.SshNet;

using var sftp = new SftpClient("192.168.1.100", 22, "root", "password");
sftp.Connect();

// 上传单个文件
string remotePath = "/opt/myapp/app.jar";

// 确保远程目录存在
string remoteDir = "/opt/myapp";
if (!sftp.Exists(remoteDir))
    sftp.CreateDirectory(remoteDir);

using (var fs = File.OpenRead(@"D:\build\app.jar"))
{
    sftp.UploadFile(fs, remotePath);
}

sftp.Disconnect();

14.3 SFTP 目录批量上传(保留子目录结构)

csharp 复制代码
/// <summary>
/// 递归上传本地目录到远程,保留目录结构
/// </summary>
/// <param name="sftp">已连接的 SftpClient</param>
/// <param name="localDir">本地目录路径</param>
/// <param name="remoteDir">远程目标目录路径</param>
private void UploadDirectory(SftpClient sftp, string localDir, string remoteDir)
{
    // 确保远程根目录存在
    EnsureRemoteDirectory(sftp, remoteDir);

    string basePath = localDir.TrimEnd('\\', '/') + Path.DirectorySeparatorChar;

    foreach (string localFile in Directory.GetFiles(localDir, "*",
                                                     SearchOption.AllDirectories))
    {
        // 计算相对路径,转换为 Unix 路径分隔符
        string relativePath = localFile.Substring(basePath.Length)
                                       .Replace('\\', '/');
        string remoteFilePath = $"{remoteDir.TrimEnd('/')}/{relativePath}";

        // 确保远程子目录存在
        string remoteSubDir = remoteFilePath.Substring(0,
                                  remoteFilePath.LastIndexOf('/'));
        EnsureRemoteDirectory(sftp, remoteSubDir);

        // 上传文件
        using var fs = File.OpenRead(localFile);
        sftp.UploadFile(fs, remoteFilePath);
    }
}

14.4 SSH.NET NuGet 依赖配置(packages.config)

xml 复制代码
<packages>
  <package id="Renci.SshNet" version="2024.2.0" targetFramework="net48" />
  <package id="BouncyCastle.Cryptography" version="2.4.0" targetFramework="net48" />
  <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
  <package id="Microsoft.Bcl.AsyncInterfaces" version="1.0.0" targetFramework="net48" />
  <package id="System.Memory" version="4.5.5" targetFramework="net48" />
  <package id="System.Buffers" version="4.5.1" targetFramework="net48" />
</packages>

14.5 ListBox 剪贴板支持

csharp 复制代码
// 监听 ListBox 的 KeyUp 事件
private void listBox2_KeyUp(object sender, KeyEventArgs e)
{
    if (e.Control && e.KeyCode == Keys.C)
    {
        listBox_Clipboard(listBox2);
    }
}

/// <summary>
/// 将 ListBox 选中的所有行合并后写入剪贴板
/// </summary>
private void listBox_Clipboard(ListBox lb)
{
    if (lb.SelectedItems.Count == 0) return;

    var sb = new StringBuilder();
    foreach (var item in lb.SelectedItems)
        sb.AppendLine(item.ToString());

    Clipboard.SetText(sb.ToString());
}

十五、总结与展望

15.1 设计优点总结

特性 实现方式 价值
配置驱动 JSON 文件 + 反序列化 无需改代码,换场景只换配置文件
批量并发 Task.Run + Interlocked 100 台设备 3 分钟完成,串行需 30 分钟
变量替换 两轮文本替换 + 反序列化 配置参数化,消除硬编码
多类型上传 通配符/目录/单文件三路处理 灵活适配不同部署产物
线程安全 UI InvokeRequired + BeginInvoke 并发时日志不串行,UI 不崩溃
sudo 自动化 echo pwd | sudo -S 无交互式 sudo 命令执行
零依赖部署 .NET Framework 4.8 内置 目标机器不需要安装运行时

15.2 可改进方向

  1. 密钥认证支持 :目前仅支持密码认证,SSH 密钥文件认证更安全,SSH.NETPrivateKeyFile 类已支持此功能。

  2. 配置文件加密:密码目前明文存储在 JSON 中,可引入 Windows DPAPI 或 AES 加密对密码字段加密。

  3. 执行结果持久化:当前日志仅显示在 ListBox,批量执行后无法回溯。可添加"保存日志"按钮,或自动写入带时间戳的日志文件。

  4. 并行度控制 :当前并发模式下所有主机同时启动,对网络有冲击。可引入 SemaphoreSlim 限制并发数:

    csharp 复制代码
    var semaphore = new SemaphoreSlim(20); // 最多 20 并发
    foreach (var host in hosts)
    {
        await semaphore.WaitAsync();
        _ = Task.Run(async () =>
        {
            try { await SshExecAsync(host); }
            finally { semaphore.Release(); }
        });
    }
  5. 失败重试机制:对连接失败的主机自动重试 N 次,适应网络不稳定环境。

  6. 任务调度:可添加定时任务功能,配合 Windows 任务计划程序实现自动化定期运维。

  7. 迁移至 .NET 8:升级到 .NET 8 + WinForms 可获得更好的性能和 AOT 支持,同时可将配置密码存储在 Windows Credential Manager 中。


参考资料


📌 完整源码 :本项目完整源码已托管在 GitHub,基于 Apache License 2.0 开源协议,欢迎 Star / Fork:

https://github.com/PascalMing/LinuxSshTools

如有问题,欢迎在评论区或 GitHub Issues 中交流!

相关推荐
用户03284722207011 小时前
如何搭建本地yum源(上)
运维
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
开发者联盟league3 天前
安装pnpm
ssh
qq_369224333 天前
Windows全系通用!ntdll.dll文件丢失、报错、闪退问题的完整排查与修复教程
windows·dll·dll修复·dll丢失·dll错误
Inhand陈工4 天前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智4 天前
ARP代理--工作原理
运维·网络·arp·arp代理
shushangyun_4 天前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化
施努卡机器视觉4 天前
SNK施努卡侧滑门锁上滑轮总成自动化装配线,从零件到组件,全流程精密制造方案
运维·自动化·制造