在 C# 中创建 FTP 服务器 - 支持 IPv6






4.91/5 (76投票s)
本文介绍了如何使用 RFC 规范在 C# 中创建可用的 FTP 服务器。
介绍
首先,我想说明本文不是什么。它不是一个功能齐全、可扩展且安全的 FTP 服务器的示例。它旨在介绍如何根据规范创建应用程序,介绍套接字通信、异步方法、流编码和基本加密。请勿在任何生产环境中使用此 FTP 服务器。它不安全,我也没有对其进行任何可扩展性测试。言归正传,我们开始吧。
什么是 FTP?
根据 IETF RFC 959 中的规范,文件传输协议具有以下目标:
- 促进文件(计算机程序和/或数据)共享
- 鼓励间接或隐式(通过程序)使用远程计算机
- 保护用户免受主机之间文件存储系统差异的影响,以及
- 可靠高效地传输数据。
FTP 是一种在计算机之间传输文件的方式。通常,客户端连接到服务器的端口 21,发送一些登录信息,然后访问服务器的本地文件系统。
基本步骤
我们将首先创建一个服务器,该服务器可以侦听来自客户端的连接。一旦我们可以接受连接,我们将学习如何将这些连接传递给另一个线程来处理命令。
如何侦听连接?
构建 FTP 服务器的第一步是让服务器侦听来自客户端的连接。让我们从基本的 FTPServer
类开始
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Net.Sockets;
using System.IO;
using System.Threading;
namespace SharpFtpServer
{
public class FtpServer
{
private TcpListener _listener;
public FtpServer()
{
}
public void Start()
{
_listener = new TcpListener(IPAddress.Any, 21);
_listener.Start();
_listener.BeginAcceptTcpClient(HandleAcceptTcpClient, _listener);
}
public void Stop()
{
if (_listener != null)
{
_listener.Stop();
}
}
private void HandleAcceptTcpClient(IAsyncResult result)
{
TcpClient client = _listener.EndAcceptTcpClient(result);
_listener.BeginAcceptTcpClient(HandleAcceptTcpClient, _listener);
// DO SOMETHING.
}
}
}
让我们分解一下这里发生了什么。根据 MSDN 文档,TcpListener
“侦听来自 TCP 网络客户端的连接”。我们创建它并告诉它在端口 21 上侦听服务器上的任何 IPAddress
。如果您的机器中有多个网络适配器,您可能希望限制您的 FTP 服务器侦听哪个适配器。如果是这样,只需将 IPAddress.Any
替换为您要侦听的 IP 地址的引用。创建它之后,我们调用 Start
,然后调用 BeginAcceptTcpClient
。Start
很明显,它只是启动 TcpListener
以侦听连接。现在 TcpListener
正在侦听连接,我们必须告诉它在客户端连接时执行某些操作。这正是 BeginAcceptTcpClient
所做的事情。我们正在传入另一个方法 (HandleAcceptTcpClient
) 的引用,该方法将为我们完成工作。BeginAcceptTcpClient
不会阻塞执行,而是立即返回。当有人连接时,.NET Framework 将调用 HandleAcceptTcpClient
方法。在那里,我们调用 _listener.EndAcceptTcpClient
,传入我们收到的 IAsyncResult
。这将返回 TcpClient
的引用,我们可以使用它与客户端通信。最后,我们必须告诉 TcpListener
继续侦听更多连接。
已连接,现在怎么办?
让我们首先获取客户端和服务器之间存在的 NetworkStream
的引用。在 FTP 中,此初始连接称为“控制”连接,因为它用于向服务器发送命令以及服务器向客户端发送响应。任何文件传输都会在稍后的“数据”连接中处理。控制连接使用基于 TELNET 的简单文本方式发送命令和接收响应,使用 ASCII 字符集。既然我们知道这一点,我们可以创建一个易于使用的 StreamWriter
和 StreamReader
,使来回通信变得容易。到目前为止,我们还没有真正创建任何 FTP 服务器,更多的是一个侦听端口 21、接受连接并可以来回读写 ASCII 的东西。让我们看看读写操作
private void HandleAcceptTcpClient(IAsyncResult result)
{
TcpClient client = _listener.EndAcceptTcpClient(result);
_listener.BeginAcceptTcpClient(HandleAcceptTcpClient, _listener);
NetworkStream stream = client.GetStream();
using (StreamWriter writer = new StreamWriter(stream, Encoding.ASCII))
using (StreamReader reader = new StreamReader(stream, Encoding.ASCII))
{
writer.WriteLine("YOU CONNECTED TO ME");
writer.Flush();
writer.WriteLine("I will repeat after you. Send a blank line to quit.");
writer.Flush();
string line = null;
while (!string.IsNullOrEmpty(line = reader.ReadLine()))
{
writer.WriteLine("Echoing back: {0}", line);
writer.Flush();
}
}
}
现在我们能够在客户端和服务器之间发送和接收数据。它目前没有任何实际用途,但我们正在接近中……需要注意的重要一点是,您始终希望在写入后将缓冲区刷新到客户端。默认情况下,当您写入 StreamWriter
时,它会将其保留在本地缓冲区中。当您调用 Flush
时,它实际上会将其发送到客户端。要测试,您可以在命令行上启动 telnet 应用程序并连接到您的服务器:telnet localhost 21。如果您没有安装 telnet,可以在 Windows 7 的命令行上使用以下命令安装:pkgmgr /iu:"TelnetClient"。
命令和回复的格式
Commands
规范的 4.1 节“FTP 命令”详细说明了每个命令是什么以及每个命令应如何工作。
命令以命令代码开头,后跟参数字段。命令代码是四个或更少字母字符。大写和小写字母字符应被视为相同。参数字段由可变长度字符串组成,以字符序列 <CRLF> 结尾。
一个用于向服务器发送用户名的 FTP 命令示例是 USER my_user_name。5.3.1 节定义了所有命令的语法。
回复
4.2 节“FTP 回复”详细说明了服务器应如何响应每个命令。
FTP 回复由一个三位数字……后跟一些文本组成。回复被定义为包含 3 位代码,后跟空格 <SP>,后跟一行文本,并以 Telnet 行尾代码终止。
规范中还有更多内容,包括如何发送多行回复,我鼓励读者阅读并理解,但我们在此不讨论。作为单行回复的示例,服务器可能会发送 200 Command OK。FTP 客户端将知道代码 200 表示成功,并且不关心文本。文本是为另一端的人类准备的。5.4 节定义了每个命令的所有可能回复。
连接时应做什么?
让我们看看 FTP 规范中规定我们应该在建立连接时做什么。5.4 节“命令和回复的序列”详细说明了客户端和服务器应如何通信。根据 5.4 节,在正常情况下,服务器会在连接建立时发送 220 回复。这让客户端知道服务器已准备好接收命令。从现在开始,我将使用出色的 FTP 客户端 FileZilla 进行测试。它易于使用,并在顶部窗口显示原始命令和回复,这对于调试非常有用。因此,让我们修改 HandleAcceptTcpClient
方法
TcpClient client = _listener.EndAcceptTcpClient(result);
_listener.BeginAcceptTcpClient(HandleAcceptTcpClient, _listener);
NetworkStream stream = client.GetStream();
using (StreamWriter writer = new StreamWriter(stream, Encoding.ASCII))
using (StreamReader reader = new StreamReader(stream, Encoding.ASCII))
{
writer.WriteLine("220 Ready!");
writer.Flush();
string line = null;
while (!string.IsNullOrEmpty(line = reader.ReadLine()))
{
Console.WriteLine(line);
writer.WriteLine("502 I DON'T KNOW");
writer.Flush();
}
}
现在我们正在发送规范中要求发送的内容,即连接时的 220 响应。然后我们只是进入一个循环,侦听来自服务器的命令。我们尚未实现任何命令,因此我们只回复 502 Command Not Implemented。查看规范,我们可以看到命令数量相当大,此时将当前连接的逻辑处理分拆到自己的类中可能是一个好主意。因此,让我们创建 ClientConnection
类。
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text;
using System.Net.Sockets;
using System.Net;
using log4net;
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
namespace SharpFtpServer
{
public class ClientConnection
{
private TcpClient _controlClient;
private NetworkStream _controlStream;
private StreamReader _controlReader;
private StreamWriter _controlWriter;
private string _username;
public ClientConnection(TcpClient client)
{
_controlClient = client;
_controlStream = _controlClient.GetStream();
_controlReader = new StreamReader(_controlStream);
_controlWriter = new StreamWriter(_controlStream);
}
public void HandleClient(object obj)
{
_controlWriter.WriteLine("220 Service Ready.");
_controlWriter.Flush();
string line;
try
{
while (!string.IsNullOrEmpty(line = _controlReader.ReadLine()))
{
string response = null;
string[] command = line.Split(' ');
string cmd = command[0].ToUpperInvariant();
string arguments = command.Length > 1 ? line.Substring(command[0].Length + 1) : null;
if (string.IsNullOrWhiteSpace(arguments))
arguments = null;
if (response == null)
{
switch (cmd)
{
case "USER":
response = User(arguments);
break;
case "PASS":
response = Password(arguments);
break;
case "CWD":
response = ChangeWorkingDirectory(arguments);
break;
case "CDUP":
response = ChangeWorkingDirectory("..");
break;
case "PWD":
response = "257 \"/\" is current directory.";
break;
case "QUIT":
response = "221 Service closing control connection";
break;
default:
response = "502 Command not implemented";
break;
}
}
if (_controlClient == null || !_controlClient.Connected)
{
break;
}
else
{
_controlWriter.WriteLine(response);
_controlWriter.Flush();
if (response.StartsWith("221"))
{
break;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
throw;
}
}
#region FTP Commands
private string User(string username)
{
_username = username;
return "331 Username ok, need password";
}
private string Password(string password)
{
if (true)
{
return "230 User logged in";
}
else
{
return "530 Not logged in";
}
}
private string ChangeWorkingDirectory(string pathname)
{
return "250 Changed to new directory";
}
#endregion
}
}
如您所见,我们将处理客户端连接的代码移到了 ClientConnection
类中,并且我添加了一些 FTP 命令,例如 USER、PASS、CWD 和 CDUP。构造函数获取 TcpClient
并打开流。然后我们可以调用 HandleClient
来告诉客户端我们已准备好接收命令并进入命令循环。我们在命令循环中做的第一件事是根据 SPACE 字符拆分从客户端接收到的行。第一项将是命令。由于规范规定命令不区分大小写,因此我们将其转换为大写以用于 switch
语句。使用上面的这个类,我们可以将 HandleAcceptTcpClient
方法修改为以下内容
private void HandleAcceptTcpClient(IAsyncResult result)
{
_listener.BeginAcceptTcpClient(HandleAcceptTcpClient, _listener);
TcpClient client = _listener.EndAcceptTcpClient(result);
ClientConnection connection = new ClientConnection(client);
ThreadPool.QueueUserWorkItem(connection.HandleClient, client);
}
ThreadPool.QueueUserWorkItem
创建一个新的后台线程,服务器将使用该线程处理请求。这使前台线程保持空闲,并允许 .NET Framework 为您管理线程。
有了新的 ClientConnection
类和上面对 HandleAcceptTcpClient
的修改,我们现在有了一个部分功能的 FTP 服务器!只需运行您的项目并使用 FileZilla 连接。目前可以使用任何您想要的用户名和密码。如您所见,我们在 Password
方法中总是返回 true(显然在将其视为真正的 FTP 服务器之前需要更改)。上面代码的 FileZilla 示例输出
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Command: SYST
Response: 502 Command not implemented
Command: FEAT
Response: 502 Command not implemented
Status: Connected
Status: Retrieving directory listing...
Command: PWD
Response: 257 "/" is current directory.
Command: TYPE I
Response: 502 Command not implemented
Error: Failed to retrieve directory listing
如上所示,当 FileZilla 首次连接时,服务器会发送 220 Service Ready 响应。然后客户端发送 USER 命令,其参数是您指定的任何用户名。它一直持续到发出 TYPE 命令。我们的服务器尚不知道如何执行该命令,因此我们从那里开始。
TYPE 命令
在 5.3.1 节中,TYPE 命令被声明为 TYPE <SP> <type-code> <CRLF>。这意味着它是 TYPE 命令,后跟一个空格,后跟一些类型代码,后跟行尾字符。5.3.2 节定义了不同命令的参数。<type-code> 定义为以下之一:I、A 后跟可选空格和 <form-code>、E 后跟可选空格和 <form-code>,或 L 后跟必需空格和 <byte-size>。同一节将 <form-code> 定义为以下之一:N、T 或 C。使用此方法,我们可以定义一个方法来处理此命令
private string Type(string typeCode, string formatControl)
{
string response = "";
switch (typeCode)
{
case "I":
break;
case "A":
break;
case "E":
break;
case "L":
break;
default:
break;
}
switch (formatControl)
{
case "N":
break;
case "T":
break;
case "C":
break;
}
return response;
}
我们现在有了方法模型,但它实际做了什么?4.1.2 节定义了传输参数命令,包括 TYPE 命令。这是传输数据的方式,A = ASCII,I = 图像,E = EBCDIC,L = 本地字节大小。根据 5.1 节,FTP 服务器的最小实现只需要 TYPE A,默认格式控制为 N(不可打印)。其余的我们将暂时向客户端表示不支持。
现在我们的 Type
方法看起来像这样
private string Type(string typeCode, string formatControl)
{
string response = "";
switch (typeCode)
{
case "A":
response = "200 OK";
break;
case "I":
case "E":
case "L":
default:
response = "504 Command not implemented for that parameter.";
break;
}
if (formatControl != null)
{
switch (formatControl)
{
case "N":
response = "200 OK";
break;
case "T":
case "C":
default:
response = "504 Command not implemented for that parameter.";
break;
}
}
return response;
}
我们还需要将其添加到 HandleClient
方法中的命令循环 switch
语句中。
case "TYPE":
string[] splitArgs = arguments.Split(' ');
response = Type(splitArgs[0], splitArgs.Length > 1 ? splitArgs[1] : null);
break;
现在让我们再次运行 FileZilla...
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Status: Connected
Status: Retrieving directory listing...
Command: PWD
Response: 257 "/" is current directory.
Command: TYPE I
Response: 504 Command not implemented for that parameter.
Error: Failed to retrieve directory listing
看起来 FileZilla 不满足于最低限度的服务器实现。它似乎需要图像类型来传输目录列表。这意味着我们现在需要存储要在数据连接上使用的传输类型,这意味着需要一个新的类级别变量。现在我们可以扩展我们的 Type
方法,使其看起来像这样
private string Type(string typeCode, string formatControl)
{
string response = "500 ERROR";
switch (typeCode)
{
case "A":
case "I":
_transferType = typeCode;
response = "200 OK";
break;
case "E":
case "L":
default:
response = "504 Command not implemented for that parameter.";
break;
}
if (formatControl != null)
{
switch (formatControl)
{
case "N":
response = "200 OK";
break;
case "T":
case "C":
default:
response = "504 Command not implemented for that parameter.";
break;
}
}
return response;
}
再次运行 FileZilla,我们得到以下结果
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Status: Connected
Status: Retrieving directory listing...
Command: PWD
Response: 257 "/" is current directory.
Command: TYPE I
Response: 200 OK
Command: PASV
Response: 502 Command not implemented
Command: PORT 127,0,0,1,239,244
Response: 502 Command not implemented
Error: Failed to retrieve directory listing
这次我们走得更远了,但现在它正在尝试使用 PASV 和 PORT 命令,这两个命令都没有实现。让我们回到规范中看看它们是什么……PASV 或被动,是向服务器发出的请求,要求它打开一个新端口供客户端连接以进行数据传输。当客户端位于防火墙后时,这很有用;由于服务器无法直接连接到客户端上的端口,因此客户端必须启动它。另一方面,PORT 指示服务器连接到给定 IP 和端口上的客户端。现在我们开始进入另一个连接,即数据连接……
数据连接
数据连接用于客户端或服务器需要传输文件或其他数据(例如目录列表)时,这些数据可能太大而无法通过控制连接发送。此连接不总是需要存在,并且通常在不再需要时销毁。如上所述,有两种类型的数据连接:被动和主动。对于主动连接,服务器使用客户端的 IP 和端口作为参数启动到客户端的数据连接。对于被动连接,服务器开始侦听新端口,在响应中将该端口信息发送回客户端,并等待客户端连接。
PORT 命令
让我们首先从 PORT 命令开始。此命令告诉服务器连接到给定的 IP 地址和端口。这通常是客户端的 IP 地址,但不一定。客户端可以将另一台服务器的 IP 地址发送给服务器,然后该服务器将使用 FXP 或 文件交换协议 为客户端接受数据连接。这对于服务器到服务器的传输很有用,其中客户端不需要自己接收数据。FXP 将不在本文中介绍。PORT 命令将其参数作为逗号分隔的数字列表。这些数字中的每一个都代表一个字节。IPv4 地址由四个字节(0 - 255)组成。端口由一个 16 位整数(0 - 65535)组成。我们做的第一件事是将这些数字中的每一个转换为一个字节,并将它们存储在两个单独的数组中。IP 地址在第一个数组中,端口在第二个数组中。IP 地址可以直接从字节数组创建,但端口需要首先转换为整数。为此,我们利用 BitConverter
类。规范规定首先发送高位(最高有效字节先存储),但根据您的 CPU 架构,您的 CPU 可能期望最低有效字节优先。存储字节顺序的这两个差异称为 字节序。大端格式首先存储最高有效位,在 大型机 中使用的 CPU 上很常见,小端格式首先存储最低有效字节,在桌面机器上更常见。考虑到这一点,如果当前架构是小端格式,我们必须反转数组。
if (BitConverter.IsLittleEndian)
Array.Reverse(port);
如果需要,在反转后,我们可以直接转换它。
BitConverter.ToInt16(port, 0)
一旦我们确定了要连接的端点,我们就发送 200 响应。当客户端尝试使用数据连接时,我们将连接到该端点以发送/接收数据。
PASV 命令
PASV(被动)命令指示服务器打开一个端口进行侦听,而不是直接连接到客户端。这对于客户端位于防火墙后面且无法接受传入连接的情况很有用。此命令不带任何参数,但要求服务器响应客户端应连接的 IP 地址和端口。在这种情况下,我们可以为数据连接创建一个新的 TcpListener
。在构造函数中,我们指定端口 0,这告诉系统我们不关心使用哪个端口。它将返回 1024 到 5000 之间可用的端口。
IPAddress localAddress = ((IPEndPoint)_controlClient.Client.LocalEndPoint).Address;
_passiveListener = new TcpListener(localAddress, 0);
_passiveListener.Start();
现在我们正在侦听,我们需要将我们正在侦听的 IP 地址和端口发送回客户端。
IPEndPoint localEndpoint = ((IPEndPoint)_passiveListener.LocalEndpoint);
byte[] address = localEndpoint.Address.GetAddressBytes();
short port = (short)localEndpoint.Port;
byte[] portArray = BitConverter.GetBytes(port);
if (BitConverter.IsLittleEndian)
Array.Reverse(portArray);
return string.Format("227 Entering Passive Mode ({0},{1},{2},{3},{4},{5})",
address[0], address[1], address[2], address[3], portArray[0], portArray[1]);
如您所见,我们再次使用了 BitConverter
类,并确保我们正在检查正确的字节序。
将数据控制命令添加到命令循环
现在我们已经创建了 Passive
和 Port
方法,我们需要将它们添加到我们的命令循环中。
case "PORT":
response = Port(arguments);
break;
case "PASV":
response = Passive();
break;
现在,当我们再次连接 FileZilla 时,我们得到以下结果
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Command: SYST
Response: 502 Command not implemented
Command: FEAT
Response: 502 Command not implemented
Status: Connected
Status: Retrieving directory listing...
Command: PWD
Response: 257 "/" is current directory.
Command: TYPE I
Response: 200 OK
Command: PASV
Response: 227 Entering Passive Mode (127,0,0,1,197,64)
Command: LIST
Response: 502 Command not implemented
Error: Failed to retrieve directory listing
看来我们接下来需要实现 LIST 命令……
LIST 命令
LIST 命令返回指定目录的文件系统条目列表。如果未指定目录,我们假定为当前工作目录。这是我们实现的第一个将使用上面创建的数据连接的命令,所以让我们一步一步地进行。
private string List(string pathname)
{
if (pathname == null)
{
pathname = string.Empty;
}
pathname = new DirectoryInfo(Path.Combine(_currentDirectory, pathname)).FullName;
if (IsPathValid(pathname))
{
如您所见,我们正在创建的 List
方法接受一个路径参数。我们进行一些快速验证以确保路径有效,然后……
if (_dataConnectionType == DataConnectionType.Active)
{
_dataClient = new TcpClient();
_dataClient.BeginConnect(_dataEndpoint.Address, _dataEndpoint.Port, DoList, pathname);
}
else
{
_passiveListener.BeginAcceptTcpClient(DoList, pathname);
}
return string.Format("150 Opening {0} mode data transfer for LIST", _dataConnectionType);
}
return "450 Requested file action not taken";
我们检查我们正在使用哪种类型的数据连接。如果我们正在使用活动连接,我们需要启动到客户端的连接。如果使用被动连接,我们需要准备好接受连接。这两者都是异步完成的,这样主线程可以将 150 响应返回给客户端。连接后,两种方式都调用 DoList
方法。我们将当前路径作为状态对象传递,可以在 DoList
方法中检索该对象。
DoList
方法实际上将在这里完成我们的繁重工作。我们首先结束连接尝试。我们必须根据连接方式(主动或被动)以不同的方式进行操作。我们还从状态对象中拉出路径。
private void DoList(IAsyncResult result)
{
if (_dataConnectionType == DataConnectionType.Active)
{
_dataClient.EndConnect(result);
}
else
{
_dataClient = _passiveListener.EndAcceptTcpClient(result);
}
string pathname = (string)result.AsyncState;
我们现在有一个 TcpClient 的引用,我们可以用它来发送/接收数据。LIST 命令指定使用 ASCII 或 EBCDIC。在我们的例子中,我们使用 ASCII,所以我们创建一个 StreamReader
和 StreamWriter
来简化通信。
using (NetworkStream dataStream = _dataClient.GetStream())
{
_dataReader = new StreamReader(dataStream, Encoding.ASCII);
_dataWriter = new StreamWriter(dataStream, Encoding.ASCII);
既然我们有办法将数据写回客户端,我们需要输出列表。让我们从目录开始。规范没有规定数据的格式,但最好使用 UNIX 系统对 ls -l 的输出。这是一个格式示例。
IEnumerable<string> directories = Directory.EnumerateDirectories(pathname);
foreach (string dir in directories)
{
DirectoryInfo d = new DirectoryInfo(dir);
string date = d.LastWriteTime < DateTime.Now - TimeSpan.FromDays(180) ?
d.LastWriteTime.ToString("MMM dd yyyy") :
d.LastWriteTime.ToString("MMM dd HH:mm");
string line = string.Format("drwxr-xr-x 2 2003 2003 {0,8} {1} {2}", "4096", date, d.Name);
_dataWriter.WriteLine(line);
_dataWriter.Flush();
}
正如我之前所说,这些行的格式没有固定标准。我发现这种格式与 FileZilla 配合得很好。您可能会在不同的客户端上得到不同的结果。上面,我们只是遍历当前目录中的每个目录,并向客户端写入一行描述每个目录。我们对文件也做类似的事情……
IEnumerable<string> files = Directory.EnumerateFiles(pathname);
foreach (string file in files)
{
FileInfo f = new FileInfo(file);
string date = f.LastWriteTime < DateTime.Now - TimeSpan.FromDays(180) ?
f.LastWriteTime.ToString("MMM dd yyyy") :
f.LastWriteTime.ToString("MMM dd HH:mm");
string line = string.Format("-rw-r--r-- 2 2003 2003 {0,8} {1} {2}", f.Length, date, f.Name);
_dataWriter.WriteLine(line);
_dataWriter.Flush();
}
列出完成后,我们关闭数据连接并在控制连接上向客户端发送一条消息,告知客户端传输已完成
_dataClient.Close();
_dataClient = null;
_controlWriter.WriteLine("226 Transfer complete");
_controlWriter.Flush();
现在我们将 LIST 命令添加到命令循环中
case "LIST":
response = List(arguments);
break;
并重新运行 FileZilla...
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Status: Connected
Status: Retrieving directory listing...
Command: PWD
Response: 257 "/" is current directory.
Command: TYPE I
Response: 200 OK
Command: PASV
Response: 227 Entering Passive Mode (127,0,0,1,198,12)
Command: LIST
Response: 150 Opening Passive mode data transfer for LIST
Response: 226 Transfer complete
Status: Directory listing successful
您应该会在您指定的目录中看到文件/文件夹列表。太好了,有些东西起作用了!现在我们需要实现更多功能。让我们尝试在 FileZilla 中下载一个文件。您应该会得到类似这样的结果
Status: Connecting to 127.0.0.1:21...
Status: Connection established, waiting for welcome message...
Response: 220 Service Ready.
Command: USER rick
Response: 331 Username ok, need password
Command: PASS ****
Response: 230 User logged in
Status: Connected
Status: Starting download of /somefile.txt
Command: CWD /
Response: 200 Changed to new directory
Command: TYPE A
Response: 200 OK
Command: PASV
Response: 227 Entering Passive Mode (127,0,0,1,198,67)
Command: RETR somefile.txt
Response: 502 Command not implemented
Error: Critical file transfer error
看来我们现在需要 RETR 命令了……
RETR 命令
RETR(检索)命令是用于将文件从服务器下载到客户端的命令。它的工作方式与 LIST 命令类似,只是我们需要根据客户端是希望将其作为 ASCII 还是图像(二进制)传输来以不同的方式发送数据。
private string Retrieve(string pathname)
{
pathname = NormalizeFilename(pathname);
if (IsPathValid(pathname))
{
if (File.Exists(pathname))
{
if (_dataConnectionType == DataConnectionType.Active)
{
_dataClient = new TcpClient();
_dataClient.BeginConnect(_dataEndpoint.Address, _dataEndpoint.Port, DoRetrieve, pathname);
}
else
{
_passiveListener.BeginAcceptTcpClient(DoRetrieve, pathname);
}
return string.Format("150 Opening {0} mode data transfer for RETR", _dataConnectionType);
}
}
return "550 File Not Found";
}
如您所见,我们对收到的路径进行了一些快速验证,然后根据我们处于主动模式还是被动模式异步连接。连接后,我们将传递给 DoRetrieve
方法。
我们的 DoRetrieve
方法应该与上面的 DoList
方法非常相似。
private void DoRetrieve(IAsyncResult result)
{
if (_dataConnectionType == DataConnectionType.Active)
{
_dataClient.EndConnect(result);
}
else
{
_dataClient = _passiveListener.EndAcceptTcpClient(result);
}
string pathname = (string)result.AsyncState;
using (NetworkStream dataStream = _dataClient.GetStream())
{
如您所见,该方法的前半部分是相同的。一旦我们获得 NetworkStream
,我们只需要按照请求的方式传输文件。首先我们打开文件进行读取……
using (FileStream fs = new FileStream(pathname, FileMode.Open, FileAccess.Read))
{
然后将文件流复制到数据流中……
CopyStream(fs, dataStream);
然后关闭我们的数据连接……
_dataClient.Close();
_dataClient = null;
最后在控制连接上向客户端发送通知……
_controlWriter.WriteLine("226 Closing data connection, file transfer successful");
_controlWriter.Flush();
}
CopyStream
方法详见下方
private static long CopyStream(Stream input, Stream output, int bufferSize)
{
byte[] buffer = new byte[bufferSize];
int count = 0;
long total = 0;
while ((count = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, count);
total += count;
}
return total;
}
private static long CopyStreamAscii(Stream input, Stream output, int bufferSize)
{
char[] buffer = new char[bufferSize];
int count = 0;
long total = 0;
using (StreamReader rdr = new StreamReader(input))
{
using (StreamWriter wtr = new StreamWriter(output, Encoding.ASCII))
{
while ((count = rdr.Read(buffer, 0, buffer.Length)) > 0)
{
wtr.Write(buffer, 0, count);
total += count;
}
}
}
return total;
}
private long CopyStream(Stream input, Stream output)
{
if (_transferType == "I")
{
return CopyStream(input, output, 4096);
}
else
{
return CopyStreamAscii(input, output, 4096);
}
}
如您所见,我们根据传输方式以不同的方式复制文件。目前我们只支持 ASCII 和 Image。
接下来,我们将 RETR 命令添加到命令循环中。
case "RETR":
response = Retrieve(arguments);
break;
此时,您应该能够从服务器下载文件了!
接下来呢?
从这里开始,您应该继续阅读规范,实现命令。我已在随附的解决方案中实现了更多命令。有许多地方可以变得更加通用,包括我们的数据连接方法。为了使其成为一个可用的 FTP 服务器,我们还需要添加一些真实的用户帐户管理、确保用户留在自己的目录中的安全性等。我还实现了 FTPS(通过 SSL 的 FTP),用于从客户端到服务器使用加密链接。这很有用,因为默认情况下,所有命令都以纯文本形式发送(包括密码)。FTPS 在与原始规范 RFC 2228 不同的 RFC 中定义。接下来是关于其实现的简要讨论。
FTPS - 通过 SSL 的 FTP
AUTH 命令
AUTH 命令向服务器发出信号,表明客户端希望通过安全通道进行通信。我只实现了 TLS 身份验证模式。TLS,或传输层安全,是 SSL 的最新实现。我们的 Auth
方法如下,看似简单。
private string Auth(string authMode)
{
if (authMode == "TLS")
{
return "234 Enabling TLS Connection";
}
else
{
return "504 Unrecognized AUTH mode";
}
}
这是因为我们首先需要向客户端返回我们是否可以接受他们使用加密的请求。然后,在命令循环中的 switch
语句下方,我们再次检查是否正在使用 AUTH 命令,然后加密流。
if (_controlClient != null && _controlClient.Connected)
{
_controlWriter.WriteLine(response);
_controlWriter.Flush();
if (response.StartsWith("221"))
{
break;
}
if (cmd == "AUTH")
{
_cert = new X509Certificate("server.cer");
我们做的第一件事是从文件中加载 X509 证书。此证书是使用 Windows SDK 中可用的 makecert.exe 命令创建的。您应该可以通过运行 Visual Studio 命令提示符来使用它。MSDN 文档 可以指导您如何创建证书。这是我用于创建测试证书的命令
makecert -r -pe -n "CN=localhost" -ss my -sr localmachine -sky
exchange -sp "Microsoft RSA SChannel Cryptographic Provider" -sy 12 server.cer
接下来,我们创建加密流并向客户端进行身份验证。
_sslStream = new SslStream(_controlStream);
_sslStream.AuthenticateAsServer(_cert);
最后,我们将流读取器和写入器设置为使用加密流。当我们读取时,底层 SslStream
将为我们解密,当我们写入时,它将为我们加密。
_controlReader = new StreamReader(_sslStream);
_controlWriter = new StreamWriter(_sslStream);
此实现不保护数据连接,仅保护命令连接。因此,传输的文件仍然容易被拦截。RFC 2228 确实提供了保护数据流的方法,但此处未实现。
IPv6
添加 IPv6 支持实际上非常简单。如果您不熟悉 IPv6,应该开始阅读。就 FTP 而言,IPv6 支持是在 RFC 2428 中添加的。此规范定义了两个新命令:EPSV 和 EPRT。这些命令与我们上面介绍的 PASV 和 PORT 命令相对应。
我们的第一个改变必须是允许服务器监听 IPv6 地址。为此,我们修改 FtpServer 类以使用新的构造函数
public FtpServer(IPAddress ipAddress, int port)
{
_localEndPoint = new IPEndPoint(ipAddress, port);
}
我们还修改了 Start 方法
_listener = new TcpListener(_localEndPoint);
现在,当我们启动服务器时,我们可以使用
FtpServer server = new FtpServer(IPAddress.IPv6Any, 21);
现在开始添加新命令。
EPRT 命令
EPRT 命令是扩展 PORT 命令。此命令接受指定要使用的互联网协议类型(1 表示 IPv4,2 表示 IPv6)、要连接的 IP 地址和端口的参数。此命令也比原始 PORT 命令更不复杂,因为 IP 地址是以字符串表示而不是字节表示给出的。这样,我们就可以直接使用 IPAddress.Parse 方法来获取我们的地址。
private string EPort(string hostPort)
{
_dataConnectionType = DataConnectionType.Active;
char delimiter = hostPort[0];
string[] rawSplit = hostPort.Split(new char[] { delimiter }, StringSplitOptions.RemoveEmptyEntries);
char ipType = rawSplit[0][0];
string ipAddress = rawSplit[1];
string port = rawSplit[2];
_dataEndpoint = new IPEndPoint(IPAddress.Parse(ipAddress), int.Parse(port));
return "200 Data Connection Established";
}
命令中的第一个字符是分隔符。这通常是管道“|”字符,但它可能不同,所以我们只需查看客户端发送给我们的内容。然后我们根据提供的分隔符拆分参数。第一个参数是要使用的网络协议,1 表示 IPv4,2 表示 IPv6。下一个参数是要连接的 IP 地址,最后一个参数是要连接的端口。事实证明,我们实际上不需要第一个参数,因为 IPAddress.Parse 方法会自动检测要使用的 IP 地址类型。
现在我们可以将命令添加到命令循环中
case "EPRT":
response = EPort(arguments);
break;
EPSV 命令
再一次,我们的新命令比原始命令更简单。由于 EPSV 命令以字符串格式而不是字节格式返回端点信息,我们可以简化我们的方法
private string EPassive()
{
_dataConnectionType = DataConnectionType.Passive;
IPAddress localIp = ((IPEndPoint)_controlClient.Client.LocalEndPoint).Address;
_passiveListener = new TcpListener(localIp, 0);
_passiveListener.Start();
IPEndPoint passiveListenerEndpoint = (IPEndPoint)_passiveListener.LocalEndpoint;
return string.Format("229 Entering Extended Passive Mode (|||{0}|)", passiveListenerEndpoint.Port);
}
然后我们将命令添加到命令循环中
case "EPSV": response = EPassive(); break;
数据连接错误
就目前而言,如果我们使用 EPRT 命令进行连接,最终会收到一个神秘的错误:“此协议版本不受支持”。事实证明,我们在创建 _dataClient 时必须指定 AddressFamily(IPv4 或 IPv6)。所以我们改变了我们创建 _dataClient 的实例
_dataClient = new TcpClient();
改为:
_dataClient = new TcpClient(_dataEndpoint.AddressFamily);
最终想法...
要使其成为一个功能齐全的 FTP 服务器,还有很多工作要做。我希望本文能为学习如何使用异步方法、网络流、加密和处理简单的命令循环提供一个良好的开端。我创建了一个托管在 GitHub 上的项目,这样您就可以看到它的后续更改。我目前没有任何计划将其开发成一个功能齐全的 FTP 服务器,但如果有人想要开发权限以继续该项目,请告诉我。
历史
首次发布。
2012 年 6 月 6 日 - 为世界 IPv6 日添加了 IPv6 支持
2013 年 10 月 7 日 - 托管从 Google Code 迁移到 GitHub