简介:这是一份基于 C# 开发的 SSH 连接功能半成品工程,原本作为另一个主项目的子功能模块,现独立打包分享。工程采用 WinForms 界面,包含源码、解决方案、安装部署工程、NuGet 依赖包及说明文档,适合正在做远程连接、网络管理,或需要为项目集成 SSH 能力的 C# 开发者参考,也可用于课程设计、毕业设计、实训与大创等初期项目立项。压缩包共 70 个文件,约 7.39MB,主要由 cs 源文件、dll 依赖库、xml 配置文件与文档、exe 可执行程序、pdb 调试符号及 resources 资源文件组成,目录保留了 Visual Studio 工程结构和 Setup 打包配置,能直接打开复现并继续扩展。当前已有 48 人浏览学习。虽标注为半成品,但完整度已能帮助读者梳理 SSH 客户端的界面交互、连接调用与异常处理思路,也适合基于现有模块改造,补充业务逻辑后快速接入实际项目。
1. 这个C# SSH半成品缺了什么:先搞清楚再下手
做C#上位机或者内部运维工具的人,大概率都遇到过这种局面:主项目做到一半,需要远程去连一台Linux设备,读个状态、跑个脚本、传个文件,于是顺手用C#写了个SSH连接的子功能,塞进项目某个角落。这个子功能往往停在“能连上、能跑一两条命令”的状态,超时、重连、批量、密钥这些边界全没补,最终压缩成一个半成品zip,等真正要用时才发现补起来比重写还费劲。这篇笔记就是把这种半成品拆开,告诉你它缺在哪、怎么补齐、踩坑在哪,适合正在做上位机集成、设备巡检、服务器批量操作的人照着改。
2. C#做SSH连接的技术选型:为什么绕不开SSH.NET
2.1 SSH.NET、SharpSSH、还是干脆调ssh.exe
C#里做SSH连接,常见的路就那么几条,我挨个说下它们的真实处境。
第一是Renci.SshNet,一般搜“SSH.NET”就能找到,NuGet上直接装。它是目前C# SSH事实标准,密码认证、私钥认证、SFTP、端口转发都有,实现上走的是纯托管代码,不用额外装OpenSSH客户端,部署到工控机上不依赖系统环境。绝大多数“C#上位机去连远端设备”的项目,最后都绕回它。
第二是SharpSSH,这个库比较老,接口风格还停留在.NET 2.0时代,维护基本停滞。如果你只是想快速验证一下能不能连,拿它写个demo还行,真要拿到生产环境,遇到新版本OpenSSH服务端的密钥交换算法,很容易握不上手。
第三是直接调ssh.exe,比如Process.Start("ssh", "user@host ...")再解析标准输出。这个做法在Windows 10以后可行,因为系统自带OpenSSH客户端,但有几个硬伤:密码没法直接传,得靠交互输入或者配密钥;批量执行时进程频繁创建销毁,开销大;ssh.exe输出的本地化信息在不同Windows版本上不一样,解析容易翻车。我一般只拿它做临时手工排查,不会写进正式流程。
第四是自己写一个SSH协议栈,这是最不建议的路。SSH不是简单的“发命令收输出”,它涉及密钥交换、主机认证、加密算法协商、压缩、通道复用,随便哪一步没做对,对端服务端日志里就是一堆算法不匹配的报错。SSH.NET把这些都封装好了,没必要重新造轮子。
所以我的结论很直接:C#做SSH连接,新项目直接用SSH.NET,别犹豫。下面所有代码都基于它。
2.2 最小可用:先跑通第一条命令
拿到半成品zIp后,我做的第一件事不是去补功能,而是先确认依赖的库是什么版本、能不能编译过、连不连得上。SSH.NET在Visual Studio的NuGet管理器里直接搜索“SSH.NET”安装,命令行用dotnet的话执行:
dotnet add package SSH.NET装完引用,先写一个最小连接,跑通一条命令再谈其他:
using Renci.SshNet; // 第一步:组装连接信息 var connectionInfo = new ConnectionInfo( "192.168.1.100", // 目标主机IP或域名 22, // SSH端口,默认就是22 "root", // 登录用户名 new PasswordAuthenticationMethod( "root", // 用户名,和上面的保持一致 "your-password") // 密码 ); // 第二步:创建客户端并连接 using var client = new SshClient(connectionInfo); client.Connect(); // 第三步:执行一条命令,拿完整输出 var cmd = client.RunCommand("cat /etc/os-release"); Console.WriteLine(cmd.Result); client.Disconnect();这段代码的逻辑:先构造ConnectionInfo,把主机、端口、用户名、认证方式打包;然后创建SshClient并调用Connect()建立会话;RunCommand执行一条命令并返回SshCommand对象,它的Result属性就是命令的标准输出。最后Disconnect释放会话,using确保客户端被释放掉。
参数有几个要注意的地方。端口必须是int类型,默认22,但如果对端改过端口号,这里要跟着改。用户名要和认证方式里的用户名一致,不一致时会直接收到认证失败。密码里如果包含特殊字符,比如$、#,直接写字符串没问题,但要注意别在代码里硬编码,后面会讲怎么用配置文件或者环境变量管理。
跑通这一条,半成品的第一步就算站住了。但如果你的服务端禁用了密码认证、只允许密钥登录,这段代码就会卡在认证阶段,这就引出后面的密钥认证内容。所以下一步不是急着加批量,而是把连接健壮性补上。
2.3 依赖包里面到底有什么:ConnectionInfo是核心入口
很多半成品代码的问题是:把SshClient直接new出来用,new SshClient(host, port, username, password)这种构造方式也能跑,但会把参数写死在代码里,换台设备就得改代码重新编译,这不是子功能该有的形态。
SSH.NET的核心入口是ConnectionInfo,它不仅仅是“连接参数集合”,它还管三件事:认证方法的优先级、编码、超时控制。我用表格列一下它常用的属性,方便你对着改:
| 属性 | 作用 | 常见取值 |
|---|---|---|
| Host | 目标主机 | IP或域名 |
| Port | SSH端口 | 22或自定义端口 |
| Username | 用户名 | root、ubuntu等 |
| AuthenticationMethods | 认证方法数组 | 密码、私钥等,按顺序尝试 |
| Timeout | 通道操作超时 | 默认30秒,调大防大文件传输误判 |
| ConnectTimeout | 连接握手超时 | 新版本支持,单位毫秒 |
| Encoding | 命令输出编码 | 默认UTF-8,设备输出非UTF-8时改这里 |
AuthenticationMethods是个数组,这是SSH.NET的一个好设计:你可以同时传密码和私钥,它会自动按顺序尝试,省去手动写“先试密码、失败再试私钥”的分支逻辑。我通常在工控场景里同时传两种认证方式,这样设备改过密码或者临时换密钥都能连上。
3. 把半成品补成一个能用的SSH子功能:命令、超时与批量
3.1 命令执行与输出捕获:别再用RunCommand就完事
半成品最常见的样子就是会调RunCommand,但RunCommand有一个隐患:它内部是同步等待命令通道关闭后才返回,一旦命令产生大量输出,比如cat /var/log/syslog或者find / -name "*.conf",输出流缓冲会被塞满,命令还没执行完,客户端这边就卡住了。
我一般用CreateCommand替换RunCommand,它能更精细地控制执行过程:
using Renci.SshNet; using var client = new SshClient(connectionInfo); client.Connect(); // 创建命令对象 var command = client.CreateCommand("cat /var/log/syslog | tail -n 50"); // 执行并等待结果,CommandTimeout是执行超时 command.CommandTimeout = TimeSpan.FromSeconds(30); var output = command.Execute(); // ExitStatus是命令的退出码,0代表成功 Console.WriteLine($"退出码: {command.ExitStatus}"); Console.WriteLine(output); client.Disconnect();这段代码和RunCommand的区别在于:CreateCommand返回一个SshCommand对象,你可以单独设置CommandTimeout,执行完还能拿ExitStatus判断命令是否真的成功——这比单纯拿输出文本去猜“是不是有报错”可靠得多。
关于CommandTimeout,它管的是单条命令整体执行的超时,单位是TimeSpan。批量巡检时我一般设30秒,长任务比如日志分析会放到60秒。如果命令超时,Execute()会抛异常,需要在外面包一层try/catch,否则整个程序会崩。另外要注意:ExitStatus在命令被CommandTimeout中断时可能是空值,判断逻辑要写清楚“超时就是失败”。
如果你需要实时看到命令输出,比如执行一个进度条任务或长编译,那就得用shell流。SSH.NET里叫CreateShellStream,它模拟一个终端会话,边出结果边读到,适合交互式命令。但shell流的坑在于它没有“结束”的概念,需要自己判断命令提示符或者等一段时间,逻辑多不少。我的建议是:能用非交互式命令解决的,就别上shell流。
3.2 超时控制、退出码与批量登录:上位机场景的真实需求
真正到了上位机场景,你会发现需求不是“连上去跑一条命令”,而是“对一批设备做同一组操作”。比如产线上有几十块Linux板卡,你要批量改配置、批量查CPU温度、批量重启服务。这时候半成品最大的缺项就是批量执行和超时处理。
批量执行的核心不是循环那么简单,而是并发控制。几十台设备同时连,如果全用同步循环,一台卡住,后面的全等;如果全开线程去连,SSH握手本身就吃资源,可能把上位机拖垮。我习惯用信号量控制并发数:
using System.Collections.Concurrent; using Renci.SshNet; var devices = new List<string> { "192.168.1.101", "192.168.1.102", "192.168.1.103" }; var results = new ConcurrentDictionary<string, string>(); var semaphore = new SemaphoreSlim(5); // 最多5个并发连接 var tasks = devices.Select(async ip => { await semaphore.WaitAsync(); try { var info = new ConnectionInfo(ip, 22, "root", new PasswordAuthenticationMethod("root", "password")); using var client = new SshClient(info); client.Connect(); var result = client.CreateCommand("uptime").Execute(); results[ip] = $"{result.Trim()} [退出码: {client.CreateCommand("echo $?").Execute()}]"; client.Disconnect(); } catch (Exception ex) { results[ip] = $"连接失败: {ex.Message}"; } finally { semaphore.Release(); } }); await Task.WhenAll(tasks); foreach (var r in results) { Console.WriteLine($"{r.Key}: {r.Value}"); }这段代码做的事情:把所有设备IP放进列表,用ConcurrentDictionary存放结果,解决多线程写集合的冲突。SemaphoreSlim(5)把并发连接数限制在5个,避免几十个SSH同时握手把上位机网络堵死。每个设备独立连接、独立执行、独立断开,一台失败不影响其他设备。
这里有个小细节:我在循环里执行了两次CreateCommand,第一次是uptime,第二次是echo $?。第二次不是必须的,Execute()返回后ExitStatus其实已经拿到了。但在某些老版本SSH.NET里,连续执行命令时ExitStatus可能拿的是上一条命令的值,所以我习惯单独查一下,算是个血泪经验。
超时设置在这段代码里也要单独提:ConnectionInfo.ConnectTimeout控制的是TCP握手和SSH协议协商阶段,默认情况下可能比较保守,工控网络慢的话容易报“连接超时”。我通常设成5000毫秒,超过5秒连不上就直接跳过这台设备,记录失败原因,继续下一台。
3.3 从SSH到SFTP:文件传输是子功能的另一半
很多情况下SSH子功能不只是为了执行命令,还要传文件,比如更新设备上的程序包、拉取日志文件。SSH.NET把SFTP客户端直接做进了库,同一个连接信息就能用。
using Renci.SshNet; using var client = new SftpClient(connectionInfo); client.Connect(); // 上传文件:本地流写入远端路径 using var fileStream = File.OpenRead(@"C:\app\update.zip"); client.UploadFile(fileStream, "/opt/app/update.zip"); // 下载文件:远端文件写入本地流 using var downloadStream = File.Create(@"C:\backup\remote.tar.gz"); client.DownloadFile("/var/log/messages.tar.gz", downloadStream); client.Disconnect();sftp的操作逻辑和FTP很像,UploadFile接受一个本地流和一个远端路径,DownloadFile接受远端路径和一个本地流。关键点是:远端路径必须是绝对路径,用相对路径很容易“文件不存在”的报错;File.OpenRead和File.Create都会占用本地文件句柄,记得用using释放。
实际项目中,我会把文件传输和命令执行放同一个SshClient上,因为SSH协议支持多通道复用,一个连接里既能开命令会话又能开SFTP会话,没有必要为传文件单独建第二个连接。半成品代码里如果有“传文件又要重新连一次”的逻辑,可以合并到一个连接里,省去握手开销。
4. 认证与参数细节:密钥、主机指纹与连接复用
4.1 密码、私钥和主机指纹:三种认证方式怎么选
半成品一般在认证这块最偷懒:密码直接写死。密码认证简单,但有一个现实问题——设备密码一旦定期轮换,上位机代码就得跟着改,改完还要重新编译发布。私钥认证能绕开这个维护成本,但它的坑不比密码少。
SSH.NET支持三种认证方式,我按使用频率给你梳理:
| 认证方式 | 使用场景 | 注意点 |
|---|---|---|
| PasswordAuthenticationMethod | 密码认证 | 密码轮换后需要更新配置 |
| PrivateKeyAuthenticationMethod | 密钥认证 | 私钥格式、passphrase、权限三座大山 |
| KeyboardInteractiveAuthenticationMethod | 设备要求交互式应答 | 常见于首次登录改密、二次验证 |
私钥认证在SSH.NET里的写法:
using Renci.SshNet; // 加载私钥文件 var privateKey = new PrivateKeyFile(@"C:\keys\id_rsa", "passphrase if any"); // 组装连接信息,认证方式用私钥 var connectionInfo = new ConnectionInfo( "192.168.1.100", 22, "root", new PrivateKeyAuthenticationMethod("root", privateKey) ); using var client = new SshClient(connectionInfo); client.Connect();这里PrivateKeyFile既支持OpenSSH格式,也支持PEM格式,构造函数第二个参数是私钥的passphrase,没有就传null。Windows上生成的密钥默认可能带有passphrase,如果批量部署想免交互,生成密钥时别设passphrase,或者把passphrase放到上位机的配置中心管理。
主机指纹校验是很多半成品忽略的安全点。SSH协议为了防止中间人攻击,会校验服务端的主机公钥。SSH.NET里默认行为是接受任意主机公钥,这在工控内网问题不大,但如果你的设备暴露在不可信网络,最好把指纹校验加上,也就是校验一把“指纹白名单”。
4.2 私钥在Windows上的格式坑
私钥认证最容易翻车的地方就是格式。很多人在Windows上用ssh-keygen生成的密钥是OpenSSH格式,头部长这样:
-----BEGIN OPENSSH PRIVATE KEY-----而SSH.NET对OpenSSH格式的支持有个演进过程,旧版本只认RSA私钥的PEM格式:
-----BEGIN RSA PRIVATE KEY-----如果密钥文件头是OPENSSH PRIVATE KEY,但你用的SSH.NET版本比较旧,加载时会直接报“invalid private key”。解决办法有两个:一是更新SSH.NET到最新版本,新版已经支持OpenSSH格式;二是把密钥转成PEM格式。
转换命令在Windows的OpenSSH客户端里直接执行:
ssh-keygen -p -m PEM -f C:\keys\id_rsa这条命令会重新处理私钥格式并转成PEM。-p表示修改passphrase,-m PEM指定输出格式,-f指向私钥文件。执行过程会先问旧passphrase,再问新passphrase,如果密钥本来没有passphrase,直接回车两次就行。转换后记得重新生成对应的公钥,因为格式变了,公钥内容可能跟着变,服务端authorized_keys里要同步更新。
另外一个容易忽略的点:Windows的私钥文件如果是从别处拷贝来的,权限可能放大到“Everyone可读”,SSH.NET不像Linux sshd那样做权限校验,但安全起见还是右击文件属性,把无关用户的读取权限去掉。
4.3 连接复用还是每次新建:性能与安全的取舍
半成品代码里另一个高频问题是:每次操作都new SshClient、Connect、Disconnect。短连接的好处是逻辑简单,坏处是握手开销大。一次SSH握手要经历TCP连接、协议版本交换、密钥交换、认证,往返次数不少。上位机如果每5秒轮询一次设备状态,频繁建连断开会让设备端的sshd日志被刷屏,还可能触发某些设备的安全策略导致IP被封。
但SSH.NET的SshClient不是线程安全的,一个实例同一时间只能处理一条命令。所以“连接复用”的正确姿势是:要么串行使用一个连接执行多条命令,要么给每个工作线程单独维护一个连接对象。我写上位机轮询时,会在初始化阶段提前建立连接,之后循环里复用同一个SshClient执行命令,程序退出时再统一断开。如果批量任务并发度很高,就做连接池,每个线程从池里拿一个独立连接。
连接池的实现不复杂,核心是队列加锁,但要注意:连接池不能无限膨胀,要设置最大连接数,超过就先等。这个边界设计和线程池很像,没有想象中神秘。
5. 从半成品到可用,最容易翻车的5个坑:现象、原因、解决
5.1 连接卡死、超时无效与端口不通
现象:调用client.Connect()后,程序卡住不动,看起来像死锁;或者你设置了Timeout,但超时之后还是卡着。
原因:SSH.NET旧版本的ConnectionInfo.Timeout管的是通道操作,管不到连接握手。连接握手阶段的超时在旧版本里是写死的,如果目标IP不可达,TCP连接会等到系统级超时,那是很长一段时间,比如几十分钟。
解决:升级到新版SSH.NET,在ConnectionInfo上显式设置ConnectTimeout,单位是毫秒。另外调用Connect()之前,先用一个简单的TCP探测确认端口通不通:
using System.Net.Sockets; using var tcp = new TcpClient(); var task = tcp.ConnectAsync("192.168.1.100", 22); if (await Task.WhenAny(task, Task.Delay(2000)) != task) { Console.WriteLine("端口不通,跳过"); return; }TCP能连上再去走SSH握手,排错路径一下子就短了。这条经验帮我在现场省了好多时间,端口不通再怎么调SSH参数也白搭。
5.2 命令输出为空、中文乱码与交互命令
现象:命令明明执行成功了,但Result是空的;或者输出一堆乱码。
原因:两种可能。第一种是命令输出全在标准错误流里,Result只包含标准输出,需要再查看Error属性;第二种是设备系统编码不是UTF-8,比如某些欧版老设备用ISO-8859-1,Windows老设备用GBK,SSH.NET默认按UTF-8解码就乱码了。
解决:执行命令后同时检查Result和Error:
var cmd = client.CreateCommand("your-command"); cmd.Execute(); var output = cmd.Result; var errorOutput = cmd.Error; if (!string.IsNullOrEmpty(errorOutput)) { Console.WriteLine($"stderr: {errorOutput}"); }如果是编码问题,在连接信息里改编码:
var connectionInfo = new ConnectionInfo(...); connectionInfo.Encoding = System.Text.Encoding.UTF8;还有一种情况:命令本身是交互式的,比如直接执行top、vim,它会等待用户输入,Execute()拿不到任何输出,一直挂着。这类命令要么改成非交互的变体,比如top -b -n 1按批处理模式跑一次,要么给命令加上< /dev/null强制它不读终端输入。
5.3 私钥报格式错误、权限问题和认证方式顺序
现象:使用私钥认证时,加载密钥就报错,或者连上之后服务端直接拒绝,日志里出现Permission denied (publickey)。
原因:前面提到过的OpenSSH格式和PEM格式的差异只是其一;其二是服务端对公钥有权限要求,~/.ssh/authorized_keys和~/.ssh目录的权限如果太大,sshd会拒绝读取你的公钥;其三是SSH.NET配置的认证方式顺序优先级,如果先试密码失败,再试私钥,某些服务端在多次失败后会直接断开。
解决:先确认私钥格式,头部是OPENSSH PRIVATE KEY就按前面转换一次。确认服务端authorized_keys里存的公钥和本地私钥配对,不一致会导致认证失败。最后,调整ConnectionInfo中AuthenticationMethods的排列顺序,把最可能成功的认证方式放最前面。
var authMethods = new AuthenticationMethod[] { new PrivateKeyAuthenticationMethod("root", privateKey), new PasswordAuthenticationMethod("root", password) }; var connectionInfo = new ConnectionInfo("host", 22, "root", authMethods);另外服务端日志是排查密钥认证失败的最好帮手。如果你能登录设备,直接看/var/log/auth.log(Debian系)或/var/log/secure(CentOS系),会明确告诉你公钥为什么被拒绝,比我瞎猜快得多。
6. 把它封装成上位机里的一个模块:异步化、心跳保活与日志
6.1 异步命令与取消机制
半成品最后要变成正式子功能,我一般会加一层异步封装。SSH.NET的新版本已经提供了ConnectAsync和ExecuteAsync,配合CancellationTokenSource能做到“用户点取消就立刻中断”。异步写法的好处是上位机界面不会卡死,跑批量任务时还能同时操作其他控件。
6.2 心跳保活与自动重连
设备端的sshd默认有空闲超时,比如ClientAliveInterval配置为300秒,意味着5分钟没有数据往来就断开。上位机长时间停在那不操作,连接就会被服务端回收。我的做法是写一个心跳循环,每隔一段空闲时间发一个轻量命令:
using Timer = System.Threading.Timer; var heartbeatTimer = new Timer(_ => { try { client.CreateCommand("echo keepalive").Execute(); } catch { // 心跳失败,标记需要重连 } }, null, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(2));心跳不只是保活,还能顺带检测设备是否在线。如果心跳失败,就触发重连逻辑,先断开旧连接,等待几秒重新Connect。这在设备会重启的现场尤其重要,设备重启后端口从监听到消失再到恢复,重连时要加退避策略,不能疯狂重试。
6.3 验证与收尾
封装完成后,我会用一个真实设备做一轮验证:正常命令、长输出命令、批量并发、私钥登录、断网重连、文件传输这六项。每项都记录用时和结果,确认后再交出去。一个人踩过太多SSH的坑,等到真正上线才发现连不上就已经晚了。
所以这类半成品项目,我最深的感触是把边界补全比把主路径跑通更费功夫。现在我再看到“能连上但别的都没有”的SSH子功能,第一件事就是检查超时、编码、复用逻辑,而不是急着加功能。希望帮到你。
本文还有配套的精品资源,点击获取