C# 开发一个简单的FTP Server

在这篇文章中,我们将学习如何使用C#开发一个简单的FTP服务器。FTP(File Transfer Protocol)是一种用于在网络上的计算机之间传输文件的标准网络协议。C#提供了强大的网络编程功能,使得我们可以方便地创建自定义的FTP服务器。本文将引导你通过从基础开始一步一步地构建一个简单的FTP服务器应用程序。

环境设置

在开始编码之前,确保你的项目已经正确设置。新建一个C#控制台应用程序项目,并确保添加了必要的引用。

实现步骤

1. 引用必要的命名空间

在你的程序顶部,需要引用一些必要的命名空间来实现网络通信和文件操作:

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;

2. 创建FTP Server类

创建一个FTPServer类以处理FTP服务器的所有逻辑。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Globalization;

namespace AppFtpServer
{
    class FtpServer
    {
        private TcpListener _listener;
        private bool _isRunning;
        private string _rootDirectory;

        public FtpServer(string ip, int port, string rootDirectory)
        {
            _listener = new TcpListener(IPAddress.Parse(ip), port);
            _rootDirectory = rootDirectory;
        }

        public async Task Start()
        {
            _isRunning = true;
            _listener.Start();
            Console.WriteLine(#34;FTP Server started. Listening for connections on port {((IPEndPoint)_listener.LocalEndpoint).Port}...");

            while (_isRunning)
            {
                TcpClient client = await _listener.AcceptTcpClientAsync();
                _ = HandleClientAsync(client);
            }
        }

        private async Task HandleClientAsync(TcpClient client)
        {
            using (NetworkStream networkStream = client.GetStream())
            using (StreamReader reader = new StreamReader(networkStream))
            using (StreamWriter writer = new StreamWriter(networkStream) { AutoFlush = true })
            {
                await writer.WriteLineAsync("220 Welcome to Simple FTP Server");

                string username = null;
                bool isLoggedIn = false;
                string currentDirectory = _rootDirectory;

                TcpListener dataListener = null; // New field for managing the data connection

                while (true)
                {
                    string command = await reader.ReadLineAsync();
                    if (string.IsNullOrEmpty(command))
                        break;

                    string[] parts = command.Split(' ');
                    string cmd = parts[0].ToUpper();

                    switch (cmd)
                    {
                        case "USER":
                            username = parts[1];
                            await writer.WriteLineAsync("331 User name okay, need password");
                            break;

                        case "PASS":
                            if (username == "admin" && parts[1] == "password")
                            {
                                isLoggedIn = true;
                                await writer.WriteLineAsync("230 User logged in");
                            }
                            else
                            {
                                await writer.WriteLineAsync("530 Login incorrect");
                            }
                            break;

                        case "PWD":
                            if (!isLoggedIn)
                            {
                                await writer.WriteLineAsync("530 Not logged in");
                                break;
                            }
                            string relativePath = GetRelativePath(_rootDirectory, currentDirectory);
                            await writer.WriteLineAsync(#34;257 \"{relativePath}\" is current directory");
                            break;

                        case "CWD":
                            if (!isLoggedIn)
                            {
                                await writer.WriteLineAsync("530 Not logged in");
                                break;
                            }
                            if (parts.Length < 2)
                            {
                                await writer.WriteLineAsync("501 Syntax error in parameters or arguments");
                                break;
                            }
                            string requestedPath = parts[1];
                            string newPath;
                            newPath = _rootDirectory + requestedPath;


                            if (!newPath.StartsWith(_rootDirectory, StringComparison.OrdinalIgnoreCase))
                            {
                                await writer.WriteLineAsync("550 Requested action not taken. Access denied.");
                                break;
                            }
                            if (Directory.Exists(newPath))
                            {
                                currentDirectory = newPath;
                                await writer.WriteLineAsync("250 Directory successfully changed.");
                            }
                            else
                            {
                                await writer.WriteLineAsync("550 Requested action not taken. Directory not found.");
                            }
                            break;

                        case "RETR":
                            if (!isLoggedIn)
                            {
                                await writer.WriteLineAsync("530 Not logged in");
                                break;
                            }
                            if (parts.Length < 2)
                            {
                                await writer.WriteLineAsync("501 Syntax error in parameters or arguments");
                                break;
                            }
                            string filePath = _rootDirectory + "\\" + parts[1];


                            if (!filePath.StartsWith(_rootDirectory, StringComparison.OrdinalIgnoreCase))
                            {
                                await writer.WriteLineAsync("550 Requested action not taken. File access denied.");
                                break;
                            }
                            if (File.Exists(filePath))
                            {
                                await writer.WriteLineAsync("150 Opening data connection for file transfer.");

                                using (TcpClient dataClient = await dataListener.AcceptTcpClientAsync())
                                using (NetworkStream dataStream = dataClient.GetStream())
                                using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
                                {
                                    await fileStream.CopyToAsync(dataStream);
                                }
                                await writer.WriteLineAsync("226 Transfer complete.");
                            }
                            else
                            {
                                await writer.WriteLineAsync("550 File not found.");
                            }
                            break;

                        case "TYPE":
                            await writer.WriteLineAsync("200 Type set to I");
                            break;
                        case "PASV":
                            // Dispose of the previous dataListener if it exists
                            dataListener?.Stop();

                            dataListener = new TcpListener(IPAddress.Any, 0);
                            dataListener.Start();
                            int pasvPort = ((IPEndPoint)dataListener.LocalEndpoint).Port;
                            byte[] pasvIpBytes = IPAddress.Parse(((IPEndPoint)client.Client.LocalEndPoint).Address.ToString()).GetAddressBytes();
                            await writer.WriteLineAsync(#34;227 Entering Passive Mode ({pasvIpBytes[0]},{pasvIpBytes[1]},{pasvIpBytes[2]},{pasvIpBytes[3]},{pasvPort / 256},{pasvPort % 256})");
                            break;

                        case "LIST":
                            await writer.WriteLineAsync("150 Here comes the directory listing.");

                            using (TcpClient dataClient = await dataListener.AcceptTcpClientAsync())
                            using (NetworkStream dataStream = dataClient.GetStream())
                            using (StreamWriter dataWriter = new StreamWriter(dataStream, Encoding.UTF8))
                            {
                                string[] entries = Directory.GetFileSystemEntries(currentDirectory);
                                foreach (string entry in entries)
                                {
                                    FileAttributes attr = File.GetAttributes(entry);
                                    string name = Path.GetFileName(entry);
                                    DateTime lastWriteTime = File.GetLastWriteTime(entry);
                                    string dateStr = lastWriteTime.ToString("MMM dd HH:mm");
                                    string line = (attr & FileAttributes.Directory) == FileAttributes.Directory
                                        ? #34;drwxr-xr-x 1 owner group 0 {dateStr} {name}"
                                        : #34;-rw-r--r-- 1 owner group {new FileInfo(entry).Length} {dateStr} {name}";
                                    await dataWriter.WriteLineAsync(line);
                                }
                            }
                            await writer.WriteLineAsync("226 Directory send OK.");
                            break;

                        case "STOR":
                            if (!isLoggedIn)
                            {
                                await writer.WriteLineAsync("530 Not logged in");
                                break;
                            }
                            if (parts.Length < 2)
                            {
                                await writer.WriteLineAsync("501 Syntax error in parameters or arguments");
                                break;
                            }

                            string saveFilePath = _rootDirectory + parts[1];


                            if (!saveFilePath.StartsWith(_rootDirectory, StringComparison.OrdinalIgnoreCase))
                            {
                                await writer.WriteLineAsync("550 Requested action not taken. Access denied.");
                                break;
                            }

                            await writer.WriteLineAsync("150 Opening data connection for file upload.");
                            using (TcpClient dataClient = await dataListener.AcceptTcpClientAsync())
                            using (NetworkStream dataStream = dataClient.GetStream())
                            using (FileStream fileStream = new FileStream(saveFilePath, FileMode.Create, FileAccess.Write))
                            {
                                await dataStream.CopyToAsync(fileStream);
                            }
                            await writer.WriteLineAsync("226 Transfer complete.");
                            break;
                        case "QUIT":
                            await writer.WriteLineAsync("221 Goodbye");
                            return;
                        default:
                            await writer.WriteLineAsync("502 Command not implemented");
                            break;
                    }
                }

                dataListener?.Stop();
            }
        }

        private async Task AcceptDataConnectionAsync(TcpListener dataListener, string currentDirectory, StreamWriter controlWriter)
        {
            using (TcpClient dataClient = await dataListener.AcceptTcpClientAsync())
            using (NetworkStream dataStream = dataClient.GetStream())
            using (StreamWriter dataWriter = new StreamWriter(dataStream, Encoding.UTF8))
            {
                string[] entries = Directory.GetFileSystemEntries(currentDirectory);
                foreach (string entry in entries)
                {
                    FileAttributes attr = File.GetAttributes(entry);
                    string name = Path.GetFileName(entry);
                    DateTime lastWriteTime = File.GetLastWriteTime(entry);
                    string dateStr = lastWriteTime.ToString("MMM dd HH:mm");
                    string line = (attr & FileAttributes.Directory) == FileAttributes.Directory
                        ? #34;drwxr-xr-x 1 owner group 0 {dateStr} {name}"
                        : #34;-rw-r--r-- 1 owner group {new FileInfo(entry).Length} {dateStr} {name}";
                    await dataWriter.WriteLineAsync(line);
                }
            }
            dataListener.Stop();
            await controlWriter.WriteLineAsync("226 Directory send OK.");
        }

        private string GetRelativePath(string rootPath, string fullPath)
        {
            string relativePath = Path.GetRelativePath(rootPath, fullPath);
            return relativePath == "." ? "/" : "/" + relativePath.Replace('\\', '/');
        }
    }

}

注意:关于目录文件的处理不同FTP客户端好像有些不同,我这用的是FileZilla做的测试,这中间还有中文乱码问题,需要修改一个连接中的配置。

  1. USER 命令: 客户端发送: USER [用户名] 服务器响应: 如果用户名已发送: 331 User name okay, need password(用户名正确,需要密码)
  2. PASS 命令: 客户端发送: PASS [密码] 服务器响应: 如果用户名是"admin"且密码是"password": 230 User logged in(用户已登录) 如果凭证不匹配: 530 Login incorrect(登录不正确)
  3. PWD 命令:(打印工作目录) 客户端发送: PWD 服务器响应: 如果未登录: 530 Not logged in(未登录) 如果已登录: 257 "[当前目录]" is current directory(当前目录)
  4. CWD 命令:(改变工作目录) 客户端发送: CWD [目录] 服务器响应: 如果未登录: 530 Not logged in(未登录) 如果目录参数缺失: 501 Syntax error in parameters or arguments(参数或参数语法错误) 如果访问新目录被拒绝: 550 Requested action not taken. Access denied.(请求的操作未执行,访问被拒绝) 如果目录找到并可访问: 250 Directory successfully changed.(目录成功更改) 如果目录未找到: 550 Requested action not taken. Directory not found.(请求的操作未执行,未找到目录)
  5. RETR 命令:(检索文件) 客户端发送: RETR [文件] 服务器响应: 如果未登录: 530 Not logged in(未登录) 如果文件参数缺失: 501 Syntax error in parameters or arguments(参数或参数语法错误) 如果文件访问被拒绝: 550 Requested action not taken. File access denied.(请求的操作未执行,文件访问被拒绝) 如果文件找到并开始传输: 150 Opening data connection for file transfer.(正在为文件传输打开数据连接) 文件传输成功完成后: 226 Transfer complete.(传输完成) 如果文件未找到: 550 File not found.(文件未找到)
  6. TYPE 命令: 客户端发送: TYPE [类型] (在代码中,仅处理TYPE I,即二进制模式) 服务器响应: 200 Type set to I(类型设置为I)
  7. PASV 命令:(进入被动模式) 客户端发送: PASV 服务器响应: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)(进入被动模式),其中h1,h2,h3,h4是IP地址,p1,p2是被动模式下的数据端口。
  8. LIST 命令:(列出目录) 客户端发送: LIST 服务器响应: 在开始传输数据之前: 150 Here comes the directory listing.(这里是目录列表) 在目录列表成功发送后: 226 Directory send OK.(目录发送成功)
  9. STOR 命令:(存储文件) 客户端发送: STOR [文件] 服务器响应: 如果未登录: 530 Not logged in(未登录) 如果文件参数缺失: 501 Syntax error in parameters or arguments(参数或参数语法错误) 如果文件存储被拒绝: 550 Requested action not taken. Access denied.(请求的操作未执行,访问被拒绝) 在文件上传开始之前: 150 Opening data connection for file upload.(正在为文件上传打开数据连接) 文件上传成功完成后: 226 Transfer complete.(传输完成)
  10. QUIT 命令:(退出) 客户端发送: QUIT 服务器响应: 221 Goodbye(再见)
  11. 未识别或未实现的命令: 服务器响应: 502 Command not implemented(命令未实现)

3. 启动服务器

在Main方法中实例化并启动你的FTP服务器:

public static async Task Main(string[] args)
{
    string ip = "127.0.0.1";
    int port = 21;
    string rootDirectory;

    if (args.Length > 0 && Directory.Exists(args[0]))
    {
        rootDirectory = Path.GetFullPath(args[0]);
    }
    else
    {
        rootDirectory = "d:\\book";
        while (!Directory.Exists(rootDirectory))
        {
            Console.WriteLine("Directory does not exist. Please enter a valid path:");
            rootDirectory = Console.ReadLine();
        }
        rootDirectory = Path.GetFullPath(rootDirectory);
    }

    Console.WriteLine(#34;Using root directory: {rootDirectory}");

    FtpServer server = new FtpServer(ip, port, rootDirectory);
    await server.Start();
}

总结

本文介绍了如何用C#开发一个基本的FTP服务器。我们的服务器目前支持基本的FTP命令并且能够响应客户端的请求。虽然这是一个简化的版本,但它为更复杂和功能完整的FTP服务器打下了基础。可以尝试扩展这个服务器,支持更多的FTP命令,添加认证、加密、和更复杂的文件管理等高级功能。

原文链接:,转发请注明来源!