从零实现一款 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 中交流!

相关推荐
.千余1 小时前
【Linux】 TCP进阶详解:字节流、粘包问题、异常情况与UDP完整对比2
linux·运维·c语言·开发语言·经验分享·笔记·php
Bert.Cai1 小时前
Linux chown命令详解
linux·运维·服务器
青梅橘子皮1 小时前
Linux---进程切换与调度
linux·运维·服务器
utf8mb4安全女神1 小时前
【forwarding】怎么把客户端的日志转发到服务器【日志转发】【rsyslog服务】
运维·服务器
Cheng小攸1 小时前
CTF攻防综合实战(1)
windows
Kurisu5752 小时前
深度拆解:从 Linux 内核 Namespace 与 Cgroups 洞察容器技术的底层本质
java·linux·运维
llf_cloud2 小时前
docker compose滚动部署实践
运维·docker·容器
liulilittle2 小时前
Linux SS快速诊断命令
linux·运维·智能路由器
田里的水稻2 小时前
OE_ssh密钥_密钥种类和分别
运维·ssh