文章目录
- MiniFTP / FTP服务器
-
- 开发语言
- 开发环境
- 项目介绍
- 项目特点
- 项目链接
- 使用实例
- 架构介绍与难点分析
-
- 系统架构
- 连接模式
-
- 特定情景
- 主动模式
- 被动模式
- 进程组的设计
-
- 设计原因
- 内部通信机制
- 用户鉴权
- 空闲断开
- 限制连接
MiniFTP / FTP服务器
开发语言
C
开发环境
CentOS7、LeapFTP、vim、gcc、gdb、git、Makefile
项目介绍
MiniFTP是一个FTP服务器软件,通过使用MiniFTP能够快速的将任何一台PC设置成为一个简易的FTP服务器,任何一台PC都可以通过使用FTP协议来与服务器进行连接,进行文件的访问、存储、管理等,实现信息共享的功能。
项目特点
- 实现FTP命令如:USER、PASS、PORT、PASV、TYPE、LIST、SYST、FEAT、PWD、SIZE、CWD、RNFR/RNTO、STOR、RETR、MKD、RMD、CWD、QUIT、DELE、REST、CDUP
- 具有用户鉴权登录、断点续传(续载)、传输限速、配置文件解析等功能
- 实现主动和被动两种连接模式,通过nobody进程协助ftp服务进程创建数据连接与特权端口绑定。
- 实现控制连接和数据连接的空闲断开,缓解了服务器的压力。
- 通过哈希表实现最大连接数、每ip连接数的限制,防止大量的恶意访问。
项目链接
github
使用实例
这里借助Windows下的FTP客户端LeapFTP来进行演示
连接
架构介绍与难点分析
系统架构
为了保证各个客户端之间具有独立性以及健壮性,我选择了使用多进程来实现。
理由如下:
- 多进程之间具有独立的资源,各个客户端之间不会互相干扰
- 如果使用多线程或者多路复用可能会因为切换工作目录、异常、中断等行为来导致全体的反应,因为它们共享同一份资源
对于每一个客户端连接,都会通过一个**进程组(nobody进程、ftp服务进程)**来进行管理
至于为什么要通过进程组来进行通信,就需要先讲讲连接模式
连接模式
因为在网络通信时,可能会因为主服务器或者客户端受到防火墙或者NAT的影响,导致通信的某一端无法被连接,所以FTP提供了主动连接模式和被动连接模式
特定情景
之所以准备了主动和被动两种连接模式,是考虑到了数据连接时可能会因为防火墙或者NAT转换的原因导致连接的建立失败
为什么控制连接不会建立失败,而数据连接会呢?
因为NAT会主动记录由内部发送外部的连接信息,而控制连接的是由客户端向服务器端主动发起的,所以这条连接可以成功的建立。
而数据连接建立时,假设客户端启用XX端口来接受连接,通信外网时由于私网地址经过了NAT转换为公网地址,而服务器的20端口会主动向NAT的XX端口发起连接请求,但是NAT可能并没有启用XX端口,因此会导致连接被拒绝。或者可能因为防火墙中并没有设置该端口的开放权限,导致通往该端口的连接直接会被拒绝。客户端也同理,如下图
客户端受到防火墙或者NAT的干扰
服务器受到防火墙或者NAT的干扰
所以设计了主动****和被动两种连接模式来解决上面那两种情况
主动模式
主动模式用于解决服务器受到防火墙或者NAT干扰的情况,既然客户端的连接请求会被拒绝,那就由服务器来主动连接客户端
此时,即使服务器这边存在干扰,也能通过主动模式来成功建立起数据连接
连接流程
- 客户端向服务器发送PORT命令,PORT携带的参数为客户端的IP地址和端口号
- 服务器解析命令,将IP地址和端口号保存下来
- 服务器创建数据连接套接字,绑定到20端口,然后服务器向客户端发起连接请求connect()
被动模式
被动连接则是用来解决客户端受到防火墙或者NAT干扰的情况,此时服务器发往客户端的请求会被拒绝,那么此时就让客户端来主动连接,服务器被动的接收连接就行。
此时,即使客户端这边存在干扰,也能通过被动模式来成功建立起数据连接
连接流程
- 客户端向服务器发送PASV命令,请求服务器被动连接
- 服务器解析命令后创建一个连接套接字,绑定并监听一个临时端口,然后将IP地址和端口号发送给客户端
- 此时客户端发起连接connect(),服务器接收连接accept(),成功建立数据连接
并且主动连接和被动连接还有一个关键点,主动连接时需要客户端提供自己的IP地址和端口号,而服务器什么并没有提供关于数据连接的信息,所以此时服务器得到了安全保障,主动连接对服务器有利
而被动连接时服务器提供了数据连接的IP地址和端口号,而客户端并没暴露信息,所以被动连接对客户端有利。
为了保证客户端的使用安全,大部分FTP服务器都会默认使用被动连接模式。
进程组的设计
对于MiniFTP,我选择使用多进程来实现,并且每一个连接都会由一个nobody进程和ftp服务进程构成的进程组来进行管理。
设计原因
从上面的连接模式可以看到,在进行主动连接的时候服务器会创建一个连接套接字来绑定20端口(协议规定),然后主动向客户端建立起数据连接。此时就出现了一个问题,普通的用户没有权限绑定特权端口(1024以内的端口)。
针对这个问题,我一开始想的方法是先以ROOT权限来进行特权端口的绑定,然后再将其转为普通用户进程,经过阅读相关博客和询问老师,我发现经过这样一个升级——绑定——降级的过程,可能会导致不安全的情况。
不仅如此,无论是主动模式还是被动模式,套接字的创建、监听、特权端口绑定等这些会涉及到内核的相关操作,如果放到FTP的服务进程中,都会导致不安全的情况出现。
所以我想到了另外一种设计方案,再创建一个nobody进程,通过setcap()来给予它相关的权限,使得它此时的权限刚好能够满足对应操作(普通用户之上,root用户之下),并将所有涉及权限的操作全部交付给nobody进程来进行操作。
此时的nobody进程只会服务FTP服务进程,它不会接收任何来自客户端的请求,它的操作如下
- 协助FTP服务进程进行数据连接的管理
- 协助FTP服务进程进行特权端口的绑定
内部通信机制
由于nobody进程和FTP服务进程为父子进程,所有可以考虑使用匿名管道(pipe)来进行进程间的通信,但是由于匿名管道的通信是半双工通信(单向通信),一次只能由一方进行读和写,所以对于双方的一次数据通信就要进行两次pipe,使得代码变得复杂。而在unix域下有着更好的机制,就是socketpair(),socketpair与管道的机制相同,但是socketpair是全双工通信(双向通信),支持双方同时进行的读和写。
void priv_sock_init(session_t *sess);
void priv_sock_close(session_t *sess);
void priv_sock_set_parent_context(session_t *sess);
void priv_sock_set_child_context(session_t *sess);
void priv_sock_send_cmd(int fd, char cmd);
char priv_sock_recv_cmd(int fd);
void priv_sock_send_result(int fd, char res);
char priv_sock_recv_result(int fd);
void priv_sock_send_int(int fd, int the_int);
int priv_sock_recv_int(int fd);
void priv_sock_send_buf(int fd, const char *buf, unsigned int len);
void priv_sock_recv_buf(int fd, char *buf, unsigned int len);
void priv_sock_send_fd(int sock_fd, int fd);
int priv_sock_recv_fd(int sock_fd);
在进程组内部的通信中,实现了对结果、命令、字符、字符串、整型、描述符等格式的传输函数。其中其他的都十分简单,但是文件描述符的传输则有点麻烦
因为文件描述符并不是一个简单的整型传输,由于两个进程有着不同的文件描述符表,而此时则需要将一个进程中的文件描述符传给另一个进程的文件描述符表中。
这时可以借助系统函数来实现
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);struct msghdr
{void *msg_name; /* 目的IP地址 */socklen_t msg_namelen; /* 地址长度 */struct iovec *msg_iov; /* 指定的内存缓冲区 */size_t msg_iovlen; /* 缓冲区的长度 */void *msg_control; /* 辅助数据 */size_t msg_controllen; /* 指向cmsghdr结构,用于控制信息字节数 */int msg_flags; /* 描述接收到的消息的标志 */
};struct cmsghdr {socklen_t cmsg_len; /* 计算cmsghdr头结构加上附属数据大小 */int cmsg_level; /* 发起协议 */int cmsg_type; /*协议特定类型 */
};//获得指向与msghadr结构关联的第一个cmsghdr结构
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *msgh);//计算 cmsghdr 头结构加上附属数据大小,并包括对其字段和可能的结尾填充字符
size_t CMSG_SPACE(size_t length);//计算 cmsghdr 头结构加上附属数据大小
size_t CMSG_LEN(size_t length);//返回一个指针和cmsghdr结构关联的数据
unsigned char *CMSG_DATA(struct cmsghdr *cmsg);
上述函数的使用在这里就不多介绍,可以通过查询man手册或者阅读相关博客来进行了解
nobody进程的工作流程
基本流程
- 将当前用户从root用户切换为nobody用户,并且通过setcap()来提升对应权限
- 循环等待FTP服务进程发送来的命令
——分支——
#define PRIV_SOCK_GET_DATA_SOCK 1
#define PRIV_SOCK_PASV_ACTIVE 2
#define PRIV_SOCK_PASV_LISTEN 3
#define PRIV_SOCK_PASV_ACCEPT 4
主动连接
- 接收到PRIV_SOCK_GET_DATA_SOCK命令
- 调用privop_pasv_recv_data_sock创建数据连接套接字,绑定20端口,并将创建好的套接字发送给FTP服务进程
被动连接
- 接收到PRIV_SOCK_PASV_LISTEN命令
- 调用privop_pasv_listen,创建一个监听套接字,绑定一个临时端口,然后将IP地址和端口号发送给FTP服务进程,然后FTP服务进程会将地址信息发送给客户端,被动接收连
- 当客户端发起连接时,FTP服务进程就会向nobody进程发送PRIV_SOCK_PASV_ACCEPT命令
- nobody调用privop_pasv_accept来接收连接,并将连接后的数据连接描述符发送给FTP服务进程
用户鉴权
对于Linux的服务器来说,每一个账户都是Linux下的用户。所以对账号的登陆验证,就是通过去对比该用户的密码与输入的密码是否一致。
那么接下来就应该确认账号和密码是否正确。
首先,我们需要查找用户输入的账号是否存在,毕竟账号不存在,就根本没有鉴定密码的必要。
我们可以通过用户名,使用struct passwd *getpwnam(const char *name)
这个函数来查找到对应用户的信息,并且返回passwd结构的用户信息,并且将passwd中的uid(用户id)保存到会话信息中
struct passwd
{char *pw_name; /* username */char *pw_passwd; /* user password */uid_t pw_uid; /* user ID */gid_t pw_gid; /* group ID */char *pw_gecos; /* user information */char *pw_dir; /* home directory */char *pw_shell; /* shell program */
};
接着,就需要验证密码。
但是在Linux下,为了保证用户的安全,所有的密码都经过了加密后与用户名一起放在了影子文件中,并且加密的算法是单向的,无法进行解密
那么我们就需要通过用户名来获取到影子文件中的内容,可以使用函数struct spwd *getspnam(const char *name)
来使用用户名来查询到对应的影子信息,这个影子信息存储在spwd结构体中
struct spwd
{char *sp_namp; /* Login name */char *sp_pwdp; /* Encrypted password */long sp_lstchg; /* Date of last change(measured in days since1970-01-01 00:00:00 +0000 (UTC)) */long sp_min; /* Min # of days between changes */long sp_max; /* Max # of days between changes */long sp_warn; /* # of days before password expiresto warn user to change it */long sp_inact; /* # of days after password expiresuntil account is disabled */long sp_expire; /* Date when account expires(measured in days since1970-01-01 00:00:00 +0000 (UTC)) */unsigned long sp_flag; /* Reserved */
};
通过访问spwd中的sp_pwdp参数,就可以获取到加密后的密码。
接下来就要思考如何进行密码的比对了,明文和密文肯定是无法直接比对的,那就需要将他们先转换为同一种格式。而Linux的加密算法又是单向的,无法将其解密,那我们就反其道而行之,将明文进行加密后再与密文进行对比。
我们可以借助char *crypt(const char *key, const char *salt)
这个函数来进行加密
其中的key为需要加密的明文,而salt为加密的密钥。因为salt会默认使用DES加密算法(会根据salt前几位的xxx中的x来修改加密方式)进行加密,并且在DES加密时会只提取salt的前两个字符作为密钥进行加密,多余的丢弃。而加密后取得的密文的前两位也就是这个密钥。
所以,我们可以直接将影子文件中的密码作为密钥进行加密,然后加密结束后判断相同的密钥加密后的明文是否与影子文件中的密码一致,如果一致则说明密码正确。
空闲断开
我们需要对某些长时间无操作的不活跃客户端进行断开操作,来减轻服务器的压力,腾出空间来为其他活跃用户服务
那么如何设计这个功能呢?我一开始时想到可以设计一个定时器,定时器会一直监控进程是否运作,如果长时间无活动则会调用一个回调函数来通知断开进程。但是这样的一个定时器实现起来并不方便,并且如果由服务器来对一个进程进行监控和维护不仅会增加服务器压力,还会使整体流程更加混乱。
考虑到上述问题,就想到让操作系统来代为管理,而正好,sigalrm信号刚好就符合我的需求。
但是,又有另外一个问题,当客户端在下载和上传的时候,就会处于一个长时间的I/O阻塞,这个时候就会可能被误判为无操作而被断开,所以针对数据连接和控制连接来对信号进行分开处理
对于控制连接
- 如果当前没有在传输,则断开连接
- 如果当前在传输中,则忽略本次,重新进行控制连接断开计时。
对于数据连接
- 当启动数据连接计时的时候,停止控制连接的计时,等到传输结束后再恢复
- 当连接断开时先关闭读端,然后将连接断开的响应码发送给客户端,再将写端断开
限制连接
为了防止有大量的恶意连接和以及同一IP下大量连接带来的服务器压力,需要考虑对总连接数以及每IP连接数进行限制。
首先,我们要考虑如何监控一个连接的创建与断开。
创建很简单,只需要在其建立起控制连接的时候进行记录即可,而删除的时候就稍微有点麻烦,我们需要注册对子进程退出的sigchld信号的处理方法,当有进程退出时,就说明有一个连接进行断开。
接着。就需要思考如何建立起IP与连接数的映射关系。
我们可以考虑使用键值对的模型,利用哈希表来进行一个映射,记录下每个IP地址的连接数。
但是问题来了,虽然在创建的时候我们可以通过访问accept()时接收的sockaddr来知道某个IP地址创建了一个新连接。但是由于退出的时候我们直接捕获了sigchld信号,并没有方法确认这个退出的进程属于哪个id地址。
经过思考后,我选择使用两个哈希表来解决这个问题。第一个哈希表用来建立起进程PID与IP地址的映射关系,第二个哈希表用来建立起IP地址与连接数的映射关系。
这样连接数的计算流程就如下
- 新连接到来时,创建子进程来管理新连接
- 对子进程PID与accept()获取的sockaddr中的ip地址建立映射关系,并且增加对应IP地址的连接数
- 当有进程退出时,捕捉sigchld信号,调用注册函数进行处理
- waitpid()获取当前退出的进程ID,通过查询哈希表来找到对应的IP地址,并对减少对应IP地址的连接数
其他具体的实现流程请参考源代码,在这里就不多赘述了,本博客只介绍了其中比较关键且难理解的地方与整体的逻辑架构设计思路
github链接