☰
.NET Core基于Renci.SshNet实现SFTP文件上传与远程命令执行
2026/10/21 7:14:07 网站建设 项目流程

做部署、做数据搬运的朋友应该都遇到过类似的诉求:.NetCore 服务得往远程 Linux 服务器传文件,传完之后还想顺手在远端执行个命令。这两年我接过不少这样的小活,发布包下发、日志归档、机器间数据中转,最后基本都回到同一个方案上——用 Renci.SshNet 走 SSH 通道搞定。这个库在 .NET 生态里算老牌了,但真正用顺手还是得摸一遍它的脾气。这篇文章我就把项目里的实际做法、连接配置、上传优化和排坑记录整理出来,大家拿去改改就能用。

1. 为什么非要用 SSH 通道传文件:场景分析与方案比选

1.1 什么样的实际场景会落到这个需求上

先说最常见的两个场景。一个是发布机是 Windows,但生产环境是 Linux,而且运维体系只开放 22 端口。你总不能为了让配置文件传输方便,就在服务器上临时起一个 FTP 吧?FTP 的明文传输和被动模式端口策略,在大多数安全评审会议上都是过不了关的。另一个场景是容器里的 .NetCore 服务需要把数据投递到另一台 Linux 机器,中间不能走消息队列、不能走 HTTP API,因为对方不是应用服务而是一台纯文件服务器。这种时候,SSH 通道就是最短路径。

另外很多人忽略的场景是:文件传上去之后,往往还要立刻做点事,比如改权限、移动目录、重启服务。哪怕你选择用某些 HTTP 上传方案,这些后续操作依然需要再认证一次。Renci.SshNet 的好处在于,SFTP/SCP 文件传输和 SSH 远程命令可以走同一套连接体系,文件传完直接开个 shell 执行命令,链路干净,认证也只需要一次。

1.2 不选 FTP、不选 WebDAV、也不自己写 Socket 的原因

FTP 我前面提到了一部分,再补充一个细节:很多 Linux 发行版现在默认不再安装 FTP 服务端,你得拿 apt 或 yum 额外装 vsftpd,这就引入了新的攻击面。而 OpenSSH 基本都是默认存在的,在绝大多数云服务器的安全组里,22 端口也是天然开放的。

那 WebDAV 呢?需要额外装 Apache 或 Nginx 的 WebDAV 模块,还要处理目录权限和锁机制,光配置成本就比 SSH 高不少。自己写 Socket 就更不现实了,你得自己实现协议认证、加密、断点续传,这些工作足够写一个开源项目。

Renci.SshNet 的优势在于它把 SSH 协议栈整个包好了,密码认证、私钥认证、SFTP、SCP、远程命令执行都是现成 API。对比其他库,比如 SSH.NET 新版(其实也就是 Renci.SshNet 改名后的延续)或者第三方的 Posh-SSH,Renci.SshNet 在 .NetCore 下的兼容性算是经过大范围验证的,老项目也一直有人维护。

1.3 这个方案的核心边界:能做什么、不能做什么

用 Renci.SshNet 能干的事,列个清单更清楚:

  • 通过密码或私钥连接远程 SSH 服务器
  • 使用 SFTP 协议上传、下载、创建目录、列出文件、删除文件、重命名
  • 使用 SCP 协议上传下载(服务器只启用 scp 时,比如某些嵌入式系统、网络设备)
  • 执行远程 shell 命令,并读取标准输出和错误流
  • 通过端口转发/隧道访问内网服务(这个进阶玩法,不是必须,但关键时刻很有用)

不能做或者不太好做的也有:它不是一个交互式终端模拟器,不适合当 PuTTY 用;断点续传需要自己实现;异步方法在某些历史版本里支持不完整,得根据版本确认。把这些边界摸清了,设计方案才不会跑偏。

2. 关键准备工作:包引入、连接认证与基础参数

2.1 NuGet 包选择和版本注意点

在 .NetCore 项目里引入 Renci.SshNet 没有悬念,直接搜 NuGet 里的Renci.SshNet包。安装命令是:

dotnet add package Renci.SshNet

不过有两点要留意。第一,很老的版本有SSH.NET和Renci.SshNet两个包名混乱的时期,现在主流是Renci.SshNet,命名空间是Renci.SshNet。第二,新版本从 2020 年开始做了一轮大的 API 调整,加了一些异步方法,但大量项目还是用同步方法,所以网上搜到的老代码大多能直接跑。建议直接上最新稳定版,别再用 2016 年的旧包,私钥格式兼容性、新加密算法的支持都在后边这些版本里补的。

2.2 密码认证与基础连接代码

最基础的写法如下,密码认证适合内部服务器、临时环境:

using Renci.SshNet; var host = "192.168.1.100"; var port = 22; var username = "deploy"; var password = "your-password"; using var client = new SftpClient(host, port, username, password); client.Connect(); if (client.IsConnected) { Console.WriteLine("SFTP 连接成功"); }

这里有一个很关键的习惯:连接用完必须释放。SftpClient实现了IDisposable,用using包裹是最基本的,否则每次连接产生的底层 socket 和 ssh session 可能不会被立即回收,时间一长就能看到句柄数暴涨。

再提一下ConnectionInfo的方式,它更灵活,可以指定超时、认证方式组合、保持连接等参数:

var connectionInfo = new ConnectionInfo( "192.168.1.100", 22, "deploy", new PasswordAuthenticationMethod("deploy", "your-password") ) { Timeout = TimeSpan.FromSeconds(15), KeepAliveInterval = TimeSpan.FromSeconds(30) }; using var client = new SftpClient(connectionInfo); client.Connect();

Timeout是连接握手超时,不是单次上传超时;KeepAliveInterval用于长时间低频操作时维持连接,防止服务器因为空闲踢掉连接。这个后面会细讲。

2.3 私钥认证与常见格式兼容问题

生产环境我最推荐用私钥认证,避免密码出现在配置文件里。Renci.SshNet 加载私钥的代码:

using Renci.SshNet; using Renci.SshNet.Security; var privateKeyFile = new PrivateKeyFile("/path/to/id_rsa", "passphrase-if-any"); var authMethod = new PrivateKeyAuthenticationMethod(username, privateKeyFile); var connectionInfo = new ConnectionInfo(host, port, username, authMethod) { Timeout = TimeSpan.FromSeconds(15) }; using var client = new SftpClient(connectionInfo); client.Connect();

这里有个很典型的坑:OpenSSH 新版本生成的私钥文件(默认格式)老版本库不识别。如果你执行ssh-keygen生成的id_rsa开头是-----BEGIN OPENSSH PRIVATE KEY-----,而你的 Renci.SshNet 版本比较低,它会直接抛InvalidKeyException。解决方式有三种:升级库版本;用ssh-keygen -p -m PEM -f id_rsa把密钥转成 PEM 格式;或者生成密钥时强制 PEM 格式:

ssh-keygen -m PEM -t rsa -b 2048 -f mykey

现在的版本对 OPENSSH 格式兼容性已经好很多,但如果你在维护老项目,这条经验仍然有参考价值。

2.4 主机密钥验证:该不该信任一切

Renci.SshNet 默认会做主机密钥校验,如果服务器指纹和本地缓存不一致,会抛SshAuthenticationException。很多开发者在测试阶段嫌麻烦,直接把校验关了:

connectionInfo.HostKeyValidator = new HostKeyValidator((host, key) => true);

我真不建议这么做。正确做法是把服务器的 expected fingerprint 掐死在代码或环境变量里:

connectionInfo.HostKeyValidator = new HostKeyValidator((host, key) => { var expected = "SHA256:xxx"; return key.FingerPrint.Equals(expected, StringComparison.OrdinalIgnoreCase); });

指纹怎么来的?在你本机执行:

ssh-keyscan -t ed25519 192.168.1.100

或直接ssh-keyscan -t rsa 192.168.1.100拿到公钥后,用ssh-keygen -lf /path/to/key.pub算出指纹。生产环境你总不希望中间人把文件路径都给改了。

3. SFTP 上传核心实现与稳定性优化

3.1 最基础的文件上传写法

SFTP 上传相当于把本地一个流推到远程路径。最简单的实现:

using var client = new SftpClient(host, port, username, password); client.Connect(); using var fileStream = File.OpenRead(@"C:\release\app.zip"); client.UploadFile(fileStream, "/home/deploy/app.zip");

这里要特别强调远程路径的格式:远程路径必须使用 Linux 风格的正斜杠/。Windows 开发环境下的Path.Combine("\\home\\deploy", "app.zip")这样的代码如果用在同一服务里且运行在 Windows,你会发现拼出来的是一个带反斜杠的路径,传到 Linux 服务器上要么目录不对,要么直接报路径不存在的错误。写这种远程路径时我都是手动拼接字符串,要拼接注意转义和目录是否存在。

3.2 上传前先检查远程目录

服务器上的目标目录大概率不是现成的,尤其是第一次部署。直接上传到不存在的目录,会抛SftpPathNotFoundException。所以常规做法是先检查再创建:

if (!client.Exists(remoteDirectory)) { client.CreateDirectory(remoteDirectory); }

如果要创建多层目录,Renci.SshNet 的CreateDirectory不会自动递归创建。比如/home/deploy/app/logs/2025,如果logs不存在,直接CreateDirectory可能失败。比较省事的写法是自己递归处理:

void EnsureRemoteDirectory(SftpClient client, string path) { var parts = path.Split('/'); var current = "/"; foreach (var part in parts) { if (string.IsNullOrEmpty(part)) continue; current = current == "/" ? $"/{part}" : $"{current}/{part}"; if (!client.Exists(current)) client.CreateDirectory(current); } }

这个方法是通用可靠的,虽然丑,但比依赖底层行为稳妥。

3.3 大量小文件上传:连接复用的价值

我最开始写接口时犯过一个错误:每个文件都新建一次SftpClient,然后Connect、上传、Disconnect。本地测试没感觉,真正跑一个包含几百个文件的发布目录时,整体耗时能到几分钟。原因很简单,Connect过程要做 TCP 握手、SSH 版本协商、密钥交换、认证多轮交互,单次成本很高。文件传输本体反而很快。

后来改成连接一次,循环上传:

using var client = new SftpClient(connectionInfo); client.Connect(); foreach (var localFile in files) { var remotePath = $"{remoteDirectory}/{Path.GetFileName(localFile)}"; using var fileStream = File.OpenRead(localFile); client.UploadFile(fileStream, remotePath); Console.WriteLine($"上传完成: {localFile}"); }

一个大连接循环传几十个文件,和每文件一个连接相比,性能差距不是一点半点。这个经验用在大文件上传、批量配置下发上同样适用。

3.4 大文件上传:缓冲区和上传进度

Renci.SshNet 的UploadFile有几个重载,其中可以传入Action<ulong>回调,实时感知字节数:

using var fileStream = File.OpenRead(@"D:\large_image.iso"); client.UploadFile(fileStream, "/data/large_image.iso", (uploaded) => { var percent = (float)uploaded / fileStream.Length * 100; Console.WriteLine($"\r已上传 {uploaded} 字节 ({percent:F2}%)"); });

这个回调在底层是每次写完一块缓冲区后调用,不需要自己造轮子。如果你想要更精细的控制,还可以设置BufferSize,但默认值一般够用。对于超过 1GB 的文件,我实测默认缓冲区可以稳定跑满内网带宽;如果是跨公网传输,瓶颈通常出在延迟和压缩上,适当调大BufferSize到 32KB 或 64KB 反而可能提高吞吐。

3.5 权限问题与属主问题

Linux 服务器上的sshd默认启用sftp-server子系统,但用户的主目录权限如果太开放,比如/home/deploy是777,sshd 会直接拒绝连接。这一点常被忽略:SFTP 连不上的时候别光想着代码问题,先到服务器上检查:

ls -ld /home/deploy

如果目录属于其他用户或权限不对,用chown和chmod修正。上传到非用户目录时,还要确认该用户有写权限,否则会报Permission denied。这个错误信息很直接,但很多人搜了半天代码,结果发现是服务器目录权限压根没给够。

3.6 上传后校验:别文件传完就以为完事

文件传完不代表字节都一样。我遇到过一次用公网传一个几百 MB 的离线安装包,传输过程没报错,但对方解压时失败了。排查一圈,最靠谱的办法是上传完成后做字节数量对比:

client.GetAttributes(remotePath).Size;

拿到远端文件大小后和本地文件长度比对,不相等就重传。更严格的做法是比对 MD5,但得提前算出来,或者用 SftpClient 拉回文件再算哈希,成本较高。对于部署包这类关键文件,大小校验是最起码的防线。

4. SCP 上传与远程命令执行

4.1 什么时候会用到 ScpClient

大部分服务器都启用了 SFTP 子系统,但总有一些特殊环境,比如精简版系统、网络设备固件,只支持 SCP 协议。Renci.SshNet 封装了ScpClient,代码和SftpClient相似:

using var client = new ScpClient(host, port, username, password); client.Connect(); using var fileStream = File.OpenRead(@"C:\firmware.bin"); client.Upload(fileStream, "/tmp/firmware.bin");

注意一点,ScpClient的Upload有多个重载,你可以传Stream、FileInfo或DirectoryInfo。传目录时它会把整个目录递归上传,这个比 SFTP 手动递归建目录省事,适合一次性分发整个文件夹。

但 SCP 的弱点也很明显:没有标准的列目录、获取文件列表协议,所以ScpClient不提供ListDirectory这类方法。它更适合纯上传、纯下载的线性场景。

4.2 用 SshClient 执行远端命令

文件传上去之后,常常要跑点什么,比如解压、移动、重启服务。Renci.SshNet 用SshClient执行命令的写法:

using var client = new SshClient(host, port, username, password); client.Connect(); var command = client.RunCommand("unzip -o /home/deploy/app.zip -d /home/deploy/app && /home/deploy/app/start.sh"); Console.WriteLine($"ExitCode: {command.ExitStatus}"); Console.WriteLine($"Output: {command.Result}");

注意RunCommand默认执行的是用户登录 shell 的默认环境。如果你想执行的命令和交互式 shell 里的行为一致,可能需要先加载环境变量,或者干脆用/bin/bash -c "source /etc/profile && 你的命令"。

我们实际项目里常用的是一个能一次做完的整串命令:上传压缩包到/tmp,然后一条命令解压到目标目录、改权限、启动。用&&串起来可以避免分步执行时因状态不同步带来的意外。

4.3 命令执行的安全边界

远程命令执行杀伤力很大,务必注意以下几点:

  • 不要把用户输入直接拼接进命令串,除非你有非常严格的过滤。否则一旦传入;、&&、管道符号,很容易出现命令注入。
  • 命令里的路径、文件名如果来自外部系统,宁可先白名单校验,不要黑名单过滤。
  • 生产环境建议用独立部署账号,只授予最必要的 sudo 权限,不要一上来就是 root。
  • 执行命令时把超时控制好,RunCommand不会自动掐断一个卡死的命令。碰到长命令,我习惯自己开线程或 Task 等结果,超时未返回就直接断开连接。

这一部分是从安全评审复盘里提纯出来的,每一行都是踩过的坑。

5. 文件管理扩展:下载、目录操作与状态查询

5.1 从远端下载文件到本地

和上传对称,下载也很常用,比如拉取日志:

using var client = new SftpClient(host, port, username, password); client.Connect(); using var localStream = File.Create(@"C:\logs\app.log"); client.DownloadFile("/var/log/app.log", localStream);

下载同样支持进度回调,和上传方式一致。下载时建议目标本地文件先创建好,避免网络流异常导致半截文件残留。

5.2 列目录与筛选文件

SFTP 的ListDirectory返回的是枚举,注意它可能包含.和..条目,遍历时最好过滤:

var entries = client.ListDirectory("/home/deploy/logs"); foreach (var entry in entries) { if (entry.Name == "." || entry.Name == "..") continue; if (entry.IsDirectory) Console.WriteLine($"[目录] {entry.FullName}"); else Console.WriteLine($"[文件] {entry.FullName} ({entry.Length} bytes)"); }

拿这个可以做一个简单的文件同步检查:对比本地文件名和远端文件名,缺什么传什么。

5.3 文件删除、重命名与移动

删除直接.DeleteFile(remotePath)。重命名用.RenameFile(oldPath, newPath)。有一个小坑:不同 SFTP 服务器的 rename 行为不完全一样,有的只支持同名同目录,跨目录重命名会失败。所以跨目录移动文件时,我常用“复制到目标目录 + 删除源文件”的方式来替代。虽然多了一步,但兼容性更好。

这些操作组合起来,可以拼出一个轻量级的部署工具:上传、校验、改名、备份旧版本、删除临时文件,全都在同一套连接里完成,比来回切工具要高效得多。

6. 常见问题排查与稳定性优化记录

6.1 一张表解决高频问题

现象常见原因处理办法
连接超时防火墙未放行 22 端口;IP 白名单拦截排查安全组与 iptables;确认端口可连通
认证失败用户名密码错误;账号被锁定;服务器禁止密码登录检查 sshd 配置中的PasswordAuthentication和PermitRootLogin
主机密钥错误服务器重装过系统,指纹变化更新本地 expected fingerprint 或删除 known_hosts 旧条目
上传报权限错误目标目录对用户不可写chown用户或调整目录权限
InvalidKeyException私钥格式不兼容升级库版本或用ssh-keygen -p -m PEM转格式
文件传上去大小为 0本地流未正确传递;远程目录不存在导致写入被拒绝用File.OpenRead;上传前先创建远程目录
远程目录不存在分层目录未完全创建使用前文EnsureRemoteDirectory方法递归创建
连接被服务器踢掉空闲无操作超时设置KeepAliveInterval,或降低单次连接空闲时间
执行命令无输出命令可能进入交互态;stderr 未捕获使用command.Error读取错误流;避免交互命令

6.2 连接稳定性:KeepAlive 的正确使用方式

ConnectionInfo.KeepAliveInterval默认是 0,也就是不主动发包。如果你一天只上传一次文件,每回还要先连接,倒无所谓。但如果你想保持一条长连接,或者服务器设了ClientAliveInterval比较短,建议设置一个合理值,比如 30 秒或 60 秒:

connectionInfo.KeepAliveInterval = TimeSpan.FromSeconds(30);

这个参数的意思是每隔多少秒发一个 SSH 层的 keepalive 包,相当于告诉服务器“我还在线”。它和 TCP KeepAlive 不是一个层面,SSH 层的这个更管用。

6.3 并发问题:别多个线程共用同一个 SftpClient

Renci.SshNet 的SftpClient不是线程安全的。多个线程同时用同一个 client 上传,运气好是排队,运气不好直接抛SocketException或协议错误。并发场景下要么每个线程独立建连接,要么在外部自己加锁串行化。我的经验是,文件传输这种场景,独立连接更好,因为锁导致的阻塞对吞吐影响很大,而且连接池也没那么难建。

6.4 异步与同步:用 Task.Run 包还是直接同步

Renci.SshNet 长期是个同步库,旧文档里大量代码都是同步调用的。在 ASP.NET Core 里直接同步调用会阻塞线程池,而线程池一旦耗尽,整个服务就可能像睡着了一样。早几年我的做法是Task.Run(() => client.UploadFile(...))扔到后台去,但这其实是逃课,线程并没有变少。

好消息是较新版本的库已经提供了一些异步入口,比如ConnectAsync以及部分异步文件操作。如果你被困在旧版本,又不想升级,折中方案是:用独立的后台任务队列去执行传输,而不是裸开线程。这样至少能控制并发数量,不会让资源被拉爆。新项目直接用新版,优先使用原生异步 API。

6.5 日志与可观测性:传了很多次,总要能审计

传文件不该是黑盒。我的习惯是每个传输任务记录以下字段:

  • 任务ID、源文件、目标路径
  • 开始时间、结束时间、耗时
  • 上传字节数、远程校验大小
  • 是否成功、失败原因

如果走的是 K8s 环境,直接打到 stdout 让日志组件收集。这个习惯在出线上故障时救过我好几次,尤其是指责对方机器有问题的时候,一拉日志就知道字节数对不对,连接时长正不正常,问题在谁那一侧一目了然。

7. 生产环境中的实际方案落地经验

7.1 参数配置:别把密码硬编码

密码、私钥路径、服务器地址这些不要写在代码里。用配置系统、环境变量或密钥管理服务都行。.NetCore 本身有一个IConfiguration体系,套一层很顺:

{ "SshOptions": { "Host": "192.168.1.100", "Port": 22, "Username": "deploy", "PrivateKeyPath": "/run/secrets/deploy_key", "RemoteBasePath": "/home/deploy/releases" } }

配置集中管理的另一个好处是:换服务器、换端口、换密钥时不用改代码发版,改配置重启就行。

7.2 重试策略:网络总会抖动

跨机房、跨公网传输时,连接失败非常正常。我的重试策略很简单:

  • 连接失败:重试 3 次,间隔 1 秒、2 秒、4 秒递增
  • 上传中途失败:不重试同一个流,因为底层状态可能已经错乱,不如断开重连再传
  • 对关键文件:部署前会额外做路径检查,确保不会覆盖写错地方

配合 Polly 这类库做重试也行,但我偏好手写一个简单的 for 循环,清晰可控,不引入额外依赖。

7.3 结尾再分享两个小细节

说了这么多,最后分享两个我实际摸索出来的小细节。第一,用SftpClient上传文件时,如果本地文件路径包含中文或空格,Stream 本身没问题,但远程拼路径时最好给文件加一层string.Replace(" ", "\\ ")之类的转义,或者干脆在上传前把文件重命名成纯字母数字名称。第二,每次传输前,先在远程临时目录写一个upload.tmp文件,测试真实链路是否通,这个探针文件返回成功后再执行正式上传任务,能大大减少“传了五分钟然后报错”的尴尬。

我个人的体会是,Renci.SshNet 用起来不难,但真正考验人的是连接生命周期、路径细节、权限边界这些旁支末节。把这些细节都攥在手里,无论是做部署工具还是数据同步服务,都会从容很多。希望这篇能帮你少走几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询