这是一个mini ftp服务器,实现了服务器的主动模式与被动模式、权限控制、限制传输速度、限制客户端连接数、断开空闲连接、断点续传等功能。
支持的客户端命令如下:
命令 | 功能 |
---|---|
USER | 输入用户名 |
PASS | 输入密码,密码正确则用户成功登录 |
PORT | 令服务器以主动模式建立数据连接:客户端向服务器发送用户的IP地址和端口,以供服务器主动连接到客户端 |
PASV | 令服务器以被动模式建立数据连接:服务器监听随机的一个端口,将该端口发送给客户端,供服务器被动接收客户端的连接 |
RETR | 下载文件,需要建立数据连接 |
STOR | 上传文件(覆盖),需要建立数据连接 |
APPE | 上传文件(追加),需要建立数据连接 |
LIST | 获取当前目录下的详细文件信息,需要建立数据连接 |
NLST | 获取当前目录下的简要文件信息,需要建立数据连接 |
REST | 设置断点续传偏移量。此命令后应该为文件传输命令。 |
PWD | 获取当前工作目录 |
CWD | 改变工作目录 |
CDUP | 回到上一层目录 |
MKD | 新建目录 |
RMD | 删除目录 |
DELE | 删除文件 |
SIZE | 获取文件大小 |
RNFR | 选择要重命名的文件 |
RNTO | 重命名文件 |
SITE | 修改文件权限 |
CHMOD | 修改文件权限 |
TYPE | 设置文件传输类型 |
SYST | 获取服务器操作系统信息 |
FEAT | 获取服务器支持的特性 |
STAT | 获取服务器状态 |
NOOP | 不做任何事,仅防止服务器踢掉空闲连接 |
HELP | 获取帮助 |
ABOR | 异常终止先前的FTP命令和数据传输。如果先前的操作已完成,返回226,否则返回426,再返回226 |
QUIT | 退出 |
FTP支持的常见的文件类型有文本文件和二进制文件,它们的区别主要在于,以二进制格式传输文件,不会转换换行符,而文本格式会转换换行符(linux下为\n, windows下为\r\n)。我们的ftp服务器只支持二进制格式,不作换行转换。
可选的配置项如下:
string listen_address {"127.0.0.1"}; // 监听地址
unsigned int listen_port {21}; // 控制连接的监听端口
unsigned int accept_timeout {600}; // accept的超时时间
unsigned int connect_timeout {300}; // connect的超时时间
unsigned int maximum_clients {2000}; // 最大客户端连接数限制
unsigned int max_conns_per_ip {50}; // 单个ip的最大连接数限制
unsigned int max_upload_rate {0}; // 最大上传速度限制(单位为字节)
unsigned int max_download_rate {0}; // 最大下载速度限制
unsigned int idle_ctrl_timeout {300}; // 空闲控制连接的自动断开时间(单位为秒)
unsigned int idle_data_timeout {300}; // 空闲数据连接的自动断开时间
unsigned int local_umask {077}; // 创建文件时的umask
默认的配置文件路径为
gedit /etc/mini_ftpd.conf
配置文件格式形如
idle_ctrl_timeout = 300
idle_data_timeout = 20
max_upload_rate = 8192000
max_download_rate = 4096000
max_conns_per_ip = 2
maximum_clients = 3
accept_timeout = 30
connect_timeout = 3
非阻塞IO是为了能让一个进程(线程)处理多个客户端的连接。假设某进程使用阻塞IO处理10个客户端的连接,第1个客户未发送来命令,进程阻塞等待;剩余9个客户发送来了命令,但进程已经阻塞,不能接收并处理客户的请求。使用非阻塞IO时,依次在每个socket上调用read系统调用,判断客户命令是否到来,第一个客户未发送命令时,read系统调用不阻塞,返回-1,进程便能去判断第二个用户是否发送来了命令,依次类推;使用IO复用后,只需要通过一次系统调用,便能判断哪个客户发来了命令。
FTP服务器不能使用多线程实现,因为多个线程共享同一个工作目录。服务器程序必须为每个客户端单独创建一个进程来为客户端提供服务。在这种模式下,使用非阻塞IO和IO复用是没有意义的。
服务器监听21端口,客户端向服务器发出主动建立连接的请求后。服务器接收该请求,建立一条TCP连接,称为控制连接,接着fork()出一个子进程为客户端提供服务,以后所有用户输入的FTP命令和服务器的应答都由该连接进行传输;父进程继续监听21端口。
客户端首先向服务器端的21端口发起连接,经过三次握手建立控制连接,此后客户端通过控制连接向服务器发送命令;服务器通过控制连接向客户端发送响应。
如果要进行文件传输,就要再建立一个数据连接;一旦数据传输完毕,数据连接就会被关闭。再次进行文件传输时,需要建立新的数据连接。
服务器建立数据连接有两种模式:主动模式和被动模式。主动模式下,服务器主动连接客户端,防止服务器方存在防火墙或NAT;被动模式下,服务器被动等待客户端的连接,防止客户端方存在防火墙或NAT。
客户端通过PORT命令告知服务器自己的IP地址和端口号,服务器保存后向客户端发送成功的响应码;
如port命令及其参数为192,168,0,1,21,56,ip地址为192,168,0,1,端口号并不是2117
,端口号的高8位为十进制的21,即二进制的10101,端口号的低8位为十进制的56,即二进制的00111000,端口号的二进制为
10101 00111000,即10进制的5432,转化为网络字节序后为14357。
服务器收到客户端的数据传输命令后,为自己绑定20端口,而后连接客户端。(如果客户端方存在NAT,可以令NAT服务器维护一个映射信息;如果不指定服务器的端口为20,就需要维护很多的映射信息了。)
客户端向服务器发送PASV命令,服务器创建监听socket并在一个临时端口上监听,接着将自己的ip地址和临时端口发送给客户端;
服务器收到客户端的数据传输命令后,接收客户端的连接。
分配给用户的ftp服务进程会立刻调用fork(),令父进程拥有nobody用户的权限、调用bind绑定断口的权限,父进程成为nobody进程;子进程成为新的ftp服务进程。
用户通过USER命令发送用户名,ftp服务进程通过用户名获取uid;用户通过pass命令发送密码明文,ftp服务进程通过uid获取影子文件中的密码密文,将用户发来的明文加密后与密文对比,对比成功后,将ftp服务进程的权限设置为用户的权限。
ftp服务进程的权限较低,因此需要权限较高的nobody进程协助它与客户端建立数据连接。
nobody进程与ftp服务进程间通过unix域协议中的socketpair进行通信。
ftp服务进程向socketpair写入主动连接客户端,或监听客户端的连接,或接受客户端连接 的命令,nobody进程从socketpair读出这些命令,执行相关的操作后,将执行成功或失败的响应写入socketpair,并通过sendmsg将得到的连接套接字发送给ftp服务进程。
当ftp服务进程退出时,sockpair的写端关闭,阻塞在sockpair读端的nobody进程返回0,此时令nobody进程也退出;nobody进程nobody进程向主进程发送SIGGHLD信号。
用户下载文件时,ftp服务进程使用sendfile系统调用,文件不会拷贝到用户空间;用户上传文件时,ftp服务进程使用read和write系统调用,作为改进,可以使用mmap内存映射来减少write系统调用。
如果当前传输速度超过最大传输速度,
易知速度和时间成反比,则
当前传输速度 / 最大传输速度 = 最大传输速度下使用的时间 / 当前传输时间
当前传输速度 / 最大传输速度 - 1 = 最大传输速度下使用的时间 / 当前传输时间 - 1
当前传输速度 / 最大传输速度 - 1 = (最大传输速度下使用的时间 - 当前传输时间)/ 当前传输时间
(当前传输速度 / 最大传输速度 - 1)* 当前传输时间 = 最大传输速度下使用的时间 - 当前传输时间
为了实现了限速的目的,需要让ftp服务进程传输文件使用的时间为最大传输速度下使用的时间,即令ftp服务进程睡眠 最大传输速度下使用的时间 - 当前传输时间, 即睡眠(当前传输速度 / 最大传输速度 - 1)* 当前传输时间 。
在主线程中使用client_count变量保存当前连接数。
主线程接收客户端的连接后,fork()出ftp服务进程前,将该变量加1。
主进程fork出ftp服务进程后,在ftp服务进程中检查client_count是否大于配置文件中的最大连接数限制,是的话直接退出。
ftp服务进程(或nobody进程)退出时向主进程发送SIGGHLD信号,主进程在该信号处理函数中将client_count减1。
注意,不同于多线程,fork()后父子进程并不共享变量,因此不能在子进程中修改这些变量。
主进程中使用两张哈希表:ip2conncount,保存ip地址 -> ip地址对应的连接数;pid2ip,ftp服务进程 -> 为哪个ip服务。
主进程接收客户端的连接后,fork()出ftp服务进程前,++ip2conncount[client_addr.sin_addr.s_addr]。fork()后的主进程返回子进程,即ftp服务进程的pid,在主进程中令pid2ip[pid] = client_addr.sin_addr.s_addr。
主进程fork出ftp服务进程后,在ftp服务进程中检查ip2conncount[ip]是否大于配置文件中的最大连接数限制,是的话直接退出。
ftp服务进程(或nobody进程)退出时向主进程发送SIGGHLD信号,主进程在该信号处理函数中删除pid2ip[pid],并将ip2conncount[pid2ip[pid]]减1。如果ip2conncount[pid2ip[pid]]减为0,则将它也删除。
注意,不同于多线程,fork()后父子进程并不共享变量,因此不能在子进程中修改这些变量。
在ftp服务进程阻塞等待用户命令前,调用alarm()。经过配置文件中指定的超时时间后,发送SIGALRM信号给ftp服务进程,令其退出。
如果在超时时间内接收到了用户的命令,则取消闹钟;在执行完用户命令后,阻塞等待用户命令前,再次调用alarm(),重置超时时间。
ftp服务进程保存一个bool型的transfering_data变量。在进行数据传输时,令其为真。初始时和数据传输结束后,令其为假。
每次建立数据连接后,调用alarm()。
经过配置文件中指定的超时时间后,发送SIGALRM信号给ftp服务进程,在信号处理函数中判断transfering_data变量。为假,令ftp服务进程退出;否则,再次调用alarm()重置超时时间。
数据传输完毕后,ftp服务进程调用alarm(),重置控制连接的超时时间。
上述方法完全是错误的,想一想,为什么?
在进行数据传输时,transfering_data变量始终为真。如果在进行数据传输时,发生了长时间的阻塞(如客户端调用sleep()),上述方法就无能为力了。
我们可以使用带超时的IO函数(使用select检测超时)解决这一问题,相关函数已封装于block_socket.h文件。
客户端记录着已上传/已下载文件的大小。传输文件前,客户端可以通过REST命令将其发送给ftp服务进程;ftp服务进程打开文件后,将文件指针偏移相应位置,再进行文件传输。
客户端通过控制连接发送的abor命令可以中断数据连接。
该命令通过紧急模式发送,因此服务器需要开启控制连接接收带外数据的功能,并开启收到带外数据时产生SIGURG信号的功能。
使用进程池
RAII自动关闭文件
实现ftp over TLS
使用mmap