资讯详情 SSH.NET实战:C#远程Linux命令执行与SFTP传输避坑
📅 2026/10/24 5:48:15
简介面向 C#/.NET 开发者的 SSH.NET 组件更新包重点解决旧版 Renci.SshNet.dll 在连接加密级别较高的 Linux 系统如 OpenEuler 22.0.3时出现的兼容性问题。包内共 20 个文件以 9 个 dll 程序集和 9 个 xml 文档为主另含 nupkg 包与 p7s 签名文件压缩包约 6.55MBdll 按多个目标框架组织覆盖 .NET Framework、.NET Standard、UAP、Silverlight 与 Windows Phone 等运行环境便于在桌面端、服务端及跨平台项目中按需引用xml 提供 API 注释nupkg 便于通过包管理方式集成签名文件可用于完整性校验。已有 774 人学习适合正在维护 .NET SSH 客户端、需要升级依赖或排查连接失败问题的开发者参考尤其适合受旧版本加密协商限制困扰的项目。替换旧版程序集后可减少因加密算法协商失败导致的连接异常借助随附文档还能快速核对类型与成员用法并按项目目标框架选用对应程序集也便于评估不同框架产物的差异降低升级过程中的试错成本。1. 一台 Linux 服务器的远程运维修起来有多烦Renci.SshNet 能顶掉哪些活后台服务跑在 Windows 上业务数据落在几台 Linux 机器里日常要拉日志、跑部署脚本、回传报表文件——这种 C# SSH .NET Linux 组合的场景太常见了。多数人的第一反应是Process.Start调ssh.exe或者 plink把命令拼成字符串再从标准输出里正则抠结果。这条路我见过太多翻车密码要交互、退出码拿不到、路径带空格就截断、中文日志变乱码、命令没跑完进程就退出了。Renci.SshNetNuGet 包 ID 是SSH.NET编译产物就是Renci.SshNet.dll干的就是把这层替掉纯托管实现 SSH-2 协议C# 里直接 new 一个SshClient连上去执行命令、开SftpClient传文件不依赖系统里装没装 OpenSSH 客户端。适合谁手里有 .NET 项目、需要对 Linux 做自动化运维或批量文件同步的人。它替代不了运维平台但把「一条命令、一个文件」这件事做干净足够了。2. dll 落地包名、目标框架与强名冲突逐个拆很多人拿到一份Renci.SshNet.dll直接拖进项目引用或者在 NuGet 里搜「Renci.SshNet」搜不到就以为没有包这两种情况最后都会绕回同一个问题程序集版本和项目目标框架对不上。这一章把「把库装进项目」这一步拆开顺便把连接建立的最小可运行代码补上因为认证构造本身也是落地的一部分。2.1 包 ID 是 SSH.NETdll 名是 Renci.SshNet别搜错三个名字要分清NuGet 包 ID 是SSH.NET程序集文件名是Renci.SshNet.dll代码里的命名空间是Renci.SshNet。搜索的时候用包 ID 才能命中Install-Package Renci.SshNet是找不到的。这也是为什么很多老项目里躺着的手工引用 dll后来同事完全不知道它对应 NuGet 上哪个包升级无门。用 PackageReference 的项目csproj 里加这么一段就行ItemGroup !-- 包 ID 是 SSH.NET版本号建议用浮动写法或者去 nuget.org 查当前最新 -- PackageReference IncludeSSH.NET Version2024.* / /ItemGroup老式的packages.config项目在「管理 NuGet 程序包」里搜SSH.NET安装即可装的同样是Renci.SshNet.dll区别只是还原方式和依赖记录位置不同。如果你的场景是离线环境、只能拿一份 dll 文件走天下那就得自己管理版本把 dll 放到lib/目录下引用时把Copy Local设为true并且在app.config或web.config里补上绑定重定向。这里有个血泪经验——同一个项目里绝不能既装 NuGet 包又手工引用 dll两份程序集版本不同编译能过运行时FileLoadException或者「方法找不到」报错信息还指向你自己的业务代码排查半天。手工引用的情况下先用 PowerShell 把 dll 的真实身份读出来这是后面写 bindingRedirect 的依据# 读程序集全名、版本号、公钥标记写 bindingRedirect 时要用 $asm [System.Reflection.AssemblyName]::GetAssemblyName(C:\lib\Renci.SshNet.dll) Write-Host Name : $($asm.Name) Write-Host Version : $($asm.Version) $token ($asm.GetPublicKeyToken() | ForEach-Object { $_.ToString(x2) }) -join Write-Host Token : $token这段输出的Name一定是Renci.SshNetVersion是新版库的年份式版本号形如2024.x.x.xToken是强名称公钥标记。把这三个值抄进配置文件configuration runtime assemblyBinding xmlnsurn:schemas-microsoft-com:asm.v1 dependentAssembly !-- publicKeyToken 和 newVersion 必须跟上面 PowerShell 的输出完全一致 -- assemblyIdentity nameRenci.SshNet publicKeyToken1cee9f8bde3db106 cultureneutral / bindingRedirect oldVersion0.0.0.0-2024.0.0.0 newVersion2024.0.0.0 / /dependentAssembly /assemblyBinding /runtime /configurationoldVersion覆盖的是「所有可能被引用的旧版本范围」写0.0.0.0-开头是最省事的做法能兜住第三方库间接引用的老版本。newVersion必须是你 bin 目录下真实存在的那个版本写错一样起不来。提示如果你的项目是 .NET Core / .NET 5不需要 bindingRedirect程序集统一由 deps.json 管理出现版本冲突直接在 csproj 里显式指定版本即可。2.2 目标框架对不上时升级还是降版本这是新手最容易卡住的一步。较新的 SSH.NET 包已经把 .NET Framework 的下限抬到了 4.6.2同时提供 netstandard2.0 和 net6/net8 目标。你如果在一个.NET Framework 4.0或4.5的项目里引用新版包编译阶段就会报「包与目标框架不兼容」或者编译通过但运行时抛「未能加载文件或程序集……需要 netstandard」。两条路选哪条看项目处境方案操作适用场景代价升级目标框架项目属性里把目标框架改到 4.6.2 及以上同时补System.ValueTuple等间接依赖项目还在维护、能改部署环境需要确认服务器上装了对应 .NET Framework 运行时降级 dll 版本换用仍带net40目标的旧版本老旧系统、不能动运行时缺少新主机密钥算法、ed25519 私钥支持可能不全我一般会先看部署机器的 Windows 版本。Windows Server 2012 R2 之后的机器装 4.6.2 以上基本没阻力那就直接升级如果是客户现场的老 XP/2003 时代的机器只能降版本同时要接受「某些新算法协商不上」的现实。还有一种隐蔽情况项目本身是 .NET Framework 4.8但引用的某个第三方库编译时依赖了 SSH.NET 的旧版本于是 bin 目录下同时出现两份不同版本的Renci.SshNet.dll一份在根目录一份在子目录。现象是本地调试正常发布到服务器上就MissingMethodException。# 发布前扫一遍输出目录确认同名程序集只有一份 find ./publish -iname Renci.SshNet.dll -exec sh -c echo $1; monodis --assembly $1 2/dev/null | grep Version _ {} \;没有 monodis 就用 PowerShell 版的[Reflection.AssemblyName]::GetAssemblyName()挨个读。确认只有一份之后再看它是不是 bindingRedirect 里写的那个版本。2.3 ConnectionInfo 的三种认证构造与连接建立装好之后第一件事是连通性验证。SshClient有几个重载最省事的是直接传主机、端口、用户名、密码但它内部只构造一种认证方式遇到禁用密码登录的服务器就抓瞎。生产代码里我一般直接构造ConnectionInfo把认证方式显式列出来using Renci.SshNet; using Renci.SshNet.Common; // 方式一密码认证最直接 var pwdConn new PasswordConnectionInfo(10.0.0.10, 22, deploy, Pssw0rd); pwdConn.Timeout TimeSpan.FromSeconds(15); // 建连 认证的总超时 // 方式二私钥认证带 passphrase 的私钥要传第二个参数 var keyFile new PrivateKeyFile(D:\keys\deploy_ed25519, key-passphrase); var keyConn new PrivateKeyConnectionInfo(10.0.0.10, 22, deploy, keyFile); // 方式三密码 键盘交互很多服务器只开了 keyboard-interactive var kbd new KeyboardInteractiveAuthenticationMethod(deploy); kbd.AuthenticationPrompt (sender, e) { // 服务器问几个问题就答几个这里统一拿密码回 foreach (var prompt in e.Prompts) { prompt.Response Pssw0rd; } }; var mixedConn new ConnectionInfo(10.0.0.10, 22, deploy, new PasswordAuthenticationMethod(deploy, Pssw0rd), kbd); using var client new SshClient(mixedConn); client.KeepAliveInterval TimeSpan.FromSeconds(30); // 空闲时发心跳防 NAT 超时断链 client.Connect(); Console.WriteLine($connected{client.IsConnected}, server{client.ConnectionInfo.ServerVersion}); client.Disconnect();几个参数值得单独说清楚。ConnectionInfo.Timeout管的是「TCP 三次握手 SSH 协议协商 认证」这一段默认值偏短跨机房或者经过跳板的链路上容易误判超时我习惯给到 1530 秒。KeepAliveInterval管的是连接建立之后的空闲保活设成 30 秒能避免很多「闲置几分钟后再操作就抛SshConnectionException」的问题尤其是中间隔着防火墙或者负载均衡的场景。KeyboardInteractiveAuthenticationMethod的AuthenticationPrompt事件是唯一能拿到服务器提问文本的地方。有些服务器会先问Password:再问一次Verification code:双因素如果你不区分直接统一回密码第二次就认证失败。判断prompt.Request里包含什么关键字再决定Response填什么这是稳妥写法。私钥这块要特别注意格式。PrivateKeyFile认的是 OpenSSH 格式-----BEGIN OPENSSH PRIVATE KEY-----和传统 PEM-----BEGIN RSA PRIVATE KEY-----PuTTY 生成的.ppk文件直接塞进去会抛「Invalid private key file」。转换用 puttygen 导出为 OpenSSH 格式或者干脆重新生成一对。ed25519 密钥的支持要看 dll 版本太老的版本只认 RSA 和 DSA遇到 ed25519 就报「Key file is invalid」这类问题换密钥类型比改代码快。3. 命令执行RunCommand 拿不到退出码时该看哪里连接通了接下来九成的人第一件事是client.RunCommand(ls)。这个 API 设计得确实顺手但它把「创建命令、执行、销毁」三步压在了一行里导致很多状态你拿不到。这一章把命令执行的几条路径和它们的边界讲透。3.1 CreateCommand 与 RunCommand 的生命周期差别RunCommand内部做的事是CreateCommand→Execute→ 返回SshCommand。返回的对象上Result、Error、ExitStatus都已经填好了但命令对象本身不能再复用也不方便在中间改超时。它适合「一次性、短命令」的场景比如探活、读一个文件、触发一个脚本。CreateCommand给你的是一个可控对象可以先设CommandTimeout再执行也可以异步执行using Renci.SshNet; using var client new SshClient(10.0.0.10, 22, deploy, Pssw0rd); client.Connect(); // 路径一RunCommand一行搞定执行完连接仍可复用 using var quick client.RunCommand(systemctl is-active nginx); Console.WriteLine($exit{quick.ExitStatus} out{quick.Result.Trim()} err{quick.Error.Trim()}); // 路径二CreateCommand可以改超时、可以反复读结果 using var cmd client.CreateCommand(tail -n 200 /var/log/app/api.log); cmd.CommandTimeout TimeSpan.FromSeconds(60); // 默认继承 ConnectionInfo.Timeout var stdout cmd.Execute(); // 阻塞到命令结束 Console.WriteLine($exit{cmd.ExitStatus}); Console.WriteLine(stdout); // 路径三异步执行适合 UI 线程或者要并发跑多台的场景 using var asyncCmd client.CreateCommand(du -sh /data/*); var ar asyncCmd.BeginExecute(); // 这里可以先去做别的事比如更新进度条 asyncCmd.EndExecute(ar); Console.WriteLine(asyncCmd.Result);生命周期上有两个坑。第一SshCommand是IDisposableCreateCommand出来的对象必须自己using或者Dispose否则底下的 channel 不释放连接数多了之后服务器会拒绝新会话报「Channel open failure」。第二RunCommand返回的对象虽然也是IDisposable但它在内部已经结束了命令Dispose 只是释放壳子别再拿它去Execute()会抛SshCommandExecutionException。Execute()的返回值是Result的副本命令执行完Result属性才有效。在BeginExecute之后、EndExecute之前去读Result拿到的是空字符串这个顺序不能颠倒。3.2 退出码、stderr 与编码的处理Linux 的约定是退出码 0 表示成功非 0 表示失败。但SshCommand.Execute()不会因为退出码非 0 而抛异常它只在连接层出问题时抛SshConnectionException、SshOperationTimeoutException这类异常。所以判断成败必须自己看ExitStatususing var cmd client.CreateCommand(bash -lc cd /data ./import.sh --date 2024-06-01); cmd.CommandTimeout TimeSpan.FromMinutes(10); try { cmd.Execute(); if (cmd.ExitStatus ! 0) { // 非 0 不抛异常必须自己判Error 里通常有失败原因 Console.WriteLine($command failed, exit{cmd.ExitStatus}); Console.WriteLine(cmd.Error); } } catch (SshOperationTimeoutException ex) { // 超时了远端命令可能还在跑必须主动取消否则会话会挂住 cmd.CancelAsync(); Console.WriteLine($timeout after {cmd.CommandTimeout}: {ex.Message}); }Error里装的只是标准错误流的内容很多脚本会把 warning 也打到 stderr所以「Error 非空」不等于「命令失败」最终判断依据还是ExitStatus。反过来一些脚本把错误信息打到 stdout 然后返回 0这种情况只能靠业务层面的输出约定去兜。编码问题在中文日志上特别常见。默认情况下 SSH.NET 用连接层的编码解析输出如果服务器 locale 是 UTF-8 但 .NET 侧按别的编码解中文就变成问号或者方块。CreateCommand有带Encoding的重载直接指定using System.Text; // 服务器 locale 是 en_US.UTF-8 或 zh_CN.UTF-8 时显式传 UTF8 最稳 using var cmd client.CreateCommand(cat /data/report/daily.txt, Encoding.UTF8); var text cmd.Execute(); File.WriteAllText(D:\report\daily.txt, text, Encoding.UTF8);还有一种情况是命令输出里带终端控制字符\u001b[32m这种颜色码写进文件后看着像乱码。在命令末尾加| sed s/\x1b\[[0-9;]*m//g过滤掉比在 C# 里写正则干净。3.3 ShellStream 处理需要 TTY 的交互命令有些命令在非交互环境下根本跑不了sudo默认要 TTYpasswd要交互输入一些安装脚本会检测isatty()然后直接退出。RunCommand分配的 channel 没有伪终端这些场景全部失效只能上ShellStream。ShellStream模拟的是一个真实终端你可以写一行、等提示符、再写下一行using System.Text.RegularExpressions; using Renci.SshNet; // 参数依次是终端类型、列、行、像素宽、像素高、缓冲区大小 using var shell client.CreateShellStream(xterm, 120, 40, 1200, 400, 4096); shell.WriteLine(sudo -S systemctl restart nginx); shell.Expect(password, TimeSpan.FromSeconds(5)); // 等密码提示超时抛异常 shell.WriteLine(Pssw0rd); // 提示符正则要跟服务器实际的 PS1 对上不同发行版默认值差别很大 var output shell.Expect(new Regex([#$]\s*$), TimeSpan.FromSeconds(30)); Console.WriteLine(output);Expect的关键是「匹配什么」。最简单的做法是先跑echo $PS1看一眼服务器的提示符格式root 一般是#普通用户是$有些发行版会带用户名和路径正则要相应放宽。正则写得太严会一直等到超时写得太松会在命令还没执行完的时候就返回半截输出。Expect的超时时间要按命令实际耗时给。systemctl restart在负载高的机器上跑 30 秒很正常给 5 秒必然超时超时之后ShellStream内部缓冲区状态就乱了后续读写全不可靠只能重建连接。我的习惯是给每个Expect单独设超时并且在整个流程外包一层 try/catch异常时直接Dispose掉 stream 重新开一个不做「原地恢复」的幻想。注意ShellStream的列宽参数会影响输出折行。设成 120 列时原本一行 200 字符的日志会被插入换行符后续解析要按折行处理。如果输出要原样保存把列宽设大一些比如 512。4. SFTP 传输大文件、进度回调与递归同步的写法命令执行解决「让远端做事」文件传输解决「把结果拿回来」。SSH.NET 里的SftpClient是独立于SshClient的另一个连接对象两者可以共用一个ConnectionInfo但各自维护自己的会话。这一章按「连上 → 看目录 → 传文件 → 批量同步」的顺序推进。4.1 SftpClient 的连接参数与目录遍历using Renci.SshNet; using var sftp new SftpClient(10.0.0.10, 22, deploy, Pssw0rd); sftp.OperationTimeout TimeSpan.FromSeconds(30); // 单次 SFTP 操作列目录、读属性超时 sftp.BufferSize 64 * 1024; // 读写缓冲区默认 32KB大文件可以调到 128KB sftp.Connect(); Console.WriteLine($home{sftp.WorkingDirectory}); // 登录后的默认目录 foreach (var f in sftp.ListDirectory(/data/reports)) { // ListDirectory 的结果里一定包含 . 和 ..不过滤会死循环 if (f.Name . || f.Name ..) continue; Console.WriteLine(${f.Name}\t{f.Length}\t{f.LastWriteTimeUtc:yyyy-MM-dd HH:mm:ss}\t{f.IsDirectory}); }OperationTimeout和ConnectionInfo.Timeout是两个东西。前者管的是「已经连上之后单条 SFTP 协议请求等多久」列一个有几万文件的目录、读一个属性都可能触发后者管的是建连。两个都要设只设一个会出现「连得上但一列目录就超时」的怪现象。ListDirectory返回的是延迟枚举边遍历边发请求。如果目录里有几十万文件遍历过程中连接断了会抛SshConnectionException已经拿到的部分数据不会回滚。批量处理场景下我一般先ToList()落内存再慢慢处理避免枚举器持有连接太久。常用的目录操作还有几个Exists(path)判断存在、CreateDirectory(path)建目录不会递归建父目录不存在会抛SftpPathNotFoundException、DeleteFile/DeleteDirectory、RenameFile。递归建目录得自己写循环static void EnsureRemoteDirectory(SftpClient sftp, string path) { var parts path.Trim(/).Split(/); var current ; foreach (var part in parts) { current / part; if (!sftp.Exists(current)) { sftp.CreateDirectory(current); } } }4.2 上传下载的缓冲区、进度与超时UploadFile有一个带进度回调的重载回调参数是「已传输字节数」类型是ulongusing var local File.OpenRead(D:\out\report_2024.csv); long lastReported 0; sftp.UploadFile( input: local, path: /data/reports/report_2024.csv, canOverride: true, // true 表示目标存在就覆盖 uploadCallback: uploaded { // 回调在传输线程上同步执行别在这里做耗时操作 if (uploaded - lastReported 4 * 1024 * 1024) { lastReported (long)uploaded; Console.WriteLine($uploaded {uploaded / 1024.0 / 1024.0:F1} MB); } });三个参数各有讲究。canOverride为false时目标已存在会抛SftpPermissionDeniedException不是「静默跳过」异常处理要按这个前提写。uploadCallback的触发频率跟BufferSize挂钩缓冲区 32KB 时每个块回调一次大文件会产生海量回调所以回调里必须做节流否则光是Console.WriteLine就能把传输拖慢一半。下载方向对称long totalRead 0; using var output File.Create(D:\in\api.log); sftp.DownloadFile(/var/log/app/api.log, output, read totalRead (long)read); Console.WriteLine($downloaded {totalRead} bytes);大文件传输最常见的翻车是「传到 80% 卡住然后抛SshOperationTimeoutException」。原因通常不是网络断而是OperationTimeout太短加上服务端磁盘 IO 抖动某一块数据传输超时。处理办法是把OperationTimeout拉到 5 分钟以上同时把BufferSize从默认 32KB 提到 64128KB减少往返次数。再稳一点的做法是自己分片本地按 16MB 切块每块单独调UploadFile的上层封装失败重试只重传当前块。SSH.NET 的UploadFile本身不支持断点续传这是它的能力边界。要续传只能用SftpFileStream手动控制偏移// 追加模式打开远端文件从已有长度开始续写 var remoteSize sftp.Exists(/data/big.iso) ? sftp.GetAttributes(/data/big.iso).Size : 0; using var remote sftp.OpenWrite(/data/big.iso, FileMode.OpenOrCreate); // 部分版本签名不同 remote.Seek((long)remoteSize, SeekOrigin.Begin); using var local File.OpenRead(D:\out\big.iso); local.Seek((long)remoteSize, SeekOrigin.Begin); local.CopyTo(remote, 65536);OpenWrite的具体重载在不同版本里有差异用之前先确认 dll 版本对应的签名。4.3 递归同步与并发上传的边界把整个目录同步上去得自己写递归。这里有个必须记住的细节远端路径分隔符只能是正斜杠/用Path.Combine在 Windows 上拼出来的是反斜杠SFTP 服务端会把它当成文件名字符的一部分结果是根目录下出现一堆名叫data\reports的怪文件。static void UploadDirectory(SftpClient sftp, string localDir, string remoteDir) { if (!sftp.Exists(remoteDir)) sftp.CreateDirectory(remoteDir); foreach (var file in Directory.EnumerateFiles(localDir)) { // 手动拼 /不要用 Path.Combine var target remoteDir.TrimEnd(/) / Path.GetFileName(file); using var fs File.OpenRead(file); sftp.UploadFile(fs, target, true); } foreach (var dir in Directory.EnumerateDirectories(localDir)) { var target remoteDir.TrimEnd(/) / Path.GetFileName(dir); UploadDirectory(sftp, dir, target); } }并发这块要把边界说清楚SftpClient实例不是线程安全的同一个连接上并发调UploadFile会直接抛异常或者数据错乱。要并发只能开多个SftpClient每个线程一个连接。开多少个合适服务器端sshd的MaxSessions默认是 10扣掉你自己占的命令会话实际能拿到 58 个。我一般按文件数量决定文件多且小开 4 个连接并行文件少且大开 12 个就够了瓶颈在带宽不在连接数。// 简单的多连接并行上传每批 4 个文件 var files Directory.GetFiles(D:\out, *.csv); Parallel.ForEach( files, new ParallelOptions { MaxDegreeOfParallelism 4 }, file { // 每个并行体内部独立建连用完就释放 using var sftp new SftpClient(connInfo); sftp.Connect(); using var fs File.OpenRead(file); sftp.UploadFile(fs, /data/reports/ Path.GetFileName(file), true); });每次建连都有 SSH 握手的开销密钥交换、认证文件很小的时候这个开销占比很高。折中的做法是用一个连接池池里放 34 个长连接用完归还避免频繁握手。5. 踩坑排查五条从生产日志里翻出来的记录这一章不按 API 分类按「你看到了什么报错」来组织。每条都按现象、原因、解决三段写遇到同类问题可以直接对号入座。5.1No suitable authentication method found现象连接一台新服务器密码确认没输错但抛SshAuthenticationException: No suitable authentication method found to complete authentication。原因服务器的sshd_config里PasswordAuthentication no只开了PubkeyAuthentication或者KbdInteractiveAuthentication。SSH.NET 默认的PasswordConnectionInfo只尝试 password 这一种方法服务器不接就报这个错。解决先登到服务器上看grep -i authentication /etc/ssh/sshd_config确认开了哪种。如果是 keyboard-interactive换成KeyboardInteractiveAuthenticationMethod加进ConnectionInfo如果是 pubkey换私钥。用ssh -v userhost在命令行看服务端接受的方法列表最快输出里会有Authentications that can continue:这一行。5.2 私钥文件报Invalid private key file现象new PrivateKeyFile(path)直接抛SshException: Invalid private key file。原因有三类。一是文件是 PuTTY 的.ppk格式SSH.NET 不认二是密钥是 ed25519 或 ECDSA而用的 dll 版本太老只支持 RSA/DSA三是私钥文件有密码但构造时没传 passphrase。解决先用head -1 私钥文件看头部-----BEGIN OPENSSH PRIVATE KEY-----是新格式-----BEGIN RSA本文还有配套的精品资源点击获取