SSH.NET 安装配置与排错指南:C# 远程执行与 SFTP 传输 简介面向 C#/.NET 开发者及需要维护远程连接程序的运维人员提供 SSH.NET 库 Renci.SshNet.dll 的更新版本重点解决旧版库在连接加密级别较高的 Linux 系统如 openEuler 22.0.3时出现的握手失败、算法不兼容等问题也适用于 SSH/SFTP 自动化运维、远程文件同步与服务器管理等场景。压缩包共 20 个文件以 9 个 dll 程序集和 9 个 xml 接口文档为主另含 nupkg 包与签名文件整体约 6.55MB包内按 net35、net40、netstandard1.3、netstandard2.0 等多个目标框架组织便于在 Windows 服务、桌面程序及跨平台项目中替换引用、查阅说明或进行包管理集成。已有 774 人学习下载可作为升级 SSH.NET 依赖、恢复远程命令执行与文件传输功能的参考包。对于仍使用旧版库且遇到密钥交换或加密算法协商失败的项目可借助其中的程序集与接口文档核对版本差异减少联调与部署成本。1. 从一次new SshClient连不上说起一个 C# 后台服务要连 Linux 跑部署脚本装包时按类名在包里搜到了 SSH.NET装完却在运行时抛Could not load file or assembly Renci.SshNet。这个错误大多不是代码写错而是包名、程序集名、目标框架三者没对齐。C# 里做 SSH 远程执行、SFTP 传文件SSH.NET 几乎是默认选择一套 API 管命令、上传下载和端口转发纯托管实现不依赖机器上装没装 openssh 客户端。但它的安装细节比多数包更繁琐版本线有分界加密依赖会变动Linux 侧 sshd 也要配合。任何一处对不上最后都表现为「连不上」。下面按实际装一遍的顺序从环境清单写到验证跑通。2. 先把环境列清目标框架、版本线和依赖树装 SSH.NET 最容易犯的错是拿到一个Renci.SshNet.dll就往项目里塞然后花一整天查连接代码。正确顺序是先确定三件事项目跑在哪个 .NET 目标框架上、要选 SSH.NET 的哪条版本线、这条线会带进哪些加密依赖。这三点决定后面所有步骤包括离线部署时该复制哪几个文件。2.1 用几条命令确认 SDK 和现有引用动手前先看项目实际情况不要凭印象。老项目最常出现的问题是“我记得它是 .NET Framework”打开 csproj 才发现是 netstandard2.0 类库编译目标完全不同。# 看本机 SDK、运行时和可用目标框架 dotnet --info # 查项目当前引用了哪些包含传递依赖关键 dotnet list ./src/DeployAgent/DeployAgent.csproj package --include-transitive # 确认 NuGet 源可用公司内网常有私有源覆盖 dotnet nuget list sourcedotnet --info的输出重点是Target Framework(s)和已安装的运行时版本--include-transitive会列出间接引用SSH.NET 的加密库是否已被其他包带进来一目了然。如果项目还在 .NET Framework 且使用packages.config上面第二条命令不生效需要在程序包管理器控制台执行Get-Package | Where-Object { $_.Id -like *Ssh* }或者直接打开packages.config看。我一般习惯先把这份清单复制到记事本装完再对比一次多出或少掉的东西全在这张表里。2.2 版本线怎么选老项目守老线新项目跟年份线SSH.NET 的版本号是“年份.序号”格式不是语义化版本所以不能靠1.x 升 2.x的直觉判断兼容性。按我手上的记录这条库大致分成两段版本线目标框架覆盖适用场景注意点2020.0.2 一类的老线含 net40 / net45 / netstandard1.3还在 .NET Framework 4.0 到 4.5 的老系统加密实现内嵌依赖少但新算法支持有限2023 之后的年份线net462 / netstandard2.0 / netstandard2.1 / 新版本 net6新项目、跨平台、需要较新密钥与算法会带进独立的加密包离线部署要一并搬判断方法很直接项目 TFM 是net45及以下基本只能往老线找是net462、netstandard2.0或net6.0/net8.0就跟年份线。至于此刻线上最新版本号是多少不必猜执行dotnet package search SSH.NET --exact-match就能看到SDK 版本较老、没有这条命令时直接运行dotnet add package SSH.NET且不加--version看它解析成多少读完再决定是否固定。新项目可以用浮动版本号起步但验证完必须固定版本号ItemGroup !-- 先浮动跑通后把 * 换成实际解析出的具体版本号 -- PackageReference IncludeSSH.NET Version2024.* / /ItemGroup浮动号的好处是省去手动查版本坏处是队友和 CI 可能解析出不同版本因为还原时间不同。我遇到过同一份代码在本地正常、流水线报MissingMethodException最后发现两边差了一个小版本。所以规则是本地可以用浮动号试探提交前一定锁死具体号并确保packages.lock.json一并入库。2.3 依赖树里那两个容易漏的加密包SSH.NET 不是零依赖库年份线版本通常把加密算法交给外部包实现。你在bin目录只看到Renci.SshNet.dll就以为齐了部署到服务器上就报FileNotFoundException: Could not load file or assembly BouncyCastle.Cryptography。老线版本可能带的是另一个名字的加密包甚至把实现内嵌因此不同版本线复制出来的 dll 清单并不一样。确认办法只有一个还原之后看输出目录不去猜。# 还原并发布到临时目录看最终会带哪些 dll dotnet publish ./src/DeployAgent/DeployAgent.csproj -c Release -o ./out/publish # 列出发布目录里所有与加密、SSH 相关的程序集 ls ./out/publish | grep -Ei ssh|bouncy|crypto-o指定输出目录-c Release保证和正式包一致。如果只在 Debug 下调试过别拿 Debug 的清单当准。grep那行是过滤关键字你可以按实际文件名调整。清单出来之后把与 SSH、加密相关的 dll 单独记一份这就是离线部署时要搬的“最小集合”。这一步多花两分钟能省掉现场排查一小时的麻烦。3. 三种安装方式NuGet 引用、老项目迁移、离线 dll 直引进入安装环节按项目形态分三条路走。新项目用 PackageReference最省心老项目卡在packages.config和 bindingRedirect 上需要先迁移完全离线的内网机器只能手动搬 dll但搬什么、怎么声明引用有固定写法。三条路的共同原则是包名是 SSH.NET程序集名是 Renci.SshNet.dll两者不一致弄清这点很多“找不到包”的困惑就会消失。3.1 PackageReference 的标准写法现代 SDK 风格项目推荐用 CLI 添加引用让工具写 csproj而不是手敲 XML 拼错大小写# 进入项目目录添加引用不加 --version 会拉取最新稳定版 dotnet add ./src/DeployAgent/DeployAgent.csproj package SSH.NET # 需要指定版本线时显式加上例如固定到某条年份线 dotnet add ./src/DeployAgent/DeployAgent.csproj package SSH.NET --version 2024.* # 还原并确认最终解析结果 dotnet restore dotnet list packagedotnet add package会同时修改 csproj 和触发还原比手动编辑安全。--version后面可以跟浮动号也可以是具体号。第三步的dotnet list package是核对环节看Requested和Resolved两列是否一致不一致说明源里有更高版本被优先选中此时要么显式准入要么检查是不是私有源覆盖了公共源。有一个容易被忽视的参数类库项目非可执行项目在 .NET Framework 下默认不复制 NuGet 依赖到输出目录导致主程序运行时找不到 dll。这种情况在 csproj 里补一行属性PropertyGroup !-- .NET Framework 类库项目必须打开否则依赖不会进 bin -- CopyLocalLockFileAssembliestrue/CopyLocalLockFileAssemblies /PropertyGroup这个属性只影响构建产物不影响运行时行为加上之后bin里就会出现完整的依赖集合。SDK 风格的net6.0以上项目默认行为已经够用不需要加。3.2 老项目从 packages.config 迁过来packages.config项目最大的问题是程序集版本绑定写死在app.config里。你升级了 SSH.NETapp.config还留着旧版本的bindingRedirect运行时就会报“找到的程序集清单定义与程序集引用不匹配”。处理顺序是先卸干净再重装最后让工具重生成绑定。# 程序包管理器控制台执行先卸载所有相关包 Uninstall-Package SSH.NET -RemoveDependencies Uninstall-Package SshNet.Security.Cryptography -RemoveDependencies # 重新安装目标版本-Version 写具体号 Install-Package SSH.NET -Version 2020.0.2-RemoveDependencies会连带清掉不再被引用的加密包避免残留两套实现互相打架。重装完成后检查 csproj 是否已自动生成AutoGenerateBindingRedirectsPropertyGroup !-- .NET Framework 项目建议打开自动为依赖生成版本绑定 -- AutoGenerateBindingRedirectstrue/AutoGenerateBindingRedirects GenerateBindingRedirectsOutputTypetrue/GenerateBindingRedirectsOutputType /PropertyGroup打开后重新编译bin下的*.exe.config里会自动出现Renci.SshNet的bindingRedirect版本区间跟着实际引用走不用手写。如果你在app.config里手写过旧版本区间先删掉否则自动生成的内容可能和手写的冲突。3.3 只有 dll 的离线场景怎么搬内网机器没有 NuGet 源只能把编译好的产物搬过去。这里要明确一个事实只复制Renci.SshNet.dll大概率跑不起来年份线版本还需要加密库。做法是先在能联网的机器上发布一份再把依赖清单里的文件按目录结构整体复制过去。ItemGroup !-- 离线引用HintPath 用相对路径方便整个解决方案换机器 -- Reference IncludeRenci.SshNet HintPath..\lib\sshnet\Renci.SshNet.dll/HintPath Privatetrue/Private /Reference Reference IncludeBouncyCastle.Cryptography HintPath..\lib\sshnet\BouncyCastle.Cryptography.dll/HintPath Privatetrue/Private /Reference /ItemGroupHintPath写相对路径是硬性要求写绝对路径换台机器就失效。Private为true表示复制到输出目录漏了这一项编译能过、运行时找不到。目录名我习惯统一成lib\sshnet\把这一版的所有相关 dll 放进去配合一份version.txt记录来源版本号。搬过去之后用 PowerShell 核对 dll 的真实版本别信文件名# 读取程序集元数据确认版本号和目标框架 [Reflection.AssemblyName]::GetAssemblyName(D:\code\lib\sshnet\Renci.SshNet.dll) | Select-Object Name, Version, ProcessorArchitectureName必须是Renci.SshNetVersion应与你记录的版本一致。如果ProcessorArchitecture显示x86而主程序是AnyCPU或x64在 64 位进程里加载会失败这时需要换一份或改平台目标。4. 连接配置Linux 侧和 C# 侧要一起调装完包只是拿到能力能不能连上取决于两端配置。很多人只在 C# 侧反复改代码却忘了 sshd 侧的StrictModes和密钥权限。我的排查顺序固定是先确认 Linux 侧能用命令行登上再让 C# 复现同样的认证方式。命令行都不通代码里再怎么调参数也没用。4.1 Linux 侧先把账号、密钥和权限备好用一个专用账号跑自动化不要用 root也不要复用个人账号。密钥用ed25519或rsa 4096生成时按需加 passphrase。# 生成密钥对-C 只是注释方便日后识别 ssh-keygen -t ed25519 -C deploy-agent -f ~/.ssh/id_ed25519_deploy # 服务端侧建专用账号并布置公钥 sudo useradd -m -s /bin/bash deploy sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh sudo install -m 600 -o deploy -g deploy \ ~/.ssh/id_ed25519_deploy.pub /home/deploy/.ssh/authorized_keys # 校验 sshd 配置语法后再重载别直接 restart sudo sshd -t sudo systemctl reload sshdinstall -d -m 700一次完成建目录和设权限比mkdir加chmod少一步也不容易忘。sshd -t是语法检查改坏了配置时它先报错不会把连接彻底切断——这一步是老运维的后悔药直接 restart 一旦配置有错你会连自己都登不上去。sshd 侧还有几个和长连接相关的项建议显式写进配置# /etc/ssh/sshd_config 片段 PubkeyAuthentication yes PasswordAuthentication no # 生产环境建议关掉密码登录 ClientAliveInterval 60 # 服务端每 60 秒探活一次 ClientAliveCountMax 3 # 连续 3 次无响应才断开 MaxStartups 30:30:100 # 并发握手限制批量任务会撞到这里改完同样走sshd -t加reload。MaxStartups那行的三个数字分别是“开始拒绝的比例:开始随机丢弃的比例:绝对上限”批量并发的部署任务最容易撞到默认值。4.2 密码认证和私钥认证的代码骨架两种认证在 SSH.NET 里都是通过ConnectionInfo组织参数差别只在AuthenticationMethod的类型。把参数写进ConnectionInfo再交给SshClient代码比散着设属性清楚。using Renci.SshNet; // 密码认证适用于受控内网公网或生产建议换私钥 var passwordInfo new ConnectionInfo( host: 10.0.0.21, port: 22, username: deploy, authenticationMethods: new AuthenticationMethod[] { new PasswordAuthenticationMethod(deploy, 从配置中心取别硬编码) }) { Timeout TimeSpan.FromSeconds(15), // 建链超时不是命令超时 RetryAttempts 3 // 握手失败的重试次数 }; using var client new SshClient(passwordInfo); client.KeepAliveInterval TimeSpan.FromSeconds(30); // 空闲期保活 client.Connect(); var cmd client.CreateCommand(cd /opt/app ./health-check.sh); cmd.CommandTimeout TimeSpan.FromSeconds(60); // 命令级超时必须单独设 var output cmd.Execute(); Console.WriteLine($exit{cmd.ExitStatus}); Console.WriteLine(output);Timeout只负责建立连接阶段命令跑多久和它无关CommandTimeout才管命令。这两个参数混用是常见误解有人把Timeout调到 5 分钟以为长任务就不会断结果照样卡死。ExitStatus一定要判SSH.NET 不会因为远端脚本返回非零就抛异常静默失败全靠这个值发现。私钥认证把PasswordAuthenticationMethod换成PrivateKeyAuthenticationMethod密钥文件路径指向本地私钥有 passphrase 就传第二个参数// 私钥认证keyFile 支持 OpenSSH 和 PEM 两种常见格式 var keyFile new PrivateKeyFile(/etc/deploy/id_ed25519_deploy, passphrase); var keyInfo new ConnectionInfo(10.0.0.21, 22, deploy, new PrivateKeyAuthenticationMethod(deploy, keyFile)); using var sftp new SftpClient(keyInfo); sftp.Connect(); sftp.ChangeDirectory(/opt/app/releases); using var fs File.OpenRead(./app.zip); sftp.UploadFile(fs, app.zip); // 同名会覆盖先备份PrivateKeyFile的第二个参数是 passphrase无密码的私钥传null或省略。UploadFile不校验远端已有文件覆盖前是否备份由业务决定。SftpClient和SshClient可以共用同一个ConnectionInfo实例但各自Connect不要以为连了一个另一个就自动可用。4.3 超时、KeepAlive 和 HostKey 三个参数这三个参数决定了脚本能不能长期稳定跑。KeepAliveInterval设成 30 到 60 秒比较稳妥太短会刷日志太长挡不住中间设备的会话回收。缺少保活时表现往往是“半夜任务莫名断开”排查起来很费劲。HostKeyReceived是常被跳过的一环。不校验就意味着任何能应答 22 端口的机器都能冒充目标机// 先用 ssh-keygen -l -E md5 -f /etc/ssh/ssh_host_ed25519_key.pub 拿到指纹 byte[] expected Convert.FromHexString(aa11bb22cc33dd44ee55ff6677889900); client.HostKeyReceived (_, e) { // 指纹不一致直接拒绝等价于 known_hosts 的效果 e.CanTrust e.FingerPrint.SequenceEqual(expected); };e.FingerPrint默认是 MD5 摘要的字节数组Convert.FromHexString需要 .NET 5 及以上低版本用Enumerable.Range手工解析十六进制字符串即可。这段代码要放在Connect()之前放在后面不起作用。5. 避坑与排查五条踩过的记录下面五条按“现象 → 原因 → 解决”记录都是我在部署环节真实撞过的按出现频率排序。5.1 引用层面的两个坑一、运行时报Could not load file or assembly Renci.SshNet。现象是编译完全正常启动即崩或者调用new SshClient的瞬间抛异常。 原因通常是三选一装错了包搜到名字相似的第三方封装、只复制了 dll 没带加密依赖、解决方案里两个项目引用了不同版本导致运行时只能加载其中一个。 解决先用dotnet list package --include-transitive确认解析版本再dotnet publish -o看输出目录两处版本号对不上就统一。离线场景按第 3.3 节的清单整体复制不要挑着复制。二、升级后报MissingMethodException或“程序集清单定义不匹配”。现象是换了个版本号代码没改运行时报方法找不到。 原因是 .NET Framework 的bindingRedirect还指向旧版本区间运行时按旧绑定去找找到的 dll 里没有新方法。 解决检查app.config和输出目录下的*.exe.config删掉手写的旧bindingRedirect打开AutoGenerateBindingRedirects重新编译。如果同时存在两份不同版本的Renci.SshNet.dll比如bin里一份、lib里一份删掉多余的那份。5.2 认证与连接阶段的三个坑三、Permission denied (publickey)但命令行能登。现象是终端里ssh deployhost正常C# 里同样的私钥登不上。 原因基本是权限家目录 777、authorized_keys是 644、或者.ssh目录属主不对sshd 的StrictModes会直接拒绝而客户端只回一句 publickey 失败不告诉你具体原因。 解决chmod 700 ~/.ssh、chmod 600 ~/.ssh/authorized_keys、chown -R deploy:deploy ~/.ssh然后看/var/log/auth.log或journalctl -u sshd里的真实原因。日志永远比客户端提示详细这是最省时间的一步。四、加载私钥抛“Invalid private key”。现象是new PrivateKeyFile(...)直接异常密钥文件在命令行下能用。 原因有两个一是密钥是新版 OpenSSH 格式文件头写着BEGIN OPENSSH PRIVATE KEY老版本线不认二是带 passphrase 的私钥没传密码。 解决转换成 PEM 格式再用ssh-keygen -p -m PEM -f id_ed25519_deploy会原地改写文件先备份一份。也可以反过来升级到年份线版本新版本对新格式支持更好。老项目动不了框架时转换格式是最省事的办法。五、命令执行不返回Execute()一直挂住。现象是代码停在cmd.Execute()不动日志没有任何输出进程 CPU 占用很低。 原因是远端命令是交互式的或者永不退出比如tail -f app.log、需要输密码的sudo、等待确认的脚本SSH.NET 会一直等标准输出关闭而CommandTimeout默认不设上限。 解决给命令加timeout 60前缀或在cmd.CommandTimeout显式设值确实需要持续读输出的场景改用ExecuteAsync配合Output事件流式处理不要指望Result一次性拿到。还有一个隐蔽情况命令输出量极大时缓冲区读满也可能卡住这时要看是不是同时读了 stderr。长连接半夜断开的问题则回到 4.3 节的KeepAliveInterval和服务端ClientAliveInterval一起调。6. 装完怎么验证三条命令加一个最小 Demo验证不要上来就跑业务逻辑按“依赖对不对、连得上、传得动”三步走每一步都有明确的通过标准。第一步确认解析版本和输出清单。执行dotnet list package --include-transitive看Renci.SshNet的Resolved列是不是你锁定的号再dotnet publish -c Release -o ./out/publish用ls ./out/publish | grep -Ei ssh|bouncy核对 dll 是否齐全。输出目录里如果有两份不同版本的Renci.SshNet.dll直接判定失败先清干净再继续。第二步跑一个只读命令的最小 Demo。新建控制台项目引同版本包只做一件事执行uname -a和cat /etc/os-release打印ExitStatus和输出。通过标准有三条——ExitStatus为 0、输出里的内核版本和你在终端看到的一致、连续跑 10 次不出现偶发超时。第三步是我固定会加的单次成功不代表连接参数没问题偶发失败往往藏在重试和保活里。第三步验证文件传输完整性。上传后对远端文件做一次摘要比对比看文件大小靠谱using var sftp new SftpClient(keyInfo); sftp.Connect(); sftp.ChangeDirectory(/opt/app/releases); using (var fs File.OpenRead(./app.zip)) { sftp.UploadFile(fs, app.zip); // 上传后立刻做摘要校验 } // 远端算 sha256和本地比对避免半包文件混进发布目录 var check new SshClient(keyInfo); check.Connect(); var cmd check.CreateCommand(sha256sum /opt/app/releases/app.zip | awk {print $1}); cmd.CommandTimeout TimeSpan.FromSeconds(30); var remoteHash cmd.Execute().Trim(); Console.WriteLine($remote{remoteHash});awk {print $1}是为了把sha256sum输出里的文件名去掉只留摘要本地用同样的算法算一遍对比即可。这一步能抓到权限不足导致的截断上传——表面上没报错文件却只有一半。验证项命令或方法通过标准依赖完整性dotnet publish后列目录只有一个版本的Renci.SshNet.dll加密库齐全连接可用性最小 Demo 跑uname -aExitStatus为 0连续 10 次稳定传输完整性远端sha256sum对比本地摘要一致无截断从那以后我每次给新机器装这套环境都强制走一遍这三步尤其是第三步的摘要比对——早年被半包文件坑过一次发布目录里混进一个只有几 MB 的 zip服务起不来排查了两个小时才发现问题在上传环节。这套 dll 和依赖清单我按版本归档在lib\sshnet\目录下连同那份version.txt一起换机器直接搬过去就能复现需要的话可以拿去照着走一遍。希望帮到你。本文还有配套的精品资源点击获取