简介:面向 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 Include="SSH.NET" Version="2024.*" /> </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 --> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> </PropertyGroup>这个属性只影响构建产物,不影响运行时行为,加上之后bin里就会出现完整的依赖集合。SDK 风格的net6.0以上项目默认行为已经够用,不需要加。
3.2 老项目从 packages.config 迁过来
packages.config项目最大的问题是程序集版本绑定写死在app.config里。你升级了 SSH.NET,app.config还留着旧版本的bindingRedirect,运行时就会报“找到的程序集清单定义与程序集引用不匹配”。处理顺序是:先卸干净,再重装,最后让工具重生成绑定。
# 程序包管理器控制台执行,先卸载所有相关包 Uninstall-Package SSH.NET -RemoveDependencies Uninstall-Package SshNet.Security.Cryptography -RemoveDependencies # 重新安装目标版本,-Version 写具体号 Install-Package SSH.NET -Version 2020.0.2-RemoveDependencies会连带清掉不再被引用的加密包,避免残留两套实现互相打架。重装完成后,检查 csproj 是否已自动生成AutoGenerateBindingRedirects:
<PropertyGroup> <!-- .NET Framework 项目建议打开,自动为依赖生成版本绑定 --> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType> </PropertyGroup>打开后重新编译,bin下的*.exe.config里会自动出现Renci.SshNet的bindingRedirect,版本区间跟着实际引用走,不用手写。如果你在app.config里手写过旧版本区间,先删掉,否则自动生成的内容可能和手写的冲突。
3.3 只有 dll 的离线场景怎么搬
内网机器没有 NuGet 源,只能把编译好的产物搬过去。这里要明确一个事实:只复制Renci.SshNet.dll大概率跑不起来,年份线版本还需要加密库。做法是先在能联网的机器上发布一份,再把依赖清单里的文件按目录结构整体复制过去。
<ItemGroup> <!-- 离线引用:HintPath 用相对路径,方便整个解决方案换机器 --> <Reference Include="Renci.SshNet"> <HintPath>..\lib\sshnet\Renci.SshNet.dll</HintPath> <Private>true</Private> </Reference> <Reference Include="BouncyCastle.Cryptography"> <HintPath>..\lib\sshnet\BouncyCastle.Cryptography.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>HintPath写相对路径是硬性要求,写绝对路径换台机器就失效。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.SshNet,Version应与你记录的版本一致。如果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 deploy@host正常,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 -a | ExitStatus为 0,连续 10 次稳定 |
| 传输完整性 | 远端sha256sum对比本地 | 摘要一致,无截断 |
从那以后,我每次给新机器装这套环境,都强制走一遍这三步,尤其是第三步的摘要比对——早年被半包文件坑过一次,发布目录里混进一个只有几 MB 的 zip,服务起不来,排查了两个小时才发现问题在上传环节。这套 dll 和依赖清单我按版本归档在lib\sshnet\目录下,连同那份version.txt一起,换机器直接搬过去就能复现,需要的话可以拿去照着走一遍。希望帮到你。
本文还有配套的精品资源,点击获取