简介:这是一份基于C#开发的FTP服务器完整源码,涵盖Web端与后台管理,主要面向具备一定C#基础的开发者,用于学习FTP协议实现、服务端架构及权限控制等核心技能。资源包共148个文件,包括36个cs源码文件、20个resources资源文件、11个resx界面资源,以及htm页面、exe程序、配置文件等,压缩包大小约11.78MB,目录结构清晰,便于按模块研读。目前已有1250人学习下载。该套源码具备FTP服务器常见功能,如文件管理、传输控制、权限分配、日志记录,支持IE浏览器/资源管理器、ftp命令及专用客户端三种访问方式,还能安装为系统服务,并包含特殊文件过滤、开发过程文档与更新记录。对希望深入理解FTP原理并快速搭建自己服务器程序的开发者,是一份实用且难得的参考资料。
1. 先说结论:C#版FTP服务器源码,为什么老协议还有新生意
FTP服务器源码(C#版,带Web端管理和后台)这个标题看起来像老掉牙的东西,但真把它拆开,你会看到三块完全不同的需求:一是内网环境需要一台可控的文件交换服务,二是C#上位机或MES系统要把本地数据推给服务器却不想碰复杂HTTP接口,三是需要一个能自定义权限、看得见在线状态的轻量后台管理界面。这套源码的价值不在FTP协议本身,而在“C# + Web端 + 后台”的组合——它把文件传输、权限控制和可视化运维塞进了一个进程里。适合谁?适合被Windows生态绑住、想自己维护源码而不是装一个闭源FTP软件的开发者和运维。
2. 拆解这套FTP服务器的代码骨架:协议层、会话层与Web管理层的边界
2.1 FTP协议比想象中简单:五个核心命令撑起九成功能
FTP协议本身其实很简单,它的设计比HTTP还要直白。一个连接是控制连接,走TCP 21端口,客户端发纯文本命令,服务器回数字状态码;传输文件时再开一条数据连接,主动模式由服务器连回客户端,被动模式由客户端连到服务器的高位端口。命令来来去去就是USER、PASS、PWD、CWD、LIST、RETR、STOR这些,编码上几乎是纯ASCII,没有任何复杂的编解码状态机。所以用C#写FTP服务器,最难的不是协议解析,而是会话状态管理和数据连接的建立时机。
我第一次动手写FTP服务器的时候,犯过一个典型错误:把每个客户端请求都开一个线程,命令处理却忘了保存当前工作目录和登录状态。结果客户端PWD之后紧接着LIST,服务器根本不知道用户在哪个目录。正确的做法是给每个控制连接维护一个FtpSession对象,里面保存用户名、当前目录、字节偏移量和数据连接类型。命令分发函数只需要从这个会话对象取值再操作即可,无状态地写协议是不行的。
C#在这一层的优势是异步Socket和值类型struct的配合,每个会话占用资源很少。TcpListener.AcceptTcpClientAsync循环加上每客户端一个Task,在没有复杂业务逻辑的前提下,单机撑几百个并发会话很轻松。相比Go语言的goroutine加标准库net包,C#的Task调度在Windows下与线程池、IOCP绑得更自然,尤其是文件读写频繁的场景,异步FileStream配合Socket直接读写,调优过的性能完全够用。
2.2 为什么用C#而不是Python或Go:托管代码、Windows亲和与后台管理的联动
FTP服务器源码有很多语言版本,Python有pyftpdlib,Go有goftp,但C#版本的价值不在协议本身,而在三个地方。第一个是Windows亲和性,源码可以直接读取NTFS文件权限、Active Directory用户组、本地用户枚举,Web后台想要集成Windows账户登录,用C#写只有几行代码。第二个是后台管理一体化,同样是Web端加后台,C#用ASP.NET Core既做管理API又做静态前端托管,一键部署到IIS或者Windows服务里,不用像Python那样额外配Gunicorn、Supervisor,省掉的都是部署层的麻烦。
第三个容易被忽视的是类型化配置。C#的强类型配置在JSON反序列化时就能发现字段拼错,而不是运行到一半读取不到值才报错。很多C#上位机项目里,工程师顺手用同一套代码写FTP服务器模块,因为配置文件可以和其他业务代码共用同一个Config类。如果你想写FTP服务器练手,常见做法是先把核心协议层独立成类库,再把Web管理API放另一个项目里,这样以后换成.NET Core 8甚至.NET 9的时候不需要动协议代码。
2.3 源码目录应该怎么组织:从FtpServer.Core到WebAdmin的依赖方向
规划源码结构是第一件必须先做的事,比写第一行协议代码重要得多。我会把整套源码按三层拆分:FtpServer.Core放协议解析与会话管理,FtpServer.Data放用户、权限、日志的数据访问层,FtpServer.WebAdmin放ASP.NET Core的管理API和前端静态资源。依赖方向只能从WebAdmin指向Data,再指向Core,Core不允许引用任何Web层面的东西。这样才能保证FTP服务端进程能单独跑,也能被Web层用进程内方式宿主。
管理型FTP服务器的源码组织通常长这样:Core项目里放命令分发器、文件系统虚拟路径映射器、日志模块;Data项目用SQLite或JSON文件做持久化,用户表、目录权限表、会话日志表各一个文件;WebAdmin项目用最小API加Vue前端。目录命名上,我习惯把FtpCommand.cs、FtpSession.cs、FtpServerHost.cs放在Core根目录,把PassivePortManager.cs单独拆出来,方便以后做NAT穿透参数调整。
3. 用C#实现FTP核心:命令解析、被动模式与断点续传
3.1 最小可跑的FTP会话类:从Socket到命令分发
写FTP服务器源码,第一步一定是从一个能响应USER和PASS的会话类开始,不要一上来就写全命令集。常见做法是先搭一个TcpListener循环,每收到一个客户端连接就创建一个FtpSession并丢给Task.Run。FtpSession内部维护一个StreamReader和一个StreamWriter,分别对应控制连接的读写。客户端发来的命令按空格拆成两个部分:命令动词和参数,然后用委托字典做命令分发。
public class FtpSession { private readonly TcpClient _client; private readonly StreamReader _reader; private readonly StreamWriter _writer; private string _username = ""; private bool _authenticated; private string _currentDir = "/"; private readonly Dictionary<string, Func<string, string>> _commands; public FtpSession(TcpClient client) { _client = client; var stream = client.GetStream(); _reader = new StreamReader(stream, Encoding.UTF8); _writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; _commands = new Dictionary<string, Func<string, string>> { ["USER"] = HandleUser, ["PASS"] = HandlePass, ["PWD"] = _ => $"257 \"{_currentDir}\" is current directory.", ["QUIT"] = _ => { _client.Close(); return "221 Goodbye."; } }; } public async Task RunAsync() { await _writer.WriteLineAsync("220 FTP Server Ready."); string? line; while ((line = await _reader.ReadLineAsync()) != null) { var parts = line.Split(' ', 2); var verb = parts[0].ToUpperInvariant(); if (!_commands.TryGetValue(verb, out var handler)) { await _writer.WriteLineAsync("502 Command not implemented."); continue; } var response = handler(parts.Length > 1 ? parts[1] : ""); await _writer.WriteLineAsync(response); } } private string HandleUser(string param) { _username = param; return "331 Password required."; } private string HandlePass(string param) { _authenticated = CheckCredential(_username, param); return _authenticated ? "230 Logged in." : "530 Login incorrect."; } private bool CheckCredential(string user, string pwd) { return user == "admin" && pwd == "123456"; } }这里的关键设计是把命令动词映射到对应的处理函数,用字典做分发而不是写一长串switch。这样做的好处是每新增一个命令,只需要在字典里加一行,再写对应方法,代码结构清晰且不容易改坏其他命令。注意事项是,StreamReader的编码不要写死成UTF-8,某些FTP客户端可能用ASCII发命令,最稳妥的是用DetectEncodingFromByteOrderMarks配合默认编码,否则中文用户名会乱码。
PWD命令直接用Lambda返回字符串,是因为它不需要处理参数,也不需要额外的状态变更。QUIT命令里直接调用了_client.Close(),这里要注意,后续如果数据连接还在传输文件,要先关闭数据连接再回到控制连接回响应,否则客户端会一直卡在等待状态。命令分发器的返回值统一做成string,方便日志统一记录每个命令的耗时和结果。
3.2 被动模式(PASV)的端口管理与NAT场景处理
被动模式是整个FTP服务器源码中最容易翻车的地方,没有之一。主动模式下服务器用20端口连回客户端,客户端本地防火墙往往把入站连接拦掉,所以内网环境下基本都要开PASV。PASV的原理是服务器开一个监听端口,把地址和端口号用227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)的形式返回给客户端,客户端再去连那个端口。
private int _passivePortStart = 50000; private int _passivePortEnd = 50100; private int _dataPort; private TcpListener? _passiveListener; private string HandlePasv(string param) { _passiveListener?.Stop(); for (int port = _passivePortStart; port <= _passivePortEnd; port++) { try { _passiveListener = new TcpListener(IPAddress.Any, port); _passiveListener.Start(); _dataPort = port; break; } catch (SocketException) { continue; // 端口被占,试下一个 } } var ip = GetLocalIpAddress(); var parts = ip.Split('.'); return $"227 Entering Passive Mode ({string.Join(",", parts)}, {_dataPort / 256}, {_dataPort % 256})."; } public async Task<TcpClient?> AcceptDataConnectionAsync() { if (_passiveListener == null) return null; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { return await _passiveListener.AcceptTcpClientAsync(cts.Token); } catch (OperationCanceledException) { return null; // 客户端没来连,超时回收 } }第一次写PASV,十个有九个会踩到同一个坑:收到的客户端反馈“无法打开数据连接”,因为把局域网IP返回给了客户端。如果客户端在NAT里面,返回服务器的内网IP,客户端连不上;如果服务器在NAT后面,返回内网IP,客户端照样连不上。常见做法是把这个IP拿到配置项里读,不要自动探测,部署的时候手动填对外的公网IP。
端口范围参数必须暴露到配置文件里,而且设置成一个区间而不是单一端口。如果只开一个端口,客户端连接还没释放下一次传输就来了,端口被占导致LIST失败。区间长度看并发量,十个二十个并发,100个端口足够。每次进入新的PASV命令时,旧的监听器必须释放,否则每个会话积累一批Socket,最终句柄泄露机器卡死。
3.3 断点续传与文件锁:REST命令和FileStream的边界
断点续传在FTP里是一条REST命令,取一个字节偏移量,然后紧接着执行RETR或STOR。很多新手看到客户端报“550 REST not supported”就直接在服务端加了REST命令,但RETR和STOR的真正实现必须在FileStream的Seek上动刀。常见实现是FtpSession里保存一个long类型restOffset变量,REST命令把它设置成客户端传进来的偏移量,RETR和STOR命令在打开文件流后先Seek到这个偏移量再开始读写。
private long _restOffset; private string HandleRest(string param) { if (!long.TryParse(param, out var offset) || offset < 0) { return "501 Invalid REST parameter."; } _restOffset = offset; return $"350 Restarting at {offset}."; } private async Task HandleRetr(FtpSession session, string path) { var fullPath = MapVirtualPath(session.CurrentDir, path); await using var fs = new FileStream( fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 1024 * 64, FileOptions.Asynchronous | FileOptions.SequentialScan); fs.Seek(_restOffset, SeekOrigin.Begin); await session.SendResponseAsync("150 Opening data connection."); await using var dataClient = await session.AcceptDataConnectionAsync(); await using var dataStream = dataClient.GetStream(); await fs.CopyToAsync(dataStream, 81920); _restOffset = 0; await session.SendResponseAsync("226 Transfer complete."); }这里有一个必须注意的边界:FileShare.ReadWrite不能省。Windows下FTP服务器经常遇到“文件被另一个进程占用”的报错,因为热备份软件正在读这个文件,或者Excel打开着这个文件没释放。FileShare.ReadWrite允许其他进程同时读写,但代价是并发写同一文件时数据可能交叉,生产环境建议把目录按用户隔离,比靠锁去保护实际得多。
REST命令执行时不能立即验证文件存在,正确的时机是在RETR或STOR打开文件失败时返回550。还有如果客户端在REST之后换了文件名,偏移量就必须清零,这是和FileStream没有关系的状态机问题,只靠协议层状态清理是每个人都会漏改的地方。断点续传调试时,用FileZilla比用命令行ftp好用得多,图形界面能看到“是否恢复传输”的弹窗,容易判断是客户端没有发送REST还是服务端没响应。
4. Web端与后台管理:管理API设计、前端页面和权限模型
4.1 后台管理API:用户、目录权限、登录日志的三张表设计
一套FTP服务器源码如果只有协议层,还只能算半成品,另一半价值在后台管理上。Web端和后台不是给协议层换一个皮肤那么简单,它决定了你管理100个用户时是编辑JSON文件还是点网页按钮。常见做法是用户表、权限表、日志表三张表,用户表存用户名、密码哈希、主目录、是否启用;权限表存目录路径、可读可写标记、用户ID外键;日志表记录登录时间、IP、上传下载文件名和字节数。
密码哈希别用MD5,用PBKDF2或者SHA256加盐,这个在FTP服务器场景里特别容易被忽略。FTP账号的密码经常和Windows账户密码同一个,一旦数据库泄露等于把内网登录凭据全交出去。用户表落SQLite还是SQL Server取决于部署规模,个人和小团队用SQLite一个文件搞定,公司级别用SQL Server,因为能顺便接已有的AD域登录逻辑。
app.MapGet("/api/users", async (FtpDbContext db) => { var users = await db.Users .AsNoTracking() .Select(u => new { u.Id, u.Username, u.HomeDir, u.IsEnabled }) .ToListAsync(); return Results.Ok(users); }); app.MapPost("/api/users", async (FtpDbContext db, CreateUserRequest req) => { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) { return Results.BadRequest(new { error = "用户名和密码不能为空" }); } var salt = RandomNumberGenerator.GetBytes(16); var hash = Rfc2898DeriveBytes.Pbkdf2( req.Password, salt, 10000, HashAlgorithmName.SHA256, 32); var user = new FtpUser { Username = req.Username, PasswordHash = Convert.ToBase64String(hash), Salt = Convert.ToBase64String(salt), HomeDir = req.HomeDir, IsEnabled = true }; db.Users.Add(user); await db.SaveChangesAsync(); return Results.Created($"/api/users/{user.Id}", new { user.Id }); });GET接口返回用户列表时只返回必要的字段,PasswordHash和Salt绝不能出现在序列化结果里,否则后台管理系统一打开就把所有密码哈希暴露在浏览器开发者工具里。POST接口里如果用原生SQL拼接参数,SQL注入能直接绕过登录往里插管理员账号,这个项目后端用EF Core做参数化查询可以避免。
Rfc2898DeriveBytes的迭代次数10000是起步值,到了.NET 8里还是这个API,性能在低频的创建用户场景完全够用。真正要注意的是HomeDir字段:FTP用户的主目录在创建后如果改路径,已登录用户不会自动断线,服务器要等老会话退出下一次登录才用新目录,这个状态同步问题不做会在用户投诉“改了权限没生效”时被反复追问。
4.2 让FTP服务端进程与应用层隔离:Web层只能操作API,不能直接碰Socket
一个最容易走入的歧途是让Web后台直接管理FTP的Socket连接。有人会想,Web页面显示在线会话列表,那就在Web项目里引用FtpServer.Core,直接遍历Session集合。但这样做的代价是,Web回收应用程序池的时候,FTP服务端跟着重启,正在传一半的文件全部断掉,这个坑一旦触发必炸。常见做法是让FtpServer.Core作为一个独立进程运行,Web后台通过进程间通信或共享数据库拿到状态信息。
状态数据的传递有两条路。第一条路是FTP进程把会话状态和传输统计定时写进同一个SQLite库,Web后台只读数据库来展示;第二条路是FTP进程暴露一个本地HTTP健康检查接口,Web后台定时轮询。两条路各有适用场景:只有一个FTP服务节点用数据库最简单,以后要扩展成多节点就得换成HTTP探活。
进程隔离还有一个附加好处是Web后台可以单独更新,不需要停FTP传输。FTP服务端和Web管理端部署在同一个Windows服务里当然省事,但发布升级的时候总要提心吊胆,怕用户正在传的1GB文件被拖断。如果一定要合并,至少把Web管理模块放在独立AppDomain或用.NET 8的进程内隔离功能,避免程序集版本冲突引入的运行时错误。
4.3 前端页面的最小可用集:用户列表、在线会话、速率限制
前端页面不需要做成商用级别的Vue全家桶后台管理系统,一个单页应用加三四个视图就够了。用户列表页负责增删改查,在线会话页显示当前连接的用户IP、当前目录、已经传输的字节数和连接时长,速率限制页针对指定用户配置上下行带宽上限。很多团队会顺手用Vue 3加Element Plus搭一套后台管理架子,但在FTP运维场景里,纯静态HTML加fetch调用就够用,少一层构建链少一堆依赖。
在线会话的实时展示要谨慎处理,不要用轮询三秒刷新一次,因为每次刷新都要查一次数据库,在线人数多了数据库会很吃力。比较稳的办法是FTP服务端定期把会话快照写进内存表,Web后台通过SignalR或者Server-Sent Events订阅更新。如果不想引入SignalR,一个妥协方案是Web后台每10秒查一次会话表,查询只SELECT会话总数和最近10条,数据库压力在100人以下完全可接受。
会话列表的长度要限制,不能一次全量返回。一个运行了一周的FTP服务器,历史会话记录可能上百万条,全查出来页面直接卡死。接口上必须分页,同时把“当前在线”和“历史记录”分两个接口,在线接口只查IsActive标记为1的记录,查询速度才有保障。
5. FTP服务器源码部署避坑:从编译到上线的常见问题排查
5.1 现象一:FileZilla连接超时,卡在“正在连接服务器”不动
原因基本是Windows防火墙没有放行FTP服务进程的入站规则。FTP有控制端口21和数据端口区间50000到50100,只放行21端口忘了放行数据端口,登录能成功,一执行LIST就卡住或者超时。解决方法是新增入站规则,放行指定程序而不是只放行端口,否则以后升级程序换路径,防火墙规则又得重新配。
5.2 现象二:中文文件名上传后乱码
FTP协议没有强制规定文件名编码,Windows客户端以GBK发送,FileZilla默认UTF-8,服务端如果只按UTF-8解码,GBK的中文文件名必然乱码。解决方法是命令通道的判断:客户端连接后如果发送了OPTS UTF8 ON指令,则采用UTF-8,否则按系统默认编码GBK解码文件名。这个方案不一定百分之百兼容,但能覆盖95%的客户端,剩下的让用户在客户端手动切编码。
5.3 现象三:端口21被占用,启动抛异常
Windows自带的IIS FTP服务默认监听21端口,如果有老旧的FTP服务在跑,新起的进程肯定绑定失败。排查时用netstat -ano | findstr :21找到占用进程的PID,再用tasklist /fi "pid eq 进程号"确认是什么程序。解决方法是停掉IIS的FTP服务,或者把自研FTP的端口改成21以外的自定义端口,内网使用自定义端口还能顺便挡掉扫描器的端口探测。
5.4 现象四:Web后台登录不上去
新搭建的Web后台经常卡在数据库初始化。SQLite版本下,数据库文件默认放在程序运行目录,如果用NSSM注册成Windows服务启动,工作目录是system32,因此数据库文件会生成到C:\Windows\System32下,程序没权限写就直接崩溃。解决方法是把数据库连接串和存储目录全部改成绝对路径,并确保服务账户对那个目录有写权限。
5.5 现象五:大文件传一半断开,断点续传失效
这个往往是数据连接空闲超时写的逻辑问题。很多FTP服务器实现给数据连接设了一个超时时间,比如30秒,但无论传了多少数据,只要超过这个总时间就断开。正确的做法是设置空闲超时而不是总超时,超过10秒没有数据流动才断开,传输中的大文件不受影响。用Socket的ReceiveTimeout是最容易踩的写法,拿到数据后务必重置计时器。顺带一提:测大文件断点续传不要用内网,用同网段上千兆也可能几十秒传完,客户端根本来不及点暂停,要模拟真实断线场景最好用tc或网络工具把带宽限制到5MB/s。
6. 部署成Windows服务并验证吞吐:从源码到生产环境的最后一步
6.1 用NSSM把FTP服务器注册成Windows服务
代码写完后,第一步是把它部署成Windows服务而不是每次开机手动启动一个控制台窗口。但普通EXE要通过sc命令注册,日志重定向和失败重启配置麻烦,所以更简单的方式是用NSSM把exe包装成服务。打开命令行执行nssm install MyFtpServer,弹出界面后指定exe路径、启动参数和工作目录。NSSM保存的配置会写入注册表,服务启动时会以对应账户上下文工作。
6.2 验证服务是否正常的检查清单
部署完成后不能直接认为万事大吉,至少要过一遍下面这张检查清单:
- 控制台命令行输入ftp localhost,能收到220欢迎信息,输入用户和密码能登录。
- FileZilla连接并上传一个1GB文件,观察服务端日志记录的传输字节数和耗时。
- 用两台机器分别放防火墙内外测一遍PASV模式,确保数据端口放行。
- 杀掉FTP进程模拟崩溃,确认Windows服务自动重启配置生效。
- 开启Web后台,确认用户列表、在线会话和速率限制的展示正常。
- 查看SQLite数据库大小和日志轮转策略,确认长期运行不会把磁盘撑爆。
如果验证时发现传输速度比预期低很多,优先排查缓冲区大小。常见做法是FileStream的CopyToAsync缓冲区用81920字节,这是Socket和文件系统性能平衡的一个典型值,调得过大反而会因为内存页交换拖慢速度。超过100个并发时建议把线程池最小线程数调高,否则连接尖峰到来时线程创建延迟会让客户端误判为超时。
6.3 最后一步:把管理端口和数据端口隔离开
部署长期跑的服务,我会在防火墙规则上多做一层隔离,把FTP的数据端口、控制端口、Web后台管理端口分别放行,Web后台只对办公网IP段开放。这个动作做起来很简单,却能在最大程度上避免后台管理系统被外部扫描器盯上。这套源码能跑通只是第一步,跑了半年不出事才算真正落地。希望帮到你。
本文还有配套的精品资源,点击获取