简介:这是一份基于C#实现的SSH连接功能半成品资源,原计划作为另一个项目中的子功能模块,现单独整理成zip压缩包供参考复用,适合有C#基础、需要在WinForms项目中集成SSH通信的开发者借鉴。压缩包共70个文件,约7.39MB,以源码、工程配置和运行库为主:6个.cs源码文件、1个.sln解决方案、1个.csproj工程文件,搭配12个dll依赖与5个config配置文件,另有可执行exe、项目安装Setup文件等,基本保留了从编译到部署所需的完整结构;其中resources、resx资源文件承载窗体界面配置,pdb文件可用于调试定位问题。附带README.md说明文档,便于快速了解项目背景与使用方式。目前已有48人学习浏览,虽然体量不大,但作为一个可运行的C# SSH连接雏形,能够帮助学习者快速复现运行环境、梳理SSH通信逻辑,也可在此模块基础上扩展出文件传输、远程命令执行或简易运维工具。
1. C# 做 SSH 连接:一个子功能被写成半成品,问题出在哪
我见过不少类似的半成品:本来是给"另一个项目"做的 SSH 子功能,结果做着做着发现坑比想象多,代码堆到一半就停在那,最后打包成一个 zip 传给别人接手。C# 连接 SSH 这个需求,落到实处的本质就三件事:认证方式对不对、通道类型选得准不准、连接生命周期管没管好。很多半成品恰恰是在这三件事上任性了一把,才导致后续根本没法往下走。
这类代码最适合的读者有两类:一类是主项目里急需"能跑通的 SSH 最小模块",不想从零碰底层协议的人;另一类是拿到一个半成品 zip,正犹豫是缝缝补补继续用,还是推倒重写的人。前者需要一套能抄能改的代码骨架,后者需要一张能快速做技术判断的验收清单。这篇文章就把两件事都讲透,从最小可用的连接类写起,到密钥认证、超时调参、断线重连,再到半成品代码的评估方法和高频踩坑点,最后落在一个能维持会话状态的交互式 shell 封装上。
2. 用 C# 连 SSH:封装一个最小可用的连接类
2.1 先搞清楚三件事:认证类型、通道类型、连接生命周期
C# 里做 SSH 连接,常见做法是用 SSH.NET 库,它把底层传输、加密、认证都封装好了,你不需要碰 SSH 协议细节,只要关心怎么用。但正因为太方便,很多人上来就写代码,写完连不上一脸懵。我一般会先让代码回答三个问题。
认证类型:密码认证还是密钥认证。半成品里十有八九是硬编码密码,写死在代码里,连配置项都没抽出来。这个设计问题比想象严重,后面换密钥、换密码全要改代码。通道类型:你是要执行完一条命令就断开,还是要保持一个交互式 shell 会话?SSH.NET 里有 SshClient.RunCommand 和 SshClient.CreateShellStream 两条路,前者适合跑一次性命令,后者适合登录交换机、进交互程序。连接生命周期:SSH 连接不是 HTTP 那种短连接,一个连接对象有连接、认证、会话、断开几个阶段,Dispose 没写对,程序跑几次就耗尽资源。
这三件事在动手前想清楚,能过滤掉一半的坑。下面按密码认证的最小闭环先写一个能跑的版本。
2.2 最小代码:密码认证能跑通的最小类
先给一个最少依赖、能跑通的类。实际项目中我不建议把密码写死在代码里,但作为最小验证,它足够直白。
using Renci.SshNet; public class SshConnector { private readonly string _host; private readonly int _port; private readonly string _username; private readonly string _password; private SshClient _client; public SshConnector(string host, int port, string username, string password) { _host = host; _port = port; _username = username; _password = password; } public void Connect() { var connectionInfo = new ConnectionInfo( _host, _port, _username, new PasswordAuthenticationMethod(_username, _password) ); _client = new SshClient(connectionInfo); _client.Connect(); } public string RunCommand(string command) { using var cmd = _client.CreateCommand(command); var result = cmd.Execute(); return result; } public void Disconnect() { _client?.Disconnect(); _client?.Dispose(); } }这个类的核心在 Connect 方法里:ConnectionInfo 是连接描述,PasswordAuthenticationMethod 明确告知客户端使用密码认证。Connect 是阻塞操作,连不上会在这里抛异常。RunCommand 用 CreateCommand 创建命令通道,Execute 执行后返回标准输出字符串。
参数说明:host 是目标机 IP 或域名;port 不填时 SSH 默认是 22,填了就走自定义端口;username 和 password 在最小验证阶段直接传值,生产环境务必从配置或环境变量读取。Connect 失败时通常抛的是 SshConnectionException 或 SocketException,这两者的区分是后面排查的第一步。
这里的坑在于:很多人以为 Execute 能拿到所有输出,实际上当命令有大量错误信息时,标准输出和标准错误是分开的。上面这段代码只取了标准输出,真实场景要同时拿到 Error 字段才够用。
2.3 命令执行通道:一次连接多命令的写法
最小类里一次连接只能跑一条命令,这在真实场景不够用。SSH.NET 允许多个命令通道复用同一个 SshClient 实例,只要连接不断,可以连续创建命令。
public List<string> RunCommands(List<string> commands) { var results = new List<string>(); foreach (var command in commands) { using var cmd = _client.CreateCommand(command); var output = cmd.Execute(); results.Add(output); } return results; }复用连接是性能关键。如果每条命令都重新 Connect 一次,光握手加认证就要多花几百毫秒到几秒,批量操作时会累积到不可接受的程度。但复用连接也有边界:命令通道用完必须 Dispose,否则连接里的会话对象一直占着;另外每条命令的 Execute 是串行的,不可以在同一个连接上并行执行多条命令。
参数说明:commands 的每一条都是一个完整的 shell 命令字符串,比如 "df -h"、"/etc/init.d/nginx status" 这种。注意不要传入需要交互的命令,比如 sudo 默认是要输密码的,用 RunCommand 得不到结果,会卡到你怀疑人生。
2.4 参数说明与常见误用
刚写 SSH.NET 时,有个高频误用是:把 SshClient 当成线程安全的对象。这个库不是线程安全的,多个线程共享一个 SshClient 实例去并发调 RunCommand,轻则输出串位,重则直接抛异常。正确做法是每个线程创建自己的连接,或者用锁把命令执行串行化。
另一个误用是端口参数传成字符串。SSH.NET 的构造器里端口是 int,传字符串会在运行时做类型转换失败,而不是编译时报错。半成品代码里如果出现这类问题,说明作者连基本类型都没理清。
还有一个隐藏雷区:把连接生命周期和主窗体生命周期绑死。很多人把 SshClient 建在窗口构造函数里,窗口关了连接还挂着,结果下次打开连不上。SSH 连接有服务端空闲超时,长时间不活动会被服务端断开,所以"连接"必须跟着"需要用的时刻"走,而不是跟着界面走。
3. 连接参数配置:从"能连上"到"稳定不翻车"
3.1 超时参数:连接超时、读超时、操作超时各管什么
连接超时、读超时、操作超时,这三个概念经常被混为一谈。SSH.NET 里 ConnectionInfo 有一个 Timeout 属性,它管的是连接建立和认证的整体超时,默认是 30 秒。这个参数决定了从发起 TCP 连接到认证完成允许花多久。
读超时和操作超时不是同一个层面。SSH.NET 的命令执行没有单独的读超时概念,它是用 SshCommand 的 Execute 方法配合底层 socket 的读取机制工作。当目标网络不稳或服务端不返回数据时,Execute 可能长时间挂起。解决办法是给 Execute 套一层 Task 包装,用 Task.WhenAny 做超时控制。
public async Task<string> RunCommandWithTimeout(string command, int timeoutSeconds) { using var cmd = _client.CreateCommand(command); var executeTask = Task.Run(() => cmd.Execute()); var completedTask = await Task.WhenAny(executeTask, Task.Delay(timeoutSeconds * 1000)); if (completedTask != executeTask) { cmd.Dispose(); throw new TimeoutException($"命令执行超过 {timeoutSeconds} 秒"); } return await executeTask; }这个包装的核心逻辑是把 Execute 放到后台线程,同时开一个计时任务,谁先完成谁赢。超时后必须 Dispose 掉命令对象,否则底层会话会把命令残留挂着,下一条命令可能异常。这里没用取消令牌,因为 SSH.NET 的老版本对取消支持不完整,直接弃用连接比尝试取消更干净。
参数说明:timeoutSeconds 按命令类型给值,普通查询命令 5 到 10 秒够用;远程执行系统更新或备份命令,建议给 30 秒以上,否则误杀正常任务。
3.2 密钥认证:从 OpenSSH 密钥文件加载私钥的写法
密码认证写入代码迟早出事,密钥认证在很多内部系统里是标配。OpenSSH 生成的私钥格式是 PEM 的,SSH.NET 可以直接加载。关键是搞清楚私钥里有没有密码保护。
public static ConnectionInfo CreateKeyAuthConnectionInfo( string host, int port, string username, string privateKeyPath, string passphrase = null) { PrivateKeyFile keyFile; if (string.IsNullOrEmpty(passphrase)) { keyFile = new PrivateKeyFile(privateKeyPath); } else { keyFile = new PrivateKeyFile(privateKeyPath, passphrase); } var connectionInfo = new ConnectionInfo( host, port, username, new PrivateKeyAuthenticationMethod(username, new[] { keyFile }) ); return connectionInfo; }这里最常见的翻车点有两个。第一,OpenSSH 的 new format 私钥(默认生成的就是这个格式)在旧版 SSH.NET 上可能加载失败,现象是抛"invalid private key"异常。第二,私钥文件权限不对时,Windows 下可能能读但不报错,Linux 下直接拒绝加载。所以代码里最好加上文件存在性和可读性校验,别把错误留到连接阶段。
密钥认证失败时的行为也值得知道:服务端拒绝密钥后会继续尝试下一种认证方式,不会立即抛错。如果你同时配了密码和密钥两种认证,最终认证结果可能是后者的成功覆盖前者的失败,或者反过来,这会让排错变得非常绕。我的建议是只配一种认证方式。
3.3 端口、keepalive 与断线重连策略
SSH 连接放着不动,服务端默认的空闲超时一到就断开。表现是:程序跑着跑着,下一条命令执行时抛一个类似"connection is closed"的异常。处理思路分两层:预防层和恢复层。
预防层是发 keepalive 消息。SSH.NET 底层支持在 ConnectionInfo 上设置 KeepAliveInterval,单位是秒,每隔一段时间往服务端发一个空的 SSH2_MSG_IGNORE 消息。注意,这个值不是越短越好,设太短反而会干扰一些路由器或防火墙的会话状态跟踪,一般在 30 到 60 秒之间比较稳。
恢复层是重连。我的习惯是把"运行命令"和"确保连接可用"拆成两步:先检查 _client.IsConnected,为 false 就自动重连,再执行命令。如果重连也失败,才把异常抛给上层。这样应对服务端重启、网络瞬断、空闲被踢都有兜底。
public string SafeRunCommand(string command) { EnsureConnection(); using var cmd = _client.CreateCommand(command); return cmd.Execute(); } private void EnsureConnection() { if (_client == null || !_client.IsConnected) { Disconnect(); Connect(); } }这段代码的价值在于把"连接是否可用"的决策逻辑集中在一个私有方法里,所有对外入口都走它。避免每个调用点各写一堆重试判断,后面维护起来只想骂人。参数说明:重连次数和间隔建议做成可配置项,不要在程序里写死循环重连,否则服务端维护期间你的程序会疯狂打日志。
4. 半成品 zip 值不值得继续:先按这 5 个点做一次验收
4.1 验收清单:凭什么算完成
半成品之所以是半成品,往往不是因为功能不齐,而是没边界、没异常、没法在真实环境里长期跑。拿到一个"本来是做另一个项目,SSH 是子功能"的 zip,先别急着看代码能不能编译,按下面 5 个点逐项过一遍,答案自然就出来了。
第一,认证信息有没有从代码里剥离。第二,异常处理路径全不全。第三,命令执行后的输出有没有正确处理标准错误流。第四,连接什么时候建立、什么时候释放,有没有清晰的生命周期。第五,有没有处理网络层错误(超时、断线、重连)的逻辑。这五条全打过,才是能进主项目的连接模块。
4.2 半成品最容易缺的四类东西
第一种是缺配置。主机地址、端口、用户名、密码全部硬编码在主流程里,换个环境就要改代码重编译。第二种是缺错误上下文。异常只抛一个 "connect failed",完全没有内层 Exception 信息,也不记录主机和端口,线上出问题没法定位。第三种是缺资源释放。连接对象用一次 new 一次,从不 Dispose,跑几十条命令后程序内存和句柄暴涨。第四种是缺对服务端返回内容的解析。只把 Execute 的输出原样返回,什么换行、编码、命令是否真正成功都不管,下游拿到一堆裸文本自己猜。
4.3 继续用还是重写的判断方法
拿上面 4 类缺口去对照手里的 zip。缺配置和缺错误上下文,属于小毛病,补上就行,改动量可控。缺资源释放,要看代码里 SshClient 的创建位置是否集中,如果到处 new,意味着要动的地方很多,但还算能改。真正麻烦的是缺对返回内容的处理,这意味着你根本不知道现有代码在目标环境上行为对不对,不管三七二十一都只能边跑边摸。
我的底线是:如果拿到代码后连"它到底跑通没跑通过"都验证不出来,那这个半成品比没有更危险。与其在不确定的地基上添砖加瓦,不如从最小类重写。反过来讲,如果代码能在目标机上稳定跑通几条命令,且资源释放、错误处理、配置抽取这三项里至少有两项做得还行,那它就有救,改造成本低于重写。别只看代码量的大小,一个几百行但是逻辑一团乱麻的连接模块,拆起来比写还费劲。
注意:接手别人的半成品前,一定要先跑通一条真实命令,观察输出和预期是否一致。没经过这步验证,后面所有改造都是空中楼阁。
5. C# SSH 连接避坑:现象、原因、解决,5 条血泪经验
5.1 认证失败、乱码、命令粘连:前三条高频坑
坑一:认证失败但你看不到为什么。现象是 Connect 抛异常,但信息只有一句"Connection refused"或者"Authentication failed",完全没细节。原因是很多连接库默认不展开内部堆栈,你把 _client.ConnectionInfo 里的调试信息打印出来就明白了。解决:在 catch 里同时输出内层 Exception 的消息和堆栈,特别是有多个 AuthenticationMethod 时,要逐项打印每种方法的失败原因。不然你根本不知道是密码错了、密钥被服务端拒了,还是中间网络问题。
坑二:命令输出的中文乱码。现象是远程命令返回的 UTF-8 中文内容,在 C# 字符串里变成一串问号或乱码。原因是服务器发送的是带 locale 的输出,而 SSH.NET 默认按 ASCII 或系统默认编码解析。解决:在 SshClient 的 ConnectionInfo 里设置终端编码,或者拿到原始字节流后自行按 UTF-8 解码。千万别在连接后去调 Console.OutputEncoding,那只改控制台显示,改不了库内部解码。
坑三:两条命令的输出串在一起。现象是连续执行多条命令,后一条的结果里夹杂着前一条命令的输出。原因是缓冲区没有彻底清干净。SSH.NET 的 RunCommand 本身应该隔离,但当你用 shell stream 的时候,缓冲区的脏数据会留给下一条命令。解决:每次读取时循环读到底,直到无数据可读,再进入下一次命令。用 ShellStream 时尤其要注意,不要只读一段就以为读完了。
5.2 密钥格式、空闲断连:后两条低频但要命
坑四:密钥文件在 Linux 上生成的能连,在 Windows 上生成的不能连。现象是同样的 OpenSSH 密钥,换个生成工具就加载失败。原因是私钥格式差异,OpenSSH 新版默认的私钥格式是 openssh-key-v1,SSH.NET 部分版本对这种格式支持不全。解决:要么用 ssh-keygen 把私钥转成 PEM 格式,要么升级 SSH.NET 到较新版本。别去改密钥文件内容,纯属浪费时间。
坑五:程序挂机一晚上后,第二天早上第一条命令必失败。现象是抛"connection is closed"或者直接超时。原因是服务端空闲超时断了连接,但客户端状态没同步。解决:所有命令入口统一走 EnsureConnection 检查,加 keepalive 也只能延长不能杜绝,真正靠的是失败后重连。这个坑最容易在"看起来能跑"的测试阶段漏掉,等你部署到生产环境跑批处理时才会炸出来。
6. 进阶用法:一个函数封装交互式 shell 会话
前面的 RunCommand 适合跑一次性命令,但很多场景需要像人一样登录进去,逐步敲命令、看输出。SSH.NET 提供了 ShellStream,它创建一个持久的交互会话。这个模式最典型的应用场景是登录网络设备执行巡检命令,每一步都要等前一步的结果。
public string RunInteractiveCommand(string command, int waitSeconds = 5) { return ExecuteShellCommand(command, waitSeconds); }这个封装的重点是不要死等固定秒数,而是去读输出流,直到出现预期提示符或条件满足。死等的问题是:网络慢时可能不够,网络快时又浪费时间。我的习惯是循环读一小块数据,拼接起来判断是否匹配结束条件,超时再抛出。
我自己在写这种交互封装时,最难忘的就是 Buffer 读不干净导致的命令粘连问题。那次排查花了整个下午,最后发现是上一条命令输出没有读完就发了下一条命令,终端还把之前输入回显了一遍,整个输出像浆糊一样混在一起。从那以后,我所有交互式封装的循环读取都带上完整排空缓冲区的逻辑,宁可多等 200 毫秒也不抢跑下一条命令。
如果你能在这篇文章里带走的只有一件事,那就是:SSH 连接模块的成功率不是靠运气,是靠把「认证、通道、生命周期、超时、重连」这些环节一个不落地做扎实,半成品和能交付的成品之间差的也就是这几个环节。希望帮到你。
最后补一句我的习惯:每次写完一段连接代码,我会刻意杀掉服务端的 sshd 进程来测一次重连逻辑,这个测试做过了,代码才算真正可用。
本文还有配套的精品资源,点击获取