简介:面向使用C#开发SSH连接、远程运维或Linux自动化工具的.NET开发者,这份更新版SSH.NET库针对旧版组件在高加密级别系统上连接失败的问题提供替换方案,尤其适用于需要适配OpenEuler22.0.3等较新服务端环境的项目。压缩包共20个文件,包含9个dll程序集、9个xml文档、1个p7s签名文件和1个nupkg包,整体约6.55MB,覆盖多目标框架的类库与配套说明,便于按项目框架选取引用并核对API。其中程序集按多套框架目录组织,文档可用于查看接口注释与方法签名,签名及包文件便于校验来源和接入依赖管理流程。资源已有774人学习下载,可用于排查SSH握手、加密算法协商和连接兼容性问题,也适合旧项目升级时参考,减少自行编译、版本比对与环境验证成本,并降低升级与排查风险。
1. C# 里连 Linux 跑命令:Renci.SshNet.dll 到底解决什么问题
运维脚本里最容易被低估的一段代码,是 C# 客户端连 Linux 主机执行命令那段。很多人第一反应是起个进程去调系统的 ssh 客户端,把密码拼在参数里,结果密码在进程列表里裸奔,返回值还得靠解析标准输出。Renci.SshNet.dll(NuGet 包名 SSH.NET)把 SSH2 协议做成了纯托管实现,不依赖目标机器或本机装没装 ssh 客户端,Windows 上的 .NET 程序和 Linux 上的 .NET 程序都能直接连过去执行命令、走 SFTP 传文件、拿回结构化退出码。它适合做批量部署工具、日志采集器、配置巡检脚本这类需要长期驻留、需要拿退出码做判断的场景,不适合当交互式终端用。下面按引库、连机、执行、传文件、排错的顺序拆一遍,中间会穿插几个我用血泪经验换来的参数。
2. 引库与首次连通性检查:dll 怎么进项目,连不上先看哪三处
2.1 NuGet 优先,手工拖 dll 要算清的三笔账
新项目没什么好纠结的,一条命令解决:
dotnet add package SSH.NET装完 csproj 里会多一行PackageReference,锁定版本号。老项目如果是 packages.config 那套,用Install-Package SSH.NET也行,思路一样。
<ItemGroup> <PackageReference Include="SSH.NET" Version="2024.*" /> </ItemGroup>提示:版本号别追最新预览版。SSH.NET 的预览包偶尔会改
SftpClient的上传回调签名,生产项目用稳定版,手工锁版本。
真正需要手工引Renci.SshNet.dll的场景只有三种:目标机器不能连外网、甲方要求所有依赖进源码仓、以及 Unity 那种包管理跟不上的环境。手工引用要算三笔账。第一笔是目标框架,SSH.NET 主线版本的目标是 netstandard2.0,往下能覆盖 .NET Framework 4.6.1 及以上,往上是 .NET 6/8,.NET Framework 4.0 这种老古董得退到很旧的版本,而且没有异步 API。第二笔是依赖链,.NET Framework 项目里手工引 dll 之后,往往还要补System.Numerics之类的程序集引用,报错信息是Could not load file or assembly,第一次遇到能查半天。第三笔是版本漂移,dll 进了源码仓就没人管了,下次升级时谁也不知道当初为什么锁这个版本,注释里写清楚。
| 目标框架 | 能否直接用主线版本 | 备注 |
|---|---|---|
| .NET Framework 4.6.1+ | 可以 | 手工引用需补程序集依赖 |
| .NET Core 3.1 / .NET 5/6/7/8 | 可以 | 推荐 NuGet 引用 |
| netstandard2.0 类库 | 可以 | 被上层项目间接引用 |
| .NET Framework 4.0 | 需降级老版本 | 缺异步 API,不建议新写 |
| 单文件发布 + 裁剪 | 谨慎 | 反射用得多,裁剪可能误删 |
裁剪那条单独说一句。开了PublishTrimmed之后,SSH.NET 里靠反射构造加密算法的路径可能被裁掉,表现为连接时抛PlatformNotSupportedException或找不到算法。解决方式是加TrimmerRootAssembly把 SSH.NET 保住,或者在发布配置里直接关裁剪。这个小坑我在一个边缘设备项目上翻过车,运行时报的错跟网络超时长得一模一样,查了两天才定位到裁剪。
2.2 一个最小可跑的连通性检查
先别急着写业务,先把「能不能连上、能不能拿到退出码」验证掉。下面这段是每次接新环境我都先跑的骨架:
using Renci.SshNet; // 认证方式二选一:密码 或 私钥 var auth = new PrivateKeyAuthenticationMethod( "deploy", new PrivateKeyFile(@"C:\keys\deploy_ed25519", "your-passphrase")); var conn = new ConnectionInfo("10.0.0.21", 22, "deploy", auth); conn.Timeout = TimeSpan.FromSeconds(15); // 只管建连与握手 using var client = new SshClient(conn); client.KeepAliveInterval = TimeSpan.FromSeconds(30); // 首次接入必须比对指纹,别偷懒跳过 client.HostKeyReceived += (_, e) => { var fp = "SHA256:" + Convert.ToBase64String(e.FingerPrintSHA256).TrimEnd('='); e.CanTrust = fp == "SHA256:你从运维那拿到的指纹"; }; client.Connect(); using var cmd = client.CreateCommand("bash -lc 'systemctl is-active nginx'"); cmd.CommandTimeout = TimeSpan.FromSeconds(20); var stdout = cmd.Execute(); Console.WriteLine($"rc={cmd.ExitStatus} out={stdout.Trim()} err={cmd.Error.Trim()}"); client.Disconnect();代码不长,几个点值得逐个拆。HostKeyReceived不挂的话默认信任任何主机密钥,等价于关掉了中间人校验,内网图省事可以,但跨网段批量执行必须挂上,把指纹写进配置。bash -lc这个包装不是可有可无的,SSH 的非交互 exec 通道不会去读.bashrc和/etc/profile.d,环境变量和 PATH 跟登录终端里完全不同,很多「明明命令行能跑,代码里就 command not found」都是这个原因,-l让它走一次登录 shell 初始化。KeepAliveInterval默认 60 秒,长时间挂着的采集器建议调到 15 到 30 秒,中间有 NAT 或防火墙空闲断流的,这个值决定你多久发现连接已经死了。
2.3 三个超时参数别混用
新手最容易把所有超时都塞进一个地方,然后发现「设了 5 秒还是要等半分钟」。SSH.NET 里至少有三个超时各管各的:
ConnectionInfo.Timeout:TCP 建连加协议握手,默认 30 秒。设小了在跨机房链路上会误杀。SshCommand.CommandTimeout:单条命令的执行上限,默认无限。批量脚本必须显式设,否则一条卡住的tar能把线程吊死。SshClient.KeepAliveInterval:心跳间隔,不是超时,作用是在空闲期维持会话。
还有一个隐形的:DNS 解析。主机名解析如果走的是系统解析器,卡在 DNS 上的时间不受ConnectionInfo.Timeout约束。生产代码里我一般先用Dns.GetHostAddresses自己解析并计时,拿 IP 去建连,出问题时能明确区分是解析慢还是网络不通。
认证方式的构造也有讲究。密码认证是PasswordAuthenticationMethod,私钥是PrivateKeyAuthenticationMethod,两个都传进去会按顺序尝试,登录失败次数多了会触发服务器的MaxAuthTries,反而把自己锁一会儿。建议一次只传一种。
3. 命令执行的三种写法:RunCommand、ShellStream 与 sudo 提权
3.1 RunCommand 与 CreateCommand 的差别,以及退出码怎么读
RunCommand是CreateCommand加Execute的语法糖,一行写完很爽,但它返回的SshCommand是个IDisposable,不释放会在客户端上累积通道。批量循环里我坚持用显式写法:
using var cmd = client.CreateCommand("bash -lc 'tar -tzf /opt/app/releases/app.tar.gz > /dev/null'"); cmd.CommandTimeout = TimeSpan.FromMinutes(5); var stdout = cmd.Execute(); // 阻塞直到结束或超时 var exit = cmd.ExitStatus; // 正常结束才有意义 var stderr = cmd.Error; if (exit != 0) { throw new InvalidOperationException($"打包校验失败 rc={exit}: {stderr.Trim()}"); }ExitStatus只在命令正常退出时有值,命令超时抛SshCommandExecutionException,连接断了抛SshConnectionException,这两种情况下读到的是 -1 或者干脆读不到。所以判断成功与否的顺序是:先看有没有异常,再看ExitStatus == 0,最后才看输出内容。只 grep 输出文本判断成败的做法,迟早会栽在一条「错误信息里恰好包含 success 字样」的日志上。
还有一个常见误判:用;拼接多条命令时,退出码是最后一条的。cd /tmp; rm -rf xxx里cd失败了你根本不知道。要么用&&串联,要么一条一条发,要么在脚本头加set -e。
3.2 ShellStream:需要 tty 的场景和 sudo 密码怎么喂
有些操作在非交互通道里根本做不了,典型的就是sudo开了requiretty的机器,报错是sudo: sorry, you must have a tty to run sudo。这时候得开一个带伪终端的会话:
using var shell = client.CreateShellStream("xterm", 120, 30, 1200, 300, 8192); shell.WriteLine("sudo -S -p '' systemctl restart nginx"); // sudo -S 从 stdin 读密码,-p '' 把提示符清掉免得干扰匹配 var hint = shell.Expect("password", TimeSpan.FromSeconds(5)); if (hint != null) { shell.WriteLine("your-sudo-password"); } var tail = shell.Expect("$", TimeSpan.FromSeconds(20)); if (tail == null) { throw new TimeoutException("命令未在预期时间内返回提示符"); }Expect是按字符串匹配的阻塞读,超时返回 null 而不是抛异常,所以每个Expect后面都得自己判空。密码明文写在代码里肯定不行,从配置中心或者环境变量取,用完记得把局部变量清掉。另外-p ''这个技巧很实用,默认提示符是[sudo] password for deploy:,中间夹着用户名,写匹配串的时候很容易因为用户名变化而失配,清空提示符最省事。
ShellStream的另一个用途是跑那些必须看交互提示的命令,比如首次 SSH 到某台机器要确认指纹、或者某个安装脚本要按回车。但要注意它的输出是流式的,带 ANSI 控制字符,Expect拿到的东西得自己清洗,别直接拿去解析。
3.3 编码、换行与引号:三个天天踩的小坑
编码问题表现为中文输出变成问号或者乱码。SSH.NET 默认用 UTF-8 解码,而 Linux 那边如果LANG没设或者设成C/POSIX,命令输出可能是 ASCII 或者别的编码。稳妥做法是在命令前显式指定:
using var cmd = client.CreateCommand( "bash -lc 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; locale'");如果目标机器没装 en_US.UTF-8 语言包,locale会报警告但不影响执行。要输出中文日志的,装zh_CN.UTF-8再切过去。别指望在 C# 侧靠Encoding.GetEncoding("GBK")硬转,源头编码不对,转了也是错的。
换行的问题出在ShellStream上。WriteLine发出去的是\r\n,某些精简 shell 或者 busybox 环境下会把\r当成命令的一部分,报command not found: xxx\r。遇到这种就改用shell.Write("cmd\n")手动控制。SshCommand走的是 exec 通道,不存在这个问题。
引号是最容易出事的地方。命令字符串在 C# 里是一层转义,进了 bash 又是一层。路径里带空格、带单引号、带$的,拼字符串必翻车。我的习惯是先把参数做一次单引号包裹并把内部单引号替换成'\'',再拼进命令,或者干脆把脚本内容通过 SFTP 传上去再bash /tmp/xxx.sh执行,绕开所有转义问题。后者在参数超过三四个的时候几乎总是更划算。
4. SFTP 传文件:路径、权限与进度回调的实际写法
4.1 路径分隔符与目录判断:Exists 为什么对目录返回 false
SftpClient的路径永远用正斜杠,哪怕跑在 Windows 上。用Path.Combine拼出来的反斜杠会被当成文件名的一部分,然后报文件不存在。我一般在入口处做一次归一化:
static string RemoteJoin(string basePath, params string[] parts) => string.Join("/", new[] { basePath.TrimEnd('/') } .Concat(parts.Select(p => p.Trim('/'))));Exists这个方法有个反直觉的行为:它对目录返回 false,对文件才返回 true。原因在于它内部是靠GetAttributes加IsDirectory判断的,历史实现就是这么写的。判断目录存不存在得用ListDirectory包一层 try,或者直接GetAttributes看IsDirectory:
bool DirExists(SftpClient sftp, string path) { try { return sftp.GetAttributes(path).IsDirectory; } catch (SftpPathNotFoundException) { return false; } }用Exists判断目录然后决定要不要创建,逻辑永远是「不存在」然后去CreateDirectory,第二次运行就抛「目录已存在」。这个 bug 我在两个项目里见过同一份代码,说明它足够普遍。
4.2 流式上传下载与进度回调
大文件别用UploadFile(string localPath, string remotePath)那个重载,它内部会把文件整个读进内存或者用默认缓冲区,进度也不可见。用Stream重载加回调:
using var sftp = client.CreateSftpClient(); // 复用已有 SSH 连接 sftp.Connect(); sftp.Encoding = System.Text.Encoding.UTF8; using var fs = File.OpenRead(@"D:\build\app.tar.gz"); sftp.UploadFile(fs, "/opt/app/releases/app.tar.gz", true, uploaded => { var pct = uploaded * 100.0 / fs.Length; Console.Write($"\r上传 {pct:F1}% "); }); Console.WriteLine(); var attrs = sftp.GetAttributes("/opt/app/releases/app.tar.gz"); Console.WriteLine($"远端大小 {attrs.Size} 字节");UploadFile的第三个参数canOverride决定同名文件是否覆盖,传 false 会抛SftpPermissionDeniedException,比静默覆盖安全。回调参数是ulong类型的已传字节数,不是百分比,别搞错。下载方向对称,DownloadFile(remotePath, stream, callback),但要注意目标流必须是可写的,用File.Create而不是OpenRead。
client.CreateSftpClient()这个写法比new SftpClient(connectionInfo)好,因为它复用同一条 SSH 连接上的通道,不用重新握手,批量传小文件时差别很明显。代价是它跟SshClient的生命周期绑定,SshClient释放了它也不能用了。
4.3 权限、时间戳与覆盖策略
传上去的文件默认权限是服务端 umask 决定的,常见是 0644,可执行脚本会变成不可执行,然后bash: Permission denied。手动改:
// ChangePermissions 收的是十进制 short,八进制 755 要先转 short mode = Convert.ToInt16("755", 8); sftp.ChangePermissions("/opt/app/releases/deploy.sh", mode);Convert.ToInt16("755", 8)得到 493,这才是 API 要的值。直接传755进去会被当成十进制,改出来的权限是01363,一个谁都不认识的组合,而且不报错。这个坑我见过不止一次。
时间戳在增量同步场景很重要。SFTP 协议支持设置文件属性,但 SSH.NET 没有直接暴露设置 mtime 的简单方法,GetAttributes读得到LastWriteTimeUtc,写要靠SetAttributes构造SftpFileAttributes。实际项目里我一般不去动它,增量判断改用文件大小加 SHA256 校验,比时间戳可靠——时钟不同步、时区没对齐的机器上,基于时间戳的同步逻辑一定出过事。
覆盖策略上,生产环境的发布目录我坚持「先传到临时目录,校验通过再mv覆盖」,避免传输中断留下半个文件。mv在同分区是原子的,这一点比任何重试逻辑都管用。
5. 避坑与排查:连接超时、密钥被拒、句柄泄漏这几类问题
5.1 连接阶段:超时到底是哪一层超了
现象:ConnectionInfo.Timeout设成 5 秒,局域网里好好的,跨机房必抛SshConnectionException,信息是The connection was closed by the server。
原因:这个超时管的是建连加握手,跨机房链路上 RTT 大,密钥交换和算法协商本身的往返次数就多,加上中间安全设备对长连接的处理,5 秒根本不够。另外SshConnectionException这条信息也可能是认证阶段被服务端主动断开,比如MaxStartups到了上限,措辞跟网络超时几乎一样。
解决:分三层设置。ConnectionInfo.Timeout给 15 到 30 秒;命令超时单独设;再在外面套一个自己的CancellationToken或Task.WhenAny兜底。报错时把目标主机、端口、当前时间点一起打进日志,三次以上失败直接告警,别让它默默重试一小时。
5.2 认证阶段:同一个私钥命令行能进,代码里就不行
现象:Permission denied (publickey),用系统 ssh 客户端加同一个 key 文件能登进去。
原因通常有三个。一是格式,PuTTY 生成的.ppk文件 SSH.NET 不认,得先转成 OpenSSH 格式。二是私钥带 passphrase,构造PrivateKeyFile时没传第二个参数,或者传错了。三是用户名不一致,私钥本身不绑定用户,绑定关系在服务端authorized_keys里,ConnectionInfo里的用户名必须和 key 所属的账户对得上。
解决:转格式用puttygen key.ppk -O private-openssh -o key_openssh,一步搞定。passphrase 从密钥管理服务取,别写死在配置里。排查时打开 SSH.NET 的日志或者临时改用密码认证,把问题范围先缩小到「认证方式」还是「密钥内容」。另外服务端sshd_config里PubkeyAuthentication关掉的机器,表现也是这个错,值得顺手确认一眼。
注意:私钥文件在 Linux 上的权限必须是 0600,权限过宽(比如 0644)会被 OpenSSH 一侧的严格模式拒绝。虽然 SSH.NET 自己不做这个检查,但服务端可能因为其他原因拒绝你,排查时权限位要一起看。
5.3 执行阶段:命令「成功」了但业务是失败的
现象:cmd.Execute()没抛异常,ExitStatus是 0,但服务其实没起来。
原因:命令用了管道,退出码取的是管道最后一个命令的。systemctl is-active nginx | grep active在 nginx 没起来时,grep匹配不到会返回 1,但如果反过来写成grep active | cat,cat永远返回 0,失败就被吞了。还有一种是用;串联了多条命令,只看最后一条的码。
解决:命令头加set -o pipefail,让管道的退出码取第一个失败的命令;或者把判断逻辑写进脚本文件,脚本里显式exit 1。我现在的习惯是,凡是需要在代码里判断成败的操作,都写成独立的.sh放到仓库里,通过 SFTP 传上去执行,而不是在 C# 里拼一长串命令。脚本能本地测试,命令字符串不能。
5.4 资源阶段:跑一天以后连接数打满
现象:程序跑几小时后新连接全部失败,服务端sshd日志里能看到大量会话堆积,who一看几十个残留。
原因:SshClient、SshCommand、SftpClient都是IDisposable,异常路径上漏掉using就会泄漏。特别是RunCommand这个便捷方法,很多人以为它内部会自己清理,实际上返回的命令对象还是要你自己释放。另外循环里反复new SshClient()而不复用,每次都是完整握手,服务端MaxStartups很快撑满。
解决:所有创建出来的对象一律using,异常也要走finally;长驻程序维护一个连接池,按目标主机分桶,每桶限制并发通道数(我的经验值是单连接 5 到 10 个通道);再加一个空闲回收线程,超过 N 分钟没用的连接主动Disconnect并Dispose。这套下来,单机稳定维持几百台设备的巡检毫无压力。
5.5 一个隐蔽的坑:多线程共用一个 SshClient
现象:并发跑多个任务时偶发InvalidOperationException或者命令输出错乱,A 任务的输出出现在 B 任务的结果里。
原因:SshClient不是线程安全的,多个线程同时CreateCommand会在同一个会话上的通道分配逻辑里打架。
解决:每个线程独立的SshClient,或者用一个连接加信号量串行化通道创建。不要相信「我看它平时不报错」——这类问题只在压测或者某台机器响应慢的时候才冒出来,平时测不出来。
6. 包成可复用的执行器:连接复用、异常映射与一套自检清单
把上面的东西收拾成一个类,比每次现写一遍靠谱。核心就三件事:按目标主机缓存SshClient、给每个操作套上超时与重试、把 SSH.NET 的异常翻译成业务能懂的话。
public sealed class SshSession : IDisposable { private readonly SshClient _client; private readonly SemaphoreSlim _gate = new(8); // 单连接最多 8 个并发通道 public SshSession(string host, string user, PrivateKeyFile key) { var conn = new ConnectionInfo(host, 22, user, new PrivateKeyAuthenticationMethod(user, key)) { Timeout = TimeSpan.FromSeconds(20) }; _client = new SshClient(conn) { KeepAliveInterval = TimeSpan.FromSeconds(30) }; _client.Connect(); } public async Task<(int Code, string Out, string Err)> RunAsync(string cmd, TimeSpan timeout) { await _gate.WaitAsync(); try { using var c = _client.CreateCommand(cmd); c.CommandTimeout = timeout; var output = await Task.Run(() => c.Execute()); return (c.ExitStatus, output, c.Error); } finally { _gate.Release(); } } public void Dispose() { if (_client.IsConnected) _client.Disconnect(); _client.Dispose(); _gate.Dispose(); } }RunAsync里的Task.Run是有意为之。SSH.NET 虽然有ExecuteAsync,但底层跑的还是同步 socket,异步 API 只是把阻塞挪到线程池,不会提升吞吐。既然如此不如自己控制线程池的使用,顺便把SemaphoreSlim的并发限制加在同一层,通道数就不会失控。
异常映射这块,我整理了一张对照表贴在团队文档里,新人排查时直接查:
| 异常类型 | 高频原因 | 第一动作 |
|---|---|---|
SshConnectionException | 网络不通、握手超时、服务端拒绝 | 先telnet host 22确认端口,再看服务端日志 |
SshAuthenticationException | 密码错、私钥格式不对、用户名不匹配 | 换系统 ssh 客户端验证同一凭据 |
SshCommandExecutionException | 命令超时或通道被关闭 | 检查CommandTimeout与目标负载 |
SftpPermissionDeniedException | 目标路径无写权限、canOverride=false | 确认目录属主与 umask |
SftpPathNotFoundException | 路径拼接错误、目录未创建 | 打印完整远端路径核对斜杠 |
最后给一套上线前的自检清单,每次接新环境我都跑一遍:能连上并打印主机指纹;bash -lc 'echo $PATH'拿到的 PATH 与登录终端一致;id输出确认权限足够;SFTP 能列目录、能上传一个 1KB 文件、能改权限;故意跑一条exit 3确认退出码是 3 而不是 0;断开后ss -tnp | grep :22看不到残留连接。这六条全过,后面基本不会出玄学问题。
说个教训。早些年做一个批量发布工具,图快没做主机指纹校验,测试环境一切正常,上线后某台机器的 SSH 被中间设备接管,脚本把半成品包推了上去,服务起了但配置是错的,排查了整整一个下午。从那以后我每次接新环境,都会先把指纹比对和退出码检查这两件事强制走一遍,宁可多写二十行代码。希望帮到你。
本文还有配套的精品资源,点击获取