[C# 网络编程系列]专题十二:实现一个简单的FTP服务器

引言:

休息一个国庆节后好久没有更新文章了,主要是刚开始休息完心态还没有调整过来的, 现在差不多进入状态了, 所以继续和大家分享下网络编程的知识,在本专题中将和大家分享如何自己实现一个简单的FTP服务器。在我们平时的上网过程中,一般都是使用FTP的客户端来对商家提供的服务器进行访问(上传、下载文件),例如我们经常用到微软的SkyDrive网盘,115网盘等,然而我们经常用到的都是网页版本的,网页版本和客户端版本的不同,网页版本的FTP客户端,它与服务器的交流是使用HTTP协议发出对服务器的请求的,而客户端版本采用的是FTP协议发出命令对服务器进行请求。然后我们接触到FTP服务器却很少的, 所以本专题中将和大家介绍下如何实现一个FTP服务器(不要觉得服务器很深奥一样的,大家可以简单的认为服务器也是一个程序,该程序是对客户端发来的请求做处理的,请求大家可以简单理解为字符串,从这个角度看, 服务器程序就是一个对字符串解析的过程。),也是为后面的一个专题做一个铺垫,因为后面专题将和大家介绍下FTP客户端——文件上传下载器,有了自己自定义的FTP服务器后, 自定义的FTP客户端就可以对自定义的FTP服务器进行访问,使两者形成一个完整的软件,从而也让大家对基于FTP协议的工具有一个初步的了解。

 

一、基于FTP协议的客户端和服务器是如何"沟通的"?

FTP客户端和FTP服务器之间的“沟通”分为四个阶段的:

1. 启动FTP

客户通过FTP客户端软件,发起FTP交互式的命令,就是告诉服务器(也就是一台电脑,服务器上与一个程序(FTP服务)会接收命令,并解析发来的命令,然后发出回复信息)说:“我想和你聊聊天,可以吗?”

2. 建立控制连接

客户端TCP层根据客户给出的服务器IP地址,向服务器提供FTP服务的21号端口发出主动建立连接的请求,服务器接收到请求后,通过3次握手之后,客户端和服务器之间就建立一个TCP连接(就是一条通道,就好比生活中马路,有了马路之后车才能够在两地之间运送东西),之后,所有用户发出的FTP命令和服务器的回应都是通过该连接来传送的, 所以也把这个TCP连接叫做控制连接,控制连接在用户退出之前一直存在。

3. 建立数据连接和进行文件传输

现在客户端和服务器端已经建立聊天的通道了(控制连接),但是两者聊天过程中如果互相想赠送礼物要怎么办呢?(这里形象的把客户端和服务器端文件的传输比喻两个人通过聊天后互相赠送礼物的过程),此时我们就需要另外一条马路(数据连接)来进行“礼物的赠送”了,具体赠送礼物的过程如下:

  1.  客户端通过控制连接向服务器发送一个上传文件的命令时,会自己分配一个临时的TCP端口号。
  2.  客户端通过控制连接向服务器发送一个命令(下面将会介绍的PORT命令)来告诉服务器自己的IP地址和临时的端口号,然后再发送一条上传文件的命令(可以理解为——客户端要送礼物给服务器时,实际上不是简单的发送一个送礼物命令的,在这之前还需要发送一条自我介绍命令(就是告诉服务器自己的IP地址和端口号)来告诉服务器自己就是刚刚和它聊天的那位,这也很符合我们日常送礼物的流程的,一般大家接到礼物都要弄明白送礼物的人是谁,是不是自己认识的)
  3. 服务器接收到客户端的IP地址和临时端口号后,以这个IP地址和端口号为目标,使用服务器上的20端口(该端口是用来传输数据的端口)向客户端发出主动建立连接的请求。
  4.  客户端收到请求后,通过3此握手后就与服务器之间建立了另外一条TCP连接——数据连接,即用来互相送礼物的通道。
  5.  客户端在自己的文件系统中选择要赠送(上传)的文件
  6.  客户端将文件写入到文件传输进程中(写入网络流中)
  7.  服务器端将传输来的文件在服务器端的文件系统中进行存储
  8.  文件传输完成后,由服务器主动关闭该数据的连接

4 关闭FTP

当用户退出FTP时,通关客户端发送退出命令,之后控制连接被关闭,FTP服务结束。

二、从上面的沟通过程中你明白了什么?

从上面客户端与服务器端的沟通过程中,这里可以概括几点:

(1)客户端与服务器端进行交互过程中,传输层使用的是TCP协议而不是其他传输层协议

(2)沟通过程有两条TCP连接——一条是控制连接,即传输命令和响应信息的通道,另一条是数据连接,即传输文件的马路,并且必须先有控制连接才能建立数据连接,因为要进行文件传输首先必须知道客户的IP地址和端口号,这个过程就是通过控制连接传送的命令来告知服务器客户端的IP地址和端口号,之后再在两者之间建立数据连接来传输文件

(3)在服务器端,控制连接(端口号为21)和数据连接(端口号 为20)使用了不同的端口号

三、赠送礼物的方式?——文件传输模式

客户端与FTP服务器建立数据连接之后,首先需要告诉服务器采用哪种文件传输模式,FTP提供了两种文件传输模式,一种是主动(Port)模式,另一种是被动(Passive)模式。

主动模式——服务器向客户端发起数据连接请求,被动模式——客户端向服务器发起数据请求。

然而两种模式有什么相同点和不同点呢?

两种模式的相同点: 服务器都使用21号端口进行用户验证和管理

不同点: 传送文件数据的方式不一样,主动模式的FTP服务器数据端口固定在20,而被动模式的FTP服务器数据端口则在1025~65535之间的随机数。

3.1 主动模式

主动模式——服务器主动连接客户端,然后传输文件,在这种模式下,FTP客户端先用一个端口N(N>1024)向服务器的21号端口发起控制连接,连接成功后,在发出PORT N+1命令告诉服务器自己监听的端口为N+1;服务器接受到该命令后,用一个新的数据端口(20号端口)与客户端的端口N+1建立连接,然后进行文件传输,而客户端则通过监听N+1端口接受文件数据。

注意: 采用主动模式存在一个问题,如果客户端安装了防火墙或在内网时,由于防火墙一般不允许接受外部发起的标准端口以外的连接请求,因此外部FTP服务器就无法使用主动模式穿过防火墙主动连接客户端(这里与客户端连接的端口为N+1(N>1024),非标准端口),从而造成无法传送文件数据,此时就需要采用被动模式传送文件了。

3.2 被动模式

被动模式——服务器被动接受客户端连接请求,即控制连接请求和数据连接请求都是由客户端发起,在这种模式下,FTP客户端先随机开始一个端口N向服务器的21号端口发起控制连接,然后向服务器发送PASV命令。服务器收到该命令后,会用一个新的端口P(P>1024)进行监听,同时将该端口号告诉客户端,客户端接受到响应命令后,再通过新的端口N+1连接服务器的端口P,然后进行文件数据传输。

注意:采用被动模式与主动模式也存在相同的问题,如果服务器安装了防火墙,客户端同样可能无法与服务器端的端口P建立数据请求,因为该请求可能会被防火墙过滤掉。在实际应用中,服务器一般指定一个端口范围,允许客户端与该范围内的端口建立数据连接,而不再这个范围内的端口会被服务器的防火墙过滤掉,从而在一定程度上消除了针对服务器的恶意攻击。

四、 FTP协议中有哪些命令的?

协议简单说就是一个规范,就好比打牌一样,制定一个大家都能明白的规则,斗地主的规则被大家都认可的,但是私下我们也可以自定义规则来玩的(例如说三个只能带一个等这样的规则),同样FTP规则也是大家都认可的一个协议,我们当然也可以自定义协议。

由于.Net平台下目前还没有提供对FTP服务器端开发的类库,因此要实现一个FTP服务器端的应用程序,就必须了解FTP协议的详细内容。

4.1 FTP命令有哪些?

FTP 协议中规定了一些大家都认识的命令和组成。FTP协议中的命令都由3~4个字母组成,命令与参数之间用空格隔开,每个命令用回车换行结束。

(1)访问命令

(1)访问命令有:

USER命令——格式为:USER <username>, 指定登录的用户名,以便服务器进行身份验证。这个命令通常是控制连接后第一个发出的命令

PASS命令——格式为:PASS <password>, 指定用户密码,该命令必须跟在登录用户名命令之后。

REIN命令——格式为:REIN, 表示重新初始化用户信息,该命令终止当前USER的传输,同时终止正在传输的数据,然后重置所有参数,并打开控制连接,以便客户端再次发生USER命令。

QUIT命令——格式为:QUIT,关闭与服务器的连接

(2)模式设置命令:

PASV命令——格式为:PASV,该命令告诉FTP服务器,让FTP服务器在指定的数据端口进行监听,被动接受客户端的请求。如果未指定任何模式,FTP服务器默认使用PASV模式

PORT命令——格式为:PORT <address>,该命令告诉FTP服务器,客户端监听的端口号是address,让FTP服务器采用主动模式连接客户端。

TYPE命令——格式为: TYPE <data type>,该命令指定要传输的数据类型,有ASCII和BINARY两种类型。

MODE命令——格式为:MODE <mode>,该命令指定传输模式,S表示流,B表示块,C表示压缩。

(3)文件管理命令

CWD命令——格式为:CWD <directory>,该命令是用户可以在不同的目录或数据集下工作而不用改变登录信息,directory一般是目录名或与系统相关的文件集合。

PWD命令——格式为:PWD,该命令返回当前工作目录。

MKD命令——格式为:MKD <directory>,该命令表示在指定路径下创建新目录,directory 表示特定目录的字符串。

CDUP命令——格式为:CDUP,该命令表示回到上层目录

RMD命令——格式为:RMD <directory>,删除指定目录,directory表示特定目录的字符串。

LIST命令——格式为:LIST <name>,该命令返回指定路径下的子目录及文件列表,name 为路径。省略路径时,返回当前路径下的文件列表。

NLIST命令——格式为:NLIST <directory>,该命令返回指定路径下的目录列表,省略路径时,返回当前目录。

RNFR命令——格式为:RNFR <old path>,该命令表示重新命名文件,该命令的下一条命令用RNTO指定新的文件名。

RNTO命令——格式为:RNTO <new path>,该命令和RNFR命令共同完成对文件的重命名。

DELE命令——格式为:DELE <filename>,该命令表示删除指定路径下的文件

(4)文件传输命令:

RETR命令——RETR <filename>,表示下载指定路径的文件

STOR命令——STOR <filename>,表示上传一个指定的文件,并将其存储在指定的位置,如果文件已存在,原文件将被覆盖,如果文件不存在,则创建新文件。

(5)其他命令

SYST命令——格式为:SYST,该命令返回服务器使用的操作系统。

4.2 FTP响应码

客户端发送FTP命令后,服务器需要返回FTP响应码,响应码即是回答,我们平常聊天中别人问了说了话或者问了问题,另外一方就需要回答,FTP协议中定义以响应码的形式来作为回答,FTP响应码由ASCII编码的3位数字开头,后面接一行文本提示信息,数字和提示信息中有一个空格,如XXX 接收请求。

每个响应码同样以回车换行结束。

FTP响应码的3位数字每位都有特定的意义,具体见下表:

响应码

表示

1

1XX

表示信息已被服务器正确接收,但尚未被处理

2XX

表示信息已被服务器正确处理完毕

3XX

彪西信息已被服务器正在接受,并正在处理中

4XX

表示信息处理错误(暂时)

5XX

表示信息处理错误(永久)

2

X0X

表示语法错误

X1X

表示系统状态与信息

X2X

表示与FTP服务器系统连接状态

X3X

表示与用户认证有关的信息

X4X

表示未定义

X5X

表示与文件系统有关的信息

 下表列出了常用的响应码所代表的意义:

响应码

意义

响应码

意义

110

重新启动标记应答

332

登陆是需要账户信息

120

服务在指定时间内准备好

350

请求的文件操作需要进一步命令

125

数据连接打开——开始传输

421

服务关闭

150

文件状态良好,将要打开数据连接

425

不能打开数据连接

200

命令成功

426

关闭连接,终止传输

202

命令没有执行

450

文件不可用

211

系统状态回复

451

中止请求操作:有本地错误

212

目录状态回复

452

磁盘空间不足

213

文件状态回复

500

无效命令

214

帮助信息回复

501

语法错误

215

系统类型回复

502

命令未执行

220

服务就绪

503

命令顺序错误

221

服务关闭控制连接,可以退出登陆

504

无效命令参数

225

数据连接打开,无传输正在进行

530

未登陆

226

关闭数据连接,请求的文件操作成功

532

存储文件需要账户信息

227

进入被动模式

550

未执行请求操作

230

用户已登陆

551

请求操作终止:页类型未知

250

请求的文件操作完成

552

请求文件操作终止:超过存储分配

257

创建路径名

553

为执行请求的操作:文件名不合法

331

用户名正确,需要口令

 

 

五、实现自定义的FTP服务器

相信大家看完上面的介绍对FTP协议以及FTP客户端和FTP服务器的交互过程有一定的理解的,这时候大家知道理论后就一定很想知道知道这些之后可以做什么的?答案就是可以制作一个简单的FTP服务器,大家可以根据代码来进一步理解FTP协议。下面是程序中一些核心代码片段:

 

// 启动服务器
        private void btnFtpServerStartStop_Click(object sender, EventArgs e)
        {
            if (myTcpListener == null)
            {
                listenThread = new Thread(ListenClientConnect);
                listenThread.IsBackground = true;
                listenThread.Start();

                lstboxStatus.Enabled = true;
                lstboxStatus.Items.Clear();
                lstboxStatus.Items.Add("启动Ftp服务...");
                btnFtpServerStartStop.Text = "停止";
            }
            else
            {
                myTcpListener.Stop();
                myTcpListener = null;
                listenThread.Abort();
                lstboxStatus.Items.Add("Ftp服务已停止!");
                lstboxStatus.TopIndex = lstboxStatus.Items.Count - 1;

                btnFtpServerStartStop.Text = "启动";
            }
        }

        // 监听端口,处理客户端连接
        private void ListenClientConnect()
        {
            myTcpListener = new TcpListener(IPAddress.Parse(tbxFtpServerIp.Text), int.Parse(tbxFtpServerPort.Text));
            // 开始监听传入的请求
            myTcpListener.Start();
            AddInfo("启动成功!");
            AddInfo("Ftp服务运行中...[单机”停止“退出]");
            while (true)
            {
                try
                {
                    // 接收连接请求
                    TcpClient tcpClient = myTcpListener.AcceptTcpClient();
                    AddInfo(string.Format("客户端({0})与本机({1})建立Ftp连接", tcpClient.Client.RemoteEndPoint, myTcpListener.LocalEndpoint));
                    User user = new User();
                    user.commandSession = new UserSeesion(tcpClient);
                    user.workDir = tbxFtpRoot.Text;
                    Thread t = new Thread(UserProcessing);
                    t.IsBackground = true;
                    t.Start(user);
                }
                catch
                {
                    break;
                }
            }
        }

        // 处理客户端用户请求
        private void UserProcessing(object obj)
        {
            User user = (User)obj;
            string sendString = "220 FTP Server v1.0";
            RepleyCommandToUser(user, sendString);
            while (true)
            {
                string receiveString = null;
                try
                {
                    // 读取客户端发来的请求信息
                    receiveString = user.commandSession.streamReader.ReadLine();
                }
                catch(Exception ex)
                {
                    if (user.commandSession.tcpClient.Connected == false)
                    {
                        AddInfo(string.Format("客户端({0}断开连接!)", user.commandSession.tcpClient.Client.RemoteEndPoint));
                    }
                    else
                    {
                        AddInfo("接收命令失败!" + ex.Message);
                    }

                    break;
                }

                if (receiveString == null)
                {
                    AddInfo("接收字符串为null,结束线程!");
                    break;
                }

                AddInfo(string.Format("来自{0}:[{1}]", user.commandSession.tcpClient.Client.RemoteEndPoint, receiveString));
                
                // 分解客户端发来的控制信息中的命令和参数
                string command = receiveString;
                string param = string.Empty;
                int index = receiveString.IndexOf(' ');
                if (index != -1)
                {
                    command = receiveString.Substring(0, index).ToUpper();
                    param = receiveString.Substring(command.Length).Trim();
                }

                // 处理不需登录即可响应的命令(这里只处理QUIT)
                if (command == "QUIT")
                {
                    // 关闭TCP连接并释放与其关联的所有资源
                    user.commandSession.Close();
                    return;
                }
                else
                {
                    switch (user.loginOK)
                    {
                        // 等待用户输入用户名:
                        case 0:
                            CommandUser(user, command, param);
                            break;

                        // 等待用户输入密码
                        case 1:
                            CommandPassword(user, command, param);
                            break;

                        // 用户名和密码验证正确后登陆
                        case 2:
                            switch (command)
                            {
                                case "CWD":
                                    CommandCWD(user, param);
                                    break;
                                case "PWD":
                                    CommandPWD(user);
                                    break;
                                case "PASV":
                                    CommandPASV(user);
                                    break;
                                case "PORT":
                                    CommandPORT(user, param);
                                    break;
                                case "LIST":
                                    CommandLIST(user, param);
                                    break;
                                case "NLIST":
                                    CommandLIST(user, param);
                                    break;
                                // 处理下载文件命令
                                case "RETR":
                                    CommandRETR(user, param);
                                    break;
                                // 处理上传文件命令
                                case "STOR":
                                    CommandSTOR(user, param);
                                    break;
                                // 处理删除命令
                                case "DELE":
                                    CommandDELE(user, param);
                                    break;
                                // 使用Type命令在ASCII和二进制模式进行变换
                                case "TYPE":
                                    CommandTYPE(user, param);
                                    break;
                                default:
                                    sendString = "502 command is not implemented.";
                                    RepleyCommandToUser(user, sendString);
                                    break;
                            }

                            break;
                    }          
                }
            }       
        }

 

程序演示截图:

 首先在F:\盘下新建文件夹MyFtpServerRoot,在其中创建目录结构并放一些文件资源,例如图片,文档等,程序中演示的目录结构如下图:

 这样,本地的FTP服务站点就已经建好了,运行FTP服务器程序,然后点击“启动”按钮后就启动了FTP服务器,运行结果如下图所示:

然后配合上个专题中实现的FTP客户端来完成与FTP服务器的“聊天”演示,因为FTP服务器程序中已经初始化用户名和密码(都为admin),所以FTP客户端中取消选择“匿名复选框”,直接输入用户名和密码为admin后点击“登录”按钮后就完成了用户验证的过程,并与FTP服务器建立了控制连接和数据连接。运行结果如下图:

当然用户可以通过"上传"、“下载”和删除按钮来对FTP服务器上的文件进行操作,这里就不贴出运行图片了, 大家可以下载源码来测试下的。

 

六、内容的结尾,说说后面的计划吧

这个专题介绍完后,我这个C#网络编程系列也就介绍完了,这个系列中主要介绍网络编程的一些入门知识,对于朋友在留言中经常提到的“打洞”技术以及一些网络编程中一些更难的内容还大家一起努力来学习的,同时我也会在后面和大家分享下一些实际开发过程中的网络编程的内容(在后面的文章打算和大家分享一个下载器的实现),最后,希望这个系列可以让大家对网络协议有一个最初的入门,这样在实际的开发过程中才知道这些实现背后的原理。之后我总结下我这个系列的所有文章的索引,以便让大家更好的阅读和查找关于这个系列的所有文章。

 

源码下载:https://files.cnblogs.com/zhili/FtpServer.zip,大家如果觉得不错的话,还请大家推荐下,谢谢大家的支持

 用来演示的服务器目录:https://files.cnblogs.com/zhili/MyFtpServerRoot.zip

 上个专题FTP文件上传下载器源码:https://files.cnblogs.com/zhili/FTPUpDownloader.zip


posted @ 2013-06-12 17:30  爱生活,爱编程  阅读(1660)  评论(1编辑  收藏  举报