C# 通过 SSH.NET 连接 Linux 执行命令与 SFTP 实战避坑

发布时间:2026/10/23 6:07:43
C# 通过 SSH.NET 连接 Linux 执行命令与 SFTP 实战避坑 简介面向使用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.dllNuGet 包名 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 IncludeSSH.NET Version2024.* / /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.TimeoutTCP 建连加协议握手默认 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 LANGen_US.UTF-8; export LC_ALLen_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 为什么对目录返回 falseSftpClient的路径永远用正斜杠哪怕跑在 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看IsDirectorybool 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 | catcat永远返回 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目标路径无写权限、canOverridefalse确认目录属主与 umaskSftpPathNotFoundException路径拼接错误、目录未创建打印完整远端路径核对斜杠最后给一套上线前的自检清单每次接新环境我都跑一遍能连上并打印主机指纹bash -lc echo $PATH拿到的 PATH 与登录终端一致id输出确认权限足够SFTP 能列目录、能上传一个 1KB 文件、能改权限故意跑一条exit 3确认退出码是 3 而不是 0断开后ss -tnp | grep :22看不到残留连接。这六条全过后面基本不会出玄学问题。说个教训。早些年做一个批量发布工具图快没做主机指纹校验测试环境一切正常上线后某台机器的 SSH 被中间设备接管脚本把半成品包推了上去服务起了但配置是错的排查了整整一个下午。从那以后我每次接新环境都会先把指纹比对和退出码检查这两件事强制走一遍宁可多写二十行代码。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询