C#实现FTP服务器源码:含Web管理后台的可二次开发方案 简介这是一份基于C#语言完整实现的FTP服务器端源码工程同时包含Web管理端与后台服务适合学习.NET网络编程、FTP通信协议实现或需要搭建私有文件传输服务的开发者参考。软件覆盖文件管理、传输控制、权限分配、日志记录等基本能力支持特殊文件过滤并能安装成为Windows系统服务客户端既可使用IE浏览器或Windows资源管理器也可通过ftp命令行及CuteFTP等专门工具完成访问。整个zip压缩包约11.78MB共148个文件以36个cs源码文件与工程、解决方案文件为核心可直接编译构建另配套resx资源文件、图标及可执行程序还包含记录开发过程与更新信息的txt、htm等文档便于对照源码理解设计脉络。目前已有1461人下载学习这份完整代码尤其适合希望深入理解FTP服务端实现原理或在此基础上扩展权限控制、特殊过滤等自定义功能的中高级开发者。1. 这套FTP服务器源码到底解决什么问题你在 Windows 服务器或工控机上需要文件上传下载第一反应可能是装一个 FileZilla Server。但当你需要把用户、目录、权限做成接口、嵌进自己的程序里或要按项目快速二次开发时闭源软件就成了黑匣子——改不了存储逻辑接不了现有账号体系也没有代码能审计。标题里的“FTP服务器源码(C#版web端和后台)纯代码”指的就是一条更底层的路线用 C# 从 TcpListener 开始自己实现 FTP 服务端再把 web 管理后台打包进去。它解决的问题很具体让你拥有一个能自主维护、能接 API、能按公司内部场景瘦身的 FTP 服务。适合 C# 后端开发、上位机工程师和需要内网文件交换的团队一个能跑起来的最小服务端一个可管用户和日志的 web 后台总共只有几十个文件。真正让你翻车的往往不是协议有多难而是会话状态、路径安全和数据连接这三处细节。2. 先看总体FtpServer 核心对象与会话生命周期2.1 为什么用 C# 自己写而不是直接装 FileZilla在你决定投入这个方向之前先算一笔账。FileZilla Server、IIS FTP 都很成熟日常文件共享完全够用。但“FTP服务器代替文件共享”这个常见诉求背后往往隐藏着几个刚需用户账号要来自自己的业务数据库每个用户的根目录要动态绑定到某个项目编号上传完成后要触发后续处理程序还有审计日志要落到自己的存储里。这时候闭源服务就难受了。自己用 C# 写 FTP 服务的优势在于能嵌入FtpServer 可以作为一个后台线程跑在你的 Windows 服务里也可以和 ASP.NET Core 的 web 后台共用同一个用户存储。对比下来IIS FTP 最大的问题是配置脚本化能力弱FileZilla 有 API 但很多场景下你仍然绕不开 XML 配置和独立进程。自己实现的代码量其实不大一个命令分发器、一个数据连接管理、一组文件操作封装核心部分 1500 行左右。代价是你得亲手处理协议细节和异常分支但换来的是完全自主的行为控制。2.2 最小可跑骨架监听、会话、命令分发我习惯先把“能建立连接、能收发命令”的骨架跑起来再逐步加命令。这个骨架固定四件事启动 TcpListener、接受客户端、创建会话对象、进入命令循环。先看代码。public class FtpServer { private readonly TcpListener _listener; private readonly Dictionarystring, FuncFtpSession, string, string _commands; private readonly IPAddress _bindAddress; private readonly int _port; public FtpServer(IPAddress bindAddress, int port, IUserStore userStore) { _bindAddress bindAddress; _port port; _commands BuildCommandTable(userStore); _listener new TcpListener(bindAddress, port); } public void Start() { _listener.Start(100); // backlog Console.WriteLine($[FTP] 监听 {_bindAddress}:{_port}); _ AcceptLoopAsync(); } private async Task AcceptLoopAsync() { while (true) { var client await _listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClient(client)); } } private void HandleClient(TcpClient client) { using var session new FtpSession(client, this); session.Run(); // 命令循环内部读取控制连接 } }逻辑说明Start 里设置 backlog 为 100表示等待处理的连接队列长度如果同时有大量客户端连入这个值过小会直接导致连接拒绝。AcceptLoopAsync 中每个客户端丢到线程池里跑FTP 一个连接内是串行的命令请求所以每个会话单线程就够了不需要为每条命令再开线程。注意我没有在代码里标注 async/await 的每个细节生产环境建议用 CancellationToken 控制服务停止否则 Stop 时 Accept 循环不会自动退出。参数说明里有一个容易忽略的点监听地址不要用 0.0.0.0 一把梭内网部署我会建议固定到业务网卡 IP避免外网网卡直接暴露 21 端口引发扫描。端口默认 21但如果你要在本机调试避开系统已占用的端口会比较省心。2.3 命令分发器从“动词 参数”到处理方法FTP 控制连接上跑的是文本协议客户端发来的原始一行形如USER ftpuser\r\n。命令分发器的本质就是一个从动词到处理函数的映射表。这个设计决定了你后期加命令有多轻松。private Dictionarystring, FuncFtpSession, string, string BuildCommandTable(IUserStore userStore) { return new Dictionarystring, FuncFtpSession, string, string(StringComparer.OrdinalIgnoreCase) { [USER] (s, arg) s.HandleUser(arg, userStore), [PASS] (s, arg) s.HandlePass(arg, userStore), [PWD] (s, arg) s.HandlePwd(), [CWD] (s, arg) s.HandleCwd(arg), [PASV] (s, arg) s.HandlePasv(), [PORT] (s, arg) s.HandlePort(arg), [LIST] (s, arg) s.HandleList(arg), [NLST] (s, arg) s.HandleList(arg), [STOR] (s, arg) s.HandleStore(arg), [RETR] (s, arg) s.HandleRetrieve(arg), [QUIT] (s, arg) 221 再见\r\n, }; } // FtpSession 中的命令循环 public void Run() { _writer.WriteLine(220 欢迎使用自制 FTP 服务\r); while (_connected) { var line _reader.ReadLine(); if (line null) break; var parts line.Split( ); var verb parts[0].ToUpperInvariant(); var arg line.Substring(verb.Length).Trim(); if (!_server.TryGetCommand(verb, out var handler)) { _writer.Write(502 命令未实现\r\n); continue; } var response handler(this, arg); _writer.Write(response); _writer.Flush(); } }逻辑说明这个实现的精妙之处在于把每个命令的处理函数都收进字典后续要加REST、SIZE、MDTM只需在字典里多注册一条不用改 Run 循环。命令动词比较用了 OrdinalIgnoreCase省去大小写判断的烦恼。arg 的提取用 Substring 而不是 Split 后组回去因为路径参数里可能包含空格——比如CWD C:\My Files这种场景。关于响应格式FTP 协议规定控制连接换行符是\r\n但我这里写\r是因为 StreamReader.ReadLine 会吞掉\n我直接写完再 Flush。新手最容易犯的错是把控制连接和数据连接搞混后者是单独建立的不在这条链路上。3. FTP 协议核心登录、主动与被动模式、数据连接3.1 登录状态机不登录就别想干别的FTP 有控制连接和数据连接两种通道同时又有“未登录”和“已登录”两种状态。未登录时客户端能发的命令只有 USER、PASS、QUIT 这几个已登录后其余命令全部放行。这个状态机用纯字典映射不好表达我用一个显式分支来处理。public string HandleUser(string username, IUserStore store) { _username username; _authState AuthState.WaitingPass; return 331 需要密码\r\n; } public string HandlePass(string password, IUserStore store) { if (_authState ! AuthState.WaitingPass) return 503 请先发送 USER\r\n; var user store.FindByUsername(_username); if (user null || !user.VerifyPassword(password)) { _authState AuthState.Unauthenticated; return 530 用户名或密码错误\r\n; } _userId user.Id; _rootPath user.RootPath; _currentDir /; _authState AuthState.Authenticated; return 230 登录成功\r\n; }逻辑说明这里的关键是_authState的流转。USER 命令只负责记录用户名并进入等待密码状态密码校验放在 PASS 里。如果客户端直接发PWD此时_authState不是 Authenticated需要在 Run 循环里拦掉。常见做法是在命令分发前加一个过滤器已登录直接执行未登录时命令是否在白名单里。参数说明VerifyPassword建议用哈希存储而不是明文。最简单的方式是 PBKDF2 的Rfc2898DeriveBytes它自带盐比裸 SHA256 安全得多。很多号称“纯代码”的 FTP 项目直接把密码存成明文这在内网也许没人管但只要服务暴露到不可信网络账号泄露就是迟早的事。3.2 PASV 与 PORT数据连接的两条路数据连接是 FTP 里最容易翻车的部分。PASV 模式下服务器自己开一个监听端口然后告诉客户端去连它PORT 模式下服务器主动去连客户端告诉它的地址。过去 NAT 和内网穿透不普及PORT 很常见现在服务器多数有防火墙PASV 才是默认选择。private readonly Random _rng new(); private TcpListener? _dataListener; private TcpClient? _dataClient; public string HandlePasv() { // 1. 挑一个端口范围默认 50000-50100 var port _rng.Next(50000, 50100); var localIp GetLocalIpAddress(); _dataListener new TcpListener(localIp, port); _dataListener.Start(); // 2. 把 IP 和端口按 FTP 格式返回给客户端h1,h2,h3,h4,p1,p2 var ipPart string.Join(,, localIp.ToString().Split(.)); var p1 port / 256; var p2 port % 256; return $227 进入被动模式 ({ipPart},{p1},{p2})\r\n; } public string HandlePort(string arg) { // PORT h1,h2,h3,h4,p1,p2 var parts arg.Split(,); if (parts.Length ! 6) return 501 参数错误\r\n; var ip ${parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}; var port int.Parse(parts[4]) * 256 int.Parse(parts[5]); _dataClient new TcpClient(); _dataClient.Connect(ip, port); return 200 PORT 命令成功\r\n; }逻辑说明FTP 协议规定 PASV 回复里的 IP 和端口用逗号分隔端口是“高字节,低字节”两个字段拼回去的。我代码里p1 port / 256和p2 port % 256就是这个拆法。一个重要坑_dataListener.Start()之后真正的 Accept 要等具体命令LIST、STOR、RETR触发时才做。如果每条命令都重新 Start 一个新的 TcpListener旧的必须 Close否则端口泄漏连接一多系统就崩。参数说明50000-50100 这个端口范围不是随便写的它是给防火墙配白名单用的。如果你部署在云主机或企业内部防火墙后面需要把这段端口也放行只放行 21 端口是不够的。数据连接建立完成后FTP 的主动模式还有一个坑服务器回给客户端的 IP 如果是内网地址客户端在外网就连不上。所以内网生产环境中我一般直接固定 PASV 的数据端口范围并让防火墙管理员提前开好。3.3 STOR、RETR、LIST三类核心命令的落地有了数据连接之后文件传输命令本质就是“控制连接发指令数据连接传字节”。LIST 是服务端把目录列表写进数据连接RETR 是把文件写入数据连接STOR 是从数据连接读字节存成文件。三条命令共用一个数据通道对象传完立刻关闭这是 FTP 协议的基本规矩。public string HandleList(string arg) { var path _currentDir; if (!string.IsNullOrEmpty(arg)) path arg; if (!_dataListener.HasValue) return 425 请先发送 PASV 或 PORT\r\n; _ Task.Run(() { using var dataClient _dataListener!.AcceptTcpClient(); using var stream dataClient.GetStream(); using var writer new StreamWriter(stream); var fullPath _server.MapPath(_rootPath, path); foreach (var dir in Directory.GetDirectories(fullPath)) { var name Path.GetFileName(dir); writer.WriteLine($drwxr-xr-x 1 owner group 0 {DateTime.Now:MMM dd HH:mm} {name}); } foreach (var file in Directory.GetFiles(fullPath)) { var info new FileInfo(file); writer.WriteLine($-rw-r--r-- 1 owner group {info.Length} {DateTime.Now:MMM dd HH:mm} {info.Name}); } writer.Flush(); }); return 150 开始列表传输\r\n; }逻辑说明注意这里我用Task.Run把数据连接传输丢到后台控制连接立刻回复150 开始列表传输传输完成后再由另一个响应去通知客户端。如果你在控制线程里同步 Accept 数据连接客户端会一直等而且控制连接被占住也没法发送额外的状态响应。LIST 返回的格式严格来说是 Unix ls -l 风格但 Windows FTP 客户端大多不解析这个格式FileZilla 和 Windows 资源管理器手输 ftp 地址都能显示。真正的坑在中文文件名和中文日期上StreamWriter 默认编码是 UTF8如果你用本地代码页 GBK文件名会乱。STOR 的写入和 RETR 的读取在结构上是对称的这里不展开全部代码。核心就是FileStream打开目标文件用CopyToAsync把数据连接流转进去。有一点要提下载大文件时给 Stream.CopyAsync 传一个 128KB 的缓冲区比默认值快不少上传时先写入临时文件再替换避免客户端断开留下半截文件。4. 路径安全、用户权限与 web 端后台设计4.1 目录穿越防护虚拟根目录怎么落地现在逻辑已经闭环但直接拿用户传的路径拼字符串一定会被目录穿越轰碎。客户端传CWD ../../Windows/System32如果你直接拼到根路径后面就能看到用户根目录之外的东西。这个防线必须用Path.GetFullPath再做一次前缀校验。public static string MapPath(string rootPath, string userPath) { // 统一用正斜杠先拼到根目录下 var raw userPath.Replace(/, Path.DirectorySeparatorChar); var combined Path.Combine(rootPath, raw.TrimStart(Path.DirectorySeparatorChar)); // 重点拿到全路径后再判断是否真的在根目录里面 var fullPath Path.GetFullPath(combined); var rootFull Path.GetFullPath(rootPath).TrimEnd(Path.DirectorySeparatorChar) Path.DirectorySeparatorChar; if (!fullPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException(路径越界); } return fullPath; }逻辑说明Path.Combine遇到绝对路径时会忽略前面的 rootPath所以必须先 TrimStart 把用户路径前导分隔符去掉。真正拦截穿越的是后面的StartsWith(rootFull)判断。这个判断必须用GetFullPath后的结果否则..\..\Windows这种相对路径根本拼不到根目录外。用 OrdinalIgnoreCase 是为了兼容 Windows 盘符大小写Linux 下你改成 Ordinal 即可。这个函数是整段代码里最容易被低估的部分。我见过不少“纯代码”项目把路径安全做成简单的不允许..出现在参数里看似没问题实际字符串变形、编码绕过一抓一个准最后还是要回到规范化全路径比较上来。4.2 用户模型与权限控制用户存储我推荐用一个极简的 JSON 文件撑起整个后台——不引数据库降低部署门槛。每个用户一个模型用户名、密码哈希、根目录、读写权限、启用状态。存成users.jsonweb 后台启动时加载修改时全量写回。public class FtpUser { public string Id { get; set; } Guid.NewGuid().ToString(); public string Username { get; set; } ; public string PasswordHash { get; set; } ; public string RootPath { get; set; } ; public bool CanRead { get; set; } true; public bool CanWrite { get; set; } false; public bool CanDelete { get; set; } false; public bool Enabled { get; set; } true; } public class JsonUserStore : IUserStore { private readonly ListFtpUser _users; private readonly object _lock new(); private readonly string _filePath; public JsonUserStore(string filePath) { _filePath filePath; _users Load(); } public FtpUser? FindByUsername(string username) { lock (_lock) return _users.FirstOrDefault(u u.Username username); } public void Update(FtpUser user) { lock (_lock) { var index _users.FindIndex(u u.Id user.Id); if (index 0) _users[index] user; Save(); } } private ListFtpUser Load() File.Exists(_filePath) ? JsonSerializer.DeserializeListFtpUser(File.ReadAllText(_filePath)) ?? new ListFtpUser() : new ListFtpUser(); private void Save() File.WriteAllText(_filePath, JsonSerializer.Serialize(_users, new JsonSerializerOptions { WriteIndented true })); }逻辑说明JsonUserStore用锁保护列表是因为 FTP 会话线程和 web 后台线程会同时读写用户数据。FindByUsername加锁是为了防止遍历的同时有人修改列表导致异常。这个仓库的存储方式决定了后台管理系统不需要额外数据库一个文件就能部署到任意目录。一个小决定把PasswordHash放在模型里但VerifyPassword我建议在业务层调Rfc2898DeriveBytes不要放这里。JSON 序列化时我不会把哈希明文输出到日志审计时要小心。4.3 Web 端后台用 ASP.NET Core 给 FTP 服务加一个管理面板这个项目的“web 端和后台”是同一套代码。我用 ASP.NET Core 做最小 API 加一个 Razor 页面实现用户列表、新建用户、禁用用户和查看日志。关键点是把 FtpServer 的实例注册成单例让 web 层和 FTP 层共享同一个 JsonUserStore。var builder WebApplication.CreateBuilder(args); var userStore new JsonUserStore(Path.Combine(builder.Environment.ContentRootPath, App_Data, users.json)); var ftpServer new FtpServer(IPAddress.Any, 21, userStore); // 注册为单例 builder.Services.AddSingletonIUserStore(userStore); builder.Services.AddSingletonFtpServer(ftpServer); builder.Services.AddRazorPages(); var app builder.Build(); // 启动 FTP 服务 var ftp app.Services.GetRequiredServiceFtpServer(); ftp.Start(); // 用户管理接口 app.MapGet(/api/users, (IUserStore store) store.GetAll()); app.MapPost(/api/users, (IUserStore store, FtpUser user) { user.PasswordHash PasswordHasher.Hash(user.PasswordHash); store.Add(user); return Results.Created($/api/users/{user.Id}, user); }); app.MapPost(/api/users/{id}/disable, (string id, IUserStore store) { store.Disable(id); return Results.NoContent(); }); app.MapRazorPages(); app.Run();逻辑说明FTP 服务和 web 服务放在同一个进程里好处是用户操作立即生效不用重启 FTP。FtpServer的Start()在 web 应用启动时执行监听线程是后台线程不会阻塞 web 请求。改用户权限后 JsonUserStore 已更新FTP 会话里每次登录都会重新查 store所以过滤器那块要从共享的 IUserStore 读状态而不是缓存到会话里。Razor 页面那部分我就不贴全了一个表单加一个表格用来调/api/users接口刷新页面看最新列表。要接 Vue3、React 也行最小 API 已经把数据暴露给前端模板随意换。内网管理后台我一般会加一个 IP 白名单中间件避免暴露在不可信网络上。5. 联调与运行FTPServer 实战中常见的五类问题5.1 能登录但 LIST 命令一直卡住现象FileZilla 输入账号密码成功进入目录列表时直接超时服务端控制台看不到任何异常。原因PASV 模式回复给客户端的端口防火墙没有放行数据连接建立不了。解决把 PASV 端口范围固定在一个区间比如 50000-50100在防火墙和安全组里放行 TCP 入站对应端口如果你用云主机安全组规则也别忘了加。除了防火墙另一个常见原因是 PASV 返回的 IP 是内网地址而客户端在外网这种情况下要在配置里显式设置PasvAddress为公网地址。5.2 上传大文件时客户端报“连接被重置”现象几十 MB 小文件正常传 500MB 以上的文件到一半断连。原因控制连接长时间没有命令被交换机或防火墙的 idle 超时杀掉或者服务端 Socket 的SendTimeout太小。解决给NetworkStream设置Socket.SendTimeout 30000不要用默认的 0无限等待否则一个连接挂死线程池里全是半死会话。另外数据连接复制文件时用Stream.CopyToAsync并传入 128KB 缓冲区避免默认的小缓冲区频繁触发 TCP 分段。5.3 中文文件名乱码下载的目录列表文件名全变成问号现象Windows 客户端显示????.txtLinux 下用 lftp 正常。原因FTP 协议默认编码是 ASCII文件名里的中文字节在不同代码页下被解释错位。解决在 FEAT 响应里明确支持 UTF8并处理OPTS UTF8 ON命令在目录列表和文件名响应中统一用 UTF8 编码发送服务端默认使用StreamReader时也要指定 UTF8不要用系统默认代码页。FileZilla 和 Windows 资源管理器都支持 UTF8只有老旧的客户端还停留在本地代码页。5.4 开启 Windows 防火墙后主动模式PORT全部失败现象客户端配置的是主动模式所有 LIST/RETR/STOR 都超时换成 PASV 就正常。原因主动模式是服务器向客户端发起连接Windows 防火墙规则默认阻止出站方向对任意高端口的连接或被客户端所在网络拦掉。解决优先引导用户使用被动模式如果必须支持主动模式把服务器的程序加进出站允许规则并且给出固定端口段配合客户端网络白名单。部署到云上时主动模式基本不可用这是网络环境决定的。5.5 后台修改了用户权限旧会话却仍然能访问文件现象通过 web 后台把某个用户置为禁用但该用户已经在 FTP 上登录的会话仍然能继续传文件。原因FTP 会话在登录时把用户信息和权限缓存到了FtpSession的字段里后续每个命令都只读缓存不查用户存储。解决在HandleList、HandleStore等命令入口处加一个权限检查每次都从IUserStore重新读一次用户状态并判断Enabled如果有需要直接断开该用户的活动会话——用一个ConcurrentDictionarystring, FtpSession记录所有在线会话禁用用户时遍历并发421踢下线。禁用用户后立即生效这个交互对后台管理员来说是刚需。6. 生产环境验证与进阶把协议细节再往前推一步6.1 用 curl 和 FileZilla 做最小验证写完了源码别急着上业务。先在本地用 curl 打一遍协议流程验证登录、PASV、目录、上传下载四条链路是否全部正常。# 1. 登录并获取当前目录 curl ftp://127.0.0.1/ --user testuser:testpass # 2. 查看目录列表强制使用被动模式 curl --ftp-pasv ftp://127.0.0.1/data/ --user testuser:testpass # 3. 上传文件 curl --ftp-pasv -T ./local.txt ftp://127.0.0.1/upload/local.txt \ --user testuser:testpass # 4. 下载文件 curl --ftp-pasv ftp://127.0.0.1/upload/local.txt -o ./down.txt \ --user testuser:testpass这几条命令覆盖了登录、列表、STOR、RETR 四个核心动作。curl 过了再用 FileZilla 做一遍图形界面测试——因为 FileZilla 默认带 UTF8、时间戳解析和符系列扩展命令能帮你发现协议应答格式上的小毛病。如果 curl 正常但 FileZilla 列表慢多半是目录列表的日期格式不规范FileZilla 对 Linux 风格列表兼容性苛刻。6.2 进阶断点续传REST与 TLS/FTPS达到基本可用的状态后两个功能值得投入REST 断点续传和 FTPS 加密。REST 实现思路简单在HandleRetrieve和HandleStore之前接收REST offset命令存储偏移量打开文件流时Seek到指定位置即可。FileZilla 支持这个命令不加它大文件中断就得重头传。FTPS 则复杂得多。控制连接和数据连接都需要用SslStream包裹证书需要在 TcpListener Accept 后立刻做 TLS 握手且主动模式握手时机和被动模式还不一样坑比较多。我一般建议内网环境暂时不加 FTPS但如果你有跨公网传输场景这必须排上日程且端口范围和证书加载的位置都要提前设计好。6.3 我的做法与习惯最后说两个我踩过多年坑后形成的习惯。第一个习惯是我给 FTP 服务做联调时一定会开抓包软件看协议交互而不是只看服务端日志。没有真实报文你是看不出227回复的 IP 字段是不是被写错了也看不出LIST数据连接是卡在 SYN 还是卡在 Accept。第二个习惯是在所有TcpListener和TcpClient上设置合理的超时值绝不依赖默认的无限等待否则任何一个半死不活的会话都会把线程池占满。先写协议命令字典再补每个 handler是一个我自己试过最高效的开发顺序——协议骨架跑通后面全是体力活。希望帮到你。本文还有配套的精品资源点击获取