从零实现一款 Windows 下的 SSH 批量运维工具:LinuxSshTools 技术详解
摘要:本文介绍一款基于 C# .NET Framework 4.8 + WinForms + SSH.NET 实现的 Windows 端 SSH 批量运维工具。该工具以 JSON 配置文件驱动,支持批量并发执行命令、SFTP 文件上下传、Shell 脚本远程执行、变量模板替换等能力,解决了工业场景中向数十台乃至上百台 Linux 服务器批量部署和运维的问题。
目录
- 一、背景与动机
- 二、技术选型
- 三、整体架构设计
- 四、核心数据模型:AppConfig
- [五、JSON 配置文件详解](#五、JSON 配置文件详解)
- 六、核心执行流程:_SshExec
- 七、并发模型设计
- 八、变量替换机制
- 九、文件上传路径处理
- [十、sudo 密码自动注入](#十、sudo 密码自动注入)
- [十一、跨线程 UI 安全更新](#十一、跨线程 UI 安全更新)
- 十二、实际使用场景
- [十三、JSON 路径转义问题与解决方案](#十三、JSON 路径转义问题与解决方案)
- 十四、关键代码片段完整呈现
- 十五、总结与展望
一、背景与动机
在工业互联网和企业 IT 运维场景中,工程师常常面临如下痛点:
- 批量设备运维:数十台甚至上百台 Linux 服务器同时需要配置变更、软件升级、时间同步等操作,逐台手工操作效率极低。
- 持续集成部署:开发机是 Windows,目标服务器是 Linux,每次改动后需要将构建产物(JAR 包、前端 dist 等)推送到服务器并重启服务。
- 临时脚本执行:针对特定场景需要批量执行一段 Shell 脚本,不想每次都手动 SSH 上去敲命令。
- 现有工具笨重: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.log 和 download/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 可改进方向
-
密钥认证支持 :目前仅支持密码认证,SSH 密钥文件认证更安全,SSH.NET 的
PrivateKeyFile类已支持此功能。 -
配置文件加密:密码目前明文存储在 JSON 中,可引入 Windows DPAPI 或 AES 加密对密码字段加密。
-
执行结果持久化:当前日志仅显示在 ListBox,批量执行后无法回溯。可添加"保存日志"按钮,或自动写入带时间戳的日志文件。
-
并行度控制 :当前并发模式下所有主机同时启动,对网络有冲击。可引入
SemaphoreSlim限制并发数:csharpvar semaphore = new SemaphoreSlim(20); // 最多 20 并发 foreach (var host in hosts) { await semaphore.WaitAsync(); _ = Task.Run(async () => { try { await SshExecAsync(host); } finally { semaphore.Release(); } }); } -
失败重试机制:对连接失败的主机自动重试 N 次,适应网络不稳定环境。
-
任务调度:可添加定时任务功能,配合 Windows 任务计划程序实现自动化定期运维。
-
迁移至 .NET 8:升级到 .NET 8 + WinForms 可获得更好的性能和 AOT 支持,同时可将配置密码存储在 Windows Credential Manager 中。
参考资料
- LinuxSshTools 源码(GitHub)
- SSH.NET 官方文档
- BouncyCastle.Cryptography
- Newtonsoft.Json 文档
- WinForms 跨线程操作
- sudo -S 选项说明
- Task Parallel Library (.NET)
📌 完整源码 :本项目完整源码已托管在 GitHub,基于 Apache License 2.0 开源协议,欢迎 Star / Fork:
https://github.com/PascalMing/LinuxSshTools
如有问题,欢迎在评论区或 GitHub Issues 中交流!