简介:面向C#与.NET开发者的SSH.NET库更新包,重点解决旧版Renci.SshNet.dll在连接加密级别较高的Linux系统时出现的兼容问题,例如openEuler 22.0.3等环境。包内共20个文件,以9个dll和9个xml为主,另含1个nupkg包及1个p7s签名文件,压缩包约6.55MB;dll覆盖多个目标框架版本,xml为对应API文档注释,nupkg便于按包方式引用,签名文件可用于完整性校验。已有774人学习下载,说明该更新在SSH连接排障场景中具有实际参考价值。读者可获得可直接替换的程序集、分框架适配线索以及接口说明,便于在桌面端、服务端或跨平台项目中按现有目标框架选择合适版本,减少因加密算法协商失败导致的连接异常,并为后续升级与依赖管理保留清晰依据。
1. 一台 Linux 服务器和一堆 C# 服务之间的那根线
见过太多次这种局面:Linux 侧跑着业务,Windows 侧一个 C# 后台服务要做巡检、下发配置、回捞日志,第一反应是起进程调命令行工具,或者干脆上 WinRM。前者要在安装包里塞个额外二进制,后者在跨网段、非域环境里配置成本高得离谱。这时候Renci.SshNet.dll就是更省事的那条路——纯托管的 SSH-2 客户端实现,一个 DLL 引用进工程,密码认证、密钥认证、命令执行、SFTP 传输、端口转发全都齐了。它适合把 Linux 侧运维动作嵌进 .NET 程序的场景:批量巡检、配置下发、日志回捞、部署编排。不适合需要完整终端仿真的场合,比如在远端跑 vim、top 这类全屏交互程序并期望正确渲染,那是另一个话题。这份较新发布线的 DLL 值得放进工具箱,前提是你清楚它的边界在哪。
2. 把 DLL 引进工程:引用姿势与目标框架的取舍
拿到一个Renci.SshNet.dll,第一件事不是写代码,是决定怎么把它挂进项目。这件事看起来只是三行配置,实际上决定了后面半年你升级、排错、跨框架复用的成本。我见过项目把它丢进lib\目录手动引用,也见过直接走包管理,两种都能跑,差别在升级和依赖解析。
2.1 直接引用 DLL 还是走包管理
手动引用的写法在 csproj 里长这样:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- 同一份代码要同时出 .NET Framework 和 .NET 两个版本 --> <TargetFrameworks>net48;net8.0</TargetFrameworks> </PropertyGroup> <ItemGroup> <Reference Include="Renci.SshNet"> <!-- 相对路径,团队里每个人拉下来都能编译 --> <HintPath>lib\netstandard2.0\Renci.SshNet.dll</HintPath> <!-- true 表示复制到输出目录,否则运行期找不到程序集 --> <Private>true</Private> </Reference> </ItemGroup> </Project>HintPath指向的是相对项目文件的路径,写成绝对路径在 CI 上必炸。Private这个节点很多人会漏,漏了之后本地调试正常、发布到服务器上抛FileNotFoundException,因为编译期能找到引用不代表运行期会被复制过去。多目标框架的项目还要注意:net48和net8.0最好各放一份对应构建的 DLL,混用虽然多数情况下能跑,但加密算法可用性和空引用注解会有细微差异。
包管理方式就一行:
dotnet add package SSH.NET包 ID 是SSH.NET,命名空间仍然是Renci.SshNet,这点容易让人困惑——搜包名和写 using 是两回事。走包管理的好处是传递依赖和版本约束由工具链处理,团队成员不用手动同步二进制文件;代价是内网离线环境要自己搭源或者缓存 nupkg。我的习惯是:内部长期维护的项目走包管理,需要随安装包分发的工具类项目直接引用 DLL,避免多一层还原依赖。
2.2 目标框架决定你能协商到哪些算法
SSH 握手分四步:版本交换、算法协商、密钥交换、用户认证。算法协商的本质是取客户端列表和服务端列表的交集,交集为空就直接连接失败。这一步的坑在于,服务端 OpenSSH 近几个版本陆续关掉了一批老算法,而客户端库能提供哪些算法,取决于它运行在什么目标框架上、底层有哪些加密原语可用。
典型翻车现场是:同一份代码在开发机上连得好好的,部署到另一台只装了旧版 .NET Framework 的机器上,报Key exchange failed或者There is no suitable solution for the key exchange。原因不是网络,是那台机器上的运行时缺某些算法实现,客户端提交的算法列表里没有一个能和对面匹配上。
排查这类问题,最直接的办法是把异常链整个打出来:
using Renci.SshNet; try { using var client = new SshClient("10.0.0.10", 22, "ops", "******"); client.Connect(); } catch (SshConnectionException ex) { // 外层异常往往只有一句话,真正的原因在内层 Console.WriteLine($"outer: {ex.Message}"); var inner = ex.InnerException; while (inner != null) { Console.WriteLine($"inner: {inner.GetType().Name} -> {inner.Message}"); inner = inner.InnerException; } }内层异常里经常能看到具体的算法名,比如某个 KEX 方法不被支持。拿到算法名之后,要么在服务端放开对应算法(生产环境慎用),要么把客户端的目标框架提上去。我的经验是:新项目一律net8.0起步,老项目至少保证net48配合较新的库构建,别拿五年前的 DLL 去连刚升级过的服务器。
2.3 写一个最小连通性探针
在往业务代码里塞逻辑之前,先写一个二十行的探针,把连接、执行、退出码、标准错误四件事跑通。后面所有复杂功能都是在这个基础上叠的。
using System; using System.Text; using Renci.SshNet; var ci = new ConnectionInfo( host: "10.0.0.10", port: 22, username: "ops", new PasswordAuthenticationMethod("ops", "******")) { // TCP 连接加握手的总超时,不是单次读写超时 Timeout = TimeSpan.FromSeconds(10), // 服务端输出非 UTF-8 时改这里,否则中文全是问号 Encoding = Encoding.UTF8 }; using var client = new SshClient(ci); // 每 30 秒发一次心跳,防止中间设备把空闲连接掐掉 client.KeepAliveInterval = TimeSpan.FromSeconds(30); client.Connect(); Console.WriteLine($"server: {client.ConnectionInfo.ServerVersion}"); using var cmd = client.CreateCommand("uname -a && echo done"); // 单条命令的执行上限,和上面的 Timeout 是两码事 cmd.CommandTimeout = TimeSpan.FromSeconds(15); var stdout = cmd.Execute(); Console.WriteLine($"exit={cmd.ExitStatus}"); Console.WriteLine($"out={stdout.Trim()}"); Console.WriteLine($"err={cmd.Error.Trim()}"); client.Disconnect();几个参数值得单独说。ConnectionInfo.Timeout管的是建连阶段,握手卡住时由它兜底;SshCommand.CommandTimeout管的是命令执行阶段,两个都设才不会有某一段无限等待。KeepAliveInterval在跨 NAT、跨防火墙的场景里几乎是必设项,不设的话连接空闲几分钟后被静默断开,下次复用直接抛异常。ExitStatus是远端进程的退出码,Error是 stderr 的内容——很多人只取Execute()的返回值就完事,结果远端命令失败了程序还当作成功继续往下跑,这是最常见的一类静默错误。
探针跑通之后再谈封装。直接把这套逻辑复制到业务代码里的项目我见过不少,三个月后要改超时时间,得全局搜索十几个文件。
3. 会话与认证:ConnectionInfo 里真正影响行为的参数
ConnectionInfo是整个库的入口对象,绝大多数“玄学”问题都能追溯到这个对象的某个属性没设对。它同时承担了三件事:描述目标、声明认证方式、控制行为参数。理解这三件事各自对应哪些字段,比背 API 有用得多。
3.1 参数表与它们各自的生效时机
| 参数 | 作用阶段 | 默认行为 | 什么时候必须改 |
|---|---|---|---|
Timeout | TCP 建连 + SSH 握手 | 30 秒 | 跨公网、链路质量差时调到 10~15 秒快速失败 |
Encoding | 通道数据编解码 | UTF-8 | 服务端 locale 是 GBK 或输出含中文 |
RetryAttempts | 建连失败重试 | 1 次 | 目标机器重启窗口期需要重试 |
MaxSessionsPerChannel | 单通道内并发会话数 | 10 | 单连接上跑大量并发命令 |
ChannelCloseTimeout | 通道关闭等待 | 1 秒 | 远端回收慢导致偶发关闭超时 |
Encoding这一项值得展开。它影响的不只是命令输出,还包括 SFTP 的路径字符串。曾经有个项目,远端目录名带中文,本地ListDirectory列出来全是问号,后续按名字下载全部 404。改成Encoding.GetEncoding("GB18030")之后立刻正常。这类问题不会抛异常,只会给你错误的数据,排查起来最费时间。
RetryAttempts的坑在于它是建连重试,不是命令重试。设成 3 只代表连接失败会重试三次,命令执行到一半连接断了它不会帮你重发。命令层面的重试必须自己写,而且要考虑幂等性。
3.2 密码、密钥、键盘交互三种认证的写法
密码认证最直白:
var ci = new ConnectionInfo("10.0.0.10", 22, "ops", new PasswordAuthenticationMethod("ops", "******"));密钥认证多一个读文件的步骤:
// 第二个参数是私钥口令,没有口令就传 null var keyFile = new PrivateKeyFile(@"C:\keys\deploy_rsa", "******"); var ci = new ConnectionInfo("10.0.0.10", 22, "deploy", new PrivateKeyAuthenticationMethod("deploy", keyFile));键盘交互认证是很多人忽略的一种,实际上不少加固过的服务器只开这个:
var kbd = new KeyboardInteractiveAuthenticationMethod("ops"); kbd.AuthenticationPrompt += (sender, e) => { foreach (var prompt in e.Prompts) { // 服务端可能依次问 Password、Verification code,按关键字应答 if (prompt.Request.Contains("assword", StringComparison.Ordinal)) prompt.Response = "******"; else if (prompt.Request.Contains("code", StringComparison.OrdinalIgnoreCase)) prompt.Response = "123456"; } }; var ci = new ConnectionInfo("10.0.0.10", 22, "ops", kbd);注意Request里带的是服务端原文,可能是Password:、password:、Password for ops:,用大小写敏感的全字匹配会漏。我一般用Contains加OrdinalIgnoreCase,虽然不够严谨,但比匹配不上导致认证卡死强。
多种方式可以组合,按传入顺序尝试:
var ci = new ConnectionInfo("10.0.0.10", 22, "ops", new PrivateKeyAuthenticationMethod("ops", keyFile), // 先试密钥 new KeyboardInteractiveAuthenticationMethod("ops")); // 密钥不行再走交互顺序有讲究。密钥方式失败通常很快,交互方式可能要等用户输入超时,把快的放前面能显著缩短失败路径的耗时。
3.3 私钥格式与口令:最常翻车的一段
PrivateKeyFile构造函数抛Invalid private key file的概率,比这个库其他所有异常加起来都高。原因是私钥格式有好几代:老式 PEM(-----BEGIN RSA PRIVATE KEY-----)、OpenSSH 新格式(-----BEGIN OPENSSH PRIVATE KEY-----)、PuTTY 的.ppk。较新的 OpenSSH 客户端默认生成的是新格式,内部用 bcrypt 做 KDF、aes256-ctr 加密,部分库版本读不了。
遇到这个报错,先确认格式:
head -1 ~/.ssh/id_rsa如果输出是-----BEGIN OPENSSH PRIVATE KEY-----,而库版本较老,两条路:换一把 RSA 密钥专门给这个用途,或者把密钥转成 PEM。
# 转成传统 PEM 格式,-N "" 表示新口令为空 ssh-keygen -p -m PEM -f ~/.ssh/id_rsa -N ""注意-m PEM对 ed25519 密钥不生效,因为 PEM 容器本身不支持这个算法。用 ed25519 的话只能升级库,或者另配一把 RSA 密钥。我的做法是给程序化访问单独生成一把 RSA 3072 的密钥,和人工登录用的密钥分开,既绕开格式问题,也方便单独吊销。
口令传 null 和传空字符串在部分版本里行为不一致,无口令的私钥统一传null,别传""。
4. 命令执行与文件传输:SshCommand、ShellStream、SftpClient 的分工
库里有三条主要的数据通道,选错了会写出很难维护的代码。SshCommand适合一次性的、有明确结束点的命令;ShellStream适合需要模拟终端交互的场景;SftpClient专管文件。三者都建立在同一个SshClient连接之上,但生命周期管理方式不同。
4.1 SshCommand:退出码、stderr 与大输出的处理
最简单的用法是Execute(),它一次把 stdout 全部读进内存返回。命令短、输出小的时候没问题,输出几十兆的日志就开始吃内存了。
using var cmd = client.CreateCommand("tail -n 5000 /var/log/app/app.log"); cmd.CommandTimeout = TimeSpan.FromSeconds(60); // 流式读取,避免一次性把输出全部驻留内存 var async = cmd.BeginExecute(); using var reader = new StreamReader(cmd.OutputStream, Encoding.UTF8); var buffer = new char[8192]; while (true) { var read = reader.Read(buffer, 0, buffer.Length); if (read > 0) { Console.Write(new string(buffer, 0, read)); continue; } // 没有数据可读时看命令是否已结束,避免死循环 if (async.IsCompleted) break; Thread.Sleep(50); } cmd.EndExecute(async); // 走流式之后,stderr 要单独取 Console.WriteLine($"exit={cmd.ExitStatus} err={cmd.Error}");这段代码有个关键点:BeginExecute()之后必须持续消费OutputStream。远端进程写 stdout 是通过一个有限缓冲的通道传回来的,本地不读,缓冲区满了远端就阻塞在 write 上,命令永远结束不了,最后表现为卡到CommandTimeout。这个坑的症状特别有迷惑性——你会以为是网络慢或者命令本身有问题。
Execute()的另一个局限是它只返回 stdout,stderr 要单独从cmd.Error取。而cmd.Error在流式模式下是命令结束后才完整的,中途读不到。如果业务依赖 stderr 做实时判断,就得用ShellStream或者把 stderr 重定向进 stdout(2>&1)。
4.2 ShellStream:需要交互式会话时
有些命令没法一次性执行完,比如要输入 sudo 密码、要回答确认提示、要进一个交互式客户端。这时候用ShellStream模拟一个伪终端。
// 参数:终端类型、列、行、像素宽、像素高、缓冲区大小 using var shell = client.CreateShellStream("xterm", 120, 40, 1200, 800, 8192); // 有些系统需要 -S 从 stdin 读密码,有些直接读 tty,先试 -S shell.WriteLine("sudo -S systemctl restart app.service"); Thread.Sleep(300); shell.WriteLine("******"); var deadline = DateTime.UtcNow.AddSeconds(20); var output = new StringBuilder(); while (DateTime.UtcNow < deadline) { if (shell.DataAvailable) { output.Append(shell.Read()); if (output.ToString().Contains("active (running)")) break; } else { Thread.Sleep(100); } } Console.WriteLine(output.ToString());ShellStream的代价是它没有退出码,也没有清晰的命令边界。你只能靠轮询DataAvailable加字符串匹配来判断执行结果,脚本里一旦多了就会变成一堆脆弱的Sleep和Contains。我的原则是:能用SshCommand解决的绝不用ShellStream,只有确实需要 tty 语义(sudo 密码、交互式确认、进度条解析)时才降级到它。
Read()是非阻塞的,没有数据时返回空字符串,所以循环里必须配Sleep或者用DataAvailable判断,否则会把 CPU 打满。
4.3 SftpClient:上传下载、目录遍历与追加写
SFTP 是独立于命令通道的一条连接,需要单独建对象。
using var sftp = new SftpClient(host, 22, "deploy", "******"); // 单次操作的超时,大文件必须调大,默认值撑不住 sftp.OperationTimeout = TimeSpan.FromMinutes(10); sftp.Connect(); // 上传,第三个参数 canOverride 为 true 时覆盖同名文件 using (var fs = File.OpenRead(@"D:\pkg\app.tar.gz")) { sftp.UploadFile(fs, "/opt/release/app.tar.gz", true); } // 遍历目录并过滤,SftpFile 带 Name、Length、LastWriteTimeUtc、IsDirectory foreach (var entry in sftp.ListDirectory("/opt/release")) { if (entry.Name is "." or ".." || entry.IsDirectory) continue; Console.WriteLine($"{entry.Name}\t{entry.Length}\t{entry.LastWriteTimeUtc:yyyy-MM-dd HH:mm:ss}"); } // 断点续传:先按追加模式打开远端文件,再把本地流定位到对应位置 long uploaded = sftp.Exists("/opt/release/app.tar.gz") ? sftp.GetAttributes("/opt/release/app.tar.gz").Size : 0; using (var local = File.OpenRead(@"D:\pkg\app.tar.gz")) using (var remote = sftp.OpenWrite("/opt/release/app.tar.gz", FileMode.Append)) { local.Seek(uploaded, SeekOrigin.Begin); local.CopyTo(remote); }OperationTimeout是 SFTP 里最容易被低估的参数。它管的是单次读写操作的超时,传大文件时如果链路带宽一般,默认值很容易在中途触发超时异常,前面的传输全废。调大是第一步,第二步是在业务层做分块加续传。上面那段续传逻辑依赖GetAttributes拿到已传字节数,再用OpenWrite的FileMode.Append追加——注意这个追加是远端文件指针语义,本地流必须自己Seek到对应位置,两边不同步会写出损坏的文件。
ListDirectory返回的是惰性枚举,遍历过程中如果连接断了会在迭代时抛异常,所以要么先ToList()物化,要么在 try 块里遍历。
5. 排查与避坑:五类最常见的翻车现场
下面这些是我在实际项目里反复遇到的,按“现象 → 原因 → 解决”整理。每一条都对应过至少一次线上问题,不是理论推演。
5.1 连接阶段:认证方式不匹配与主机密钥
现象:SshConnectionException: No suitable authentication method found to complete authentication,但用同样的账号密码在命令行里ssh能登进去。
原因:服务端配置里关掉了PasswordAuthentication,只允许 publickey;或者客户端传的认证方法因为密钥读取失败被静默跳过,最后只剩一个密码方法在试。
解决:先确认服务端允许哪些方式,把客户端的认证方法列表和服务端对齐。密钥方式要把PrivateKeyFile的构造放在 try 块里单独捕获,别让它和外层连接异常混在一起。
现象:连接成功但没有任何主机密钥校验,换一台伪造的服务端也能连上。
原因:库默认不校验主机密钥,HostKeyReceived事件不订阅就等于跳过校验。
解决:订阅事件,比对预置的指纹,不匹配就拒绝:
client.HostKeyReceived += (sender, e) => { // 指纹在首次人工确认后写进配置,不要每次自动信任 var expected = "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; e.CanTrust = string.Equals(e.FingerPrintSHA256, expected, StringComparison.Ordinal); };5.2 执行阶段:卡死、乱码与输出截断
现象:Execute()调用后一直不返回,直到CommandTimeout抛超时。
原因:远端命令在等标准输入。典型的是不带-n的ssh、需要密码的sudo、无参数的cat、等待确认的包管理命令。
解决:给命令加上非交互参数(sudo -n、apt-get -y),或者把标准输入显式关闭。确认命令确实需要交互时改用ShellStream。
现象:返回的中文全是问号或乱码。
原因:ConnectionInfo.Encoding和服务端 locale 不一致,或者服务端没设LANG。
解决:服务端设LANG=en_US.UTF-8是首选,客户端改Encoding是兜底。两边都改最稳,只改一边经常出现“命令输出正常但文件名乱码”的半吊子状态。
现象:cmd.ExitStatus是null。
原因:命令还没真正结束就取了属性,或者远端 shell 没有返回 exit-status(某些非标准 shell 会这样)。
解决:Execute()是同步的,正常情况下不会有这个问题;用流式模式时要在EndExecute之后再读ExitStatus。实在拿不到就靠输出内容判断,但要接受它的不可靠性。
5.3 传输与资源:句柄泄漏与并发冲突
现象:服务跑一天后开始报 socket 相关错误,重启就恢复。
原因:SshClient、SftpClient、SshCommand都没有Dispose,底层 socket 和通道句柄持续累积。
解决:全部套using。SshClient上的CreateCommand()返回的对象也实现了IDisposable,同样要释放,这一点经常被漏掉。
现象:多线程共用一个SshClient时抛Session is already in use或者数据串台。
原因:SshClient不是线程安全的,一个连接上并发创建多个命令对象,通道复用会冲突。
解决:按“每线程一个连接”或者做连接池,池的 key 用 host + port + user。并发量不大时,简单的ConcurrentBag加健康检查就够了,别一上来就上复杂的池化组件。
现象:大文件上传到 80% 左右失败,重试又从零开始。
原因:OperationTimeout太短,或者中间网络设备掐了空闲连接。
解决:调大超时、开KeepAliveInterval、实现分块追加写。三件事一起做才有效,只做一件在长链路上依然会断。
6. 进阶:连接复用、三层超时与稳定性验证
把功能跑通只是及格线,真正决定这套东西能不能长期待在生产线上的,是连接复用和失败恢复。我一般会在项目里固定做三件事。
第一件是把超时拆成三层,分别设值,不要指望一个参数管到底:
| 层级 | 参数 | 建议值 | 管什么 |
|---|---|---|---|
| 建连 | ConnectionInfo.Timeout | 10~15 秒 | TCP 连接与 SSH 握手 |
| 命令 | SshCommand.CommandTimeout | 按业务 30~120 秒 | 单条命令从下发到结束 |
| 传输 | SftpClient.OperationTimeout | 5~10 分钟 | 单次 SFTP 读写操作 |
三层都设了之后,任何一层出问题都能快速定位到具体阶段,而不是笼统地“连不上”。
第二件是连接复用。频繁建连握手在批量场景下开销很明显,尤其密钥交换涉及大数运算。我的做法是按目标主机缓存SshClient,用SemaphoreSlim限制单连接的并发命令数,取用前检查IsConnected,不健康就丢弃重建:
public sealed class SshConnectionPool : IDisposable { private readonly ConcurrentDictionary<string, SshClient> _clients = new(); private readonly SemaphoreSlim _gate = new(4); // 单连接最多 4 个并发命令 public async Task<string> ExecuteAsync(string key, ConnectionInfo ci, string command, CancellationToken ct) { await _gate.WaitAsync(ct); try { var client = _clients.GetOrAdd(key, _ => { var c = new SshClient(ci); c.KeepAliveInterval = TimeSpan.FromSeconds(30); c.Connect(); return c; }); // 连接可能被中间设备掐断,取用时校验一次 if (!client.IsConnected) { client.Dispose(); client = new SshClient(ci); client.Connect(); _clients[key] = client; } using var cmd = client.CreateCommand(command); cmd.CommandTimeout = TimeSpan.FromSeconds(60); return cmd.Execute(); } finally { _gate.Release(); } } public void Dispose() { foreach (var c in _clients.Values) c.Dispose(); _gate.Dispose(); } }_gate的初值直接决定单连接上能并行多少条命令,设太大通道会争用,设太小批量任务排队。四到八之间是比较稳的区间,具体看单条命令的耗时。
第三件是稳定性验证。我会写一个自检程序,循环执行“连一次、跑一条echo、传一个几 KB 的小文件、断开”,每 30 秒一轮,跑满 24 小时,记录失败次数和失败时的异常链。这个自检能提前暴露三类问题:空闲连接被中间设备回收、KeepAlive 间隔不够、SFTP 通道在长时间空闲后失效。血泪经验是,这三类问题在功能测试阶段一个都不会出现,只在生产环境跑了几小时之后才开始冒。
从那以后我每次接这类远程执行的需求,都会先把自检程序挂上去跑一夜,再谈业务逻辑。多花的那点时间,比半夜被叫起来查“为什么定时任务没执行”划算太多。希望帮到你。
本文还有配套的精品资源,点击获取