FTP协议的介绍
FTP协议又称文件传输协议,它的组成包括两个部分:FTP服务端和FTP客户端,FTP服务器用来存储文件,其中20端口是传输数据的端口,21端口是传输控制信息的端口。
项目简介
本项目主要的功能是实现一个FTP服务器,客户端使用的是leapftp,是一个交互开发的过程,项目具体功能如下
- 可以实现FTP内部标准命令:UESR,PASS,PORT,PASV,LIST,STOR等。
- 实现配置文件的解析和用户的鉴权登录功能
- 实现FTP主动(port)模式和被动(pasv)模式的数据连接的建立
- 实现文件的上传下载,续传续载以及限速功能
- 实现系统的空闲断开(数据连接断开和控制连接断开)以及系统的连接数限制(客户连接数限制和每IP数限制)
FTP工作原理
我们将FTP客户端发起的一次请求称为一次会话。
1.1 启动FTP
启动FTP客户端,在客户端通过交互的用户页面,客户输入启动FTP的交互式指令。
1.2 建立控制连接
客户端根据用户命令给出的服务端的IP地址,向服务端的21端口发出主动连接的请求,服务器收到请求连接之后,通过TCP三次握手协议,在客户端的控制通道和服务端的控制通道建立了一条连接。
控制连接通道主要用来传送FTP的内部命令以及命令的响应等控制信息,它并不用来传输数据。
1.3建立数据连接
控制连接建立之后,进行数据传输的时候需要开辟数据连接通道。
FTP有两种建立数据连接的方式:分别是PORT模式和PASV模式。将分别在下面进行赘述。
1.4 关闭连接
数据传输完成之后,数据连接被关闭,重新回到FTP会话状态,直到控制连接被关闭,FTP服务结束。
FTP主动模式和被动模式连接
注意PORT模式和PASV模式都是相对于服务端来说的。
1.主动模式:
客户端向服务端发送PORT命令,客户端会创建临时套接字,绑定一个临时端口,并且在套接字上监听。
FTP服务器将通过20端口主动的连接客户端的临时端口,通过TCP三次握手建立数据连接通道。
2.被动模式:
客户端向服务端发送PASV命令,此时服务端会创建监听套接字,绑定一个端口,服务器在套接字上监听。
FTP服务端将通过控制通道告知自己的端口号,被动的等待客户端的连接。
客户端绑定临时端口和服务端创建的端口进行连接,通过TCP三次握手协议建立数据连接通道。
项目系统的逻辑架构
如上图所示:每来一个客户端。主进程就会创建一个新的进程组,该进程组专门为该客户端进行服务。其中nobody进程主要负责的是内部协议的解析,而FTP服务进程则负责数据传输和FTP协议的解析。两个进程之间的数据是通过进程通信模块来完成的。
项目实现
一、项目框架搭建
项目文件包含的文件如下图所示,文件主要可以分为以下3个部分
-
蓝色部分为配置文件加载模块,主要是仿照vsftpd中的配置文件,直接在miniftp.conf中修改配置项即可更改FTP相关的参数配置,方便用户操作。
-
橙色部分为系统公共模块,其中common.h:包含系统头文件,sysutil.c包含项目所需要的公有函数,str.c模块主要是用来解析命令行参数。
-
紫色部分为项目的重点模块,ftpproto.c模块用来实现ftp常用命令的解析,主被动模式的建立,文件的上传下载等功能。其中ftpcodes.h模块用来存储FTP的应答方式。服务器通过控制连接发送给客户端FTP应答,用户根据收到的应答信息来对服务器做出相关响应。
privparent.c主要负责的是创建主动模式和被动模式下的数据连接套接字,它其实就是nobody进程(FTP服务进程的父进程),通过privsock.c模块中提供的套接字发送接收函数将nobody进程创建的套接字发送给其子进程ftpproto.c模块。
上文提到过,FTP的一次连接其实就是一次会话。故session.c用来建立一次会话,通过fork函数会创建子进程ftp服务进程(ftpproto.c)以及自身进程(nobody进程)。之后在下文中将依次介绍每个模块是如何实现的。
二、系统各个模块的实现
配置文件解析模块
配置解析模块主要用来将miniftp.conf中对应的配置项的值加载到tunable.c文件中对应的配置变量中去。
miniftp.conf的内容如下所示:
#服务器IP //服务器的ip地址
listen_address=192.168.138.8
#是否开启被动模式
pasv_enable=YES
#是否开启主动模式
port_enable=NO
#FTP服务器端口
listen_port=9100
#最大连接数
max_clients=2000
#每ip最大连接数
max_per_ip=50
#Accept超时间
accept_timeout=60
#Connect超时间
connect_timeout=60
#控制连接超时时间
idle_session_timeout=10
#数据连接超时时间
data_connection_timeout=0
#掩码
local_umask = 077
#最大上传速度
upload_max_rate=102400
#最大下载速度
download_max_rate=102400
tunable.c的内容如下,每一个变量对应的值是默认值。
#include"tunable.h"
int tunable_pasv_enable = 1; //是否开启被动模式
int tunable_port_enable = 1; //是否开启主动模式
unsigned int tunable_listen_port = 21; //FTP服务器端口
unsigned int tunable_max_clients = 2000; //最大连接数
unsigned int tunable_max_per_ip = 50; //每ip最大连接数
unsigned int tunable_accept_timeout = 60; //Accept超时间
unsigned int tunable_connect_timeout = 60; //Connect超时间
unsigned int tunable_idle_session_timeout=300; //控制连接超时时间
unsigned int tunable_data_connection_timeout=300; //数据连接超时时间
unsigned int tunable_local_umask = 077; //掩码
unsigned int tunable_upload_max_rate = 0; //最大上传速度
unsigned int tunable_download_max_rate=0; // 最大下载速度
const char *tunable_listen_address; // FTP监听的地址
我们需要实现的功能是使得配置文件miniftp.conf中配置的参数最终可以保存在tunable.c中对应的变量中。因此,需要建立两者的映射关系。parseconf.c文件就是用来将对应的配置项参数加载到配置变量中的。
首先需要定义对应的查询数组,如下图所示:
parseconf_str_setting用来解析配置项的参数值为字符串的情况,该结构体中定义两个字符指针,分别指向了配置项参数名和配置变量。同理parseconf_int_setting结构体是用来解析配置项的参数值为整数的情况的。
需要注意的是结构体数组中包含两个NULL指针,用来代表配置项结束。
其次它的内部逻辑如下所示:
将miniftp.conf中的数据解析为参数名和对应的参数值,分别用key和value作为表示。
循环遍历各自的结构体数组,将key和结构体成员p_setting_name作比较,两者相等的时候,将value值赋值给相对应的配置变量即可。
parseconf.c模块对应的文件如下所示:
#include"parseconf.h"
#include"tunable.h"
#include"str.h"
//bool型配置项,结构体数组类型和结构体变量同时定义。
static struct parseconf_bool_setting
{
const char *p_setting_name; //配置项的名字
int *p_variable; //配置项的值
}
parseconf_bool_array[] =
{
{"pasv_enable", &tunable_pasv_enable},
{"port_enable", &tunable_port_enable},
{NULL, NULL}
};
//int配置项
static struct parseconf_uint_setting
{
const char *p_setting_name;
unsigned int *p_variable;
}
parseconf_uint_array[] =
{
{"listen_port", &tunable_listen_port},
{"max_clients", &tunable_max_clients},
{"max_per_ip" , &tunable_max_per_ip},
{"accept_timeout", &tunable_accept_timeout},
{"connect_timeout", &tunable_connect_timeout},
{"idle_session_timeout", &tunable_idle_session_timeout},
{"data_connection_timeout", &tunable_data_connection_timeout},
{"local_umask", &tunable_local_umask},
{"upload_max_rate", &tunable_upload_max_rate},
{"download_max_rate", &tunable_download_max_rate},
{NULL, NULL}
};
//str配置项
static struct parseconf_str_setting
{
const char *p_setting_name;
const char **p_variable;
}
parseconf_str_array[] =
{
{"listen_address", &tunable_listen_address},
{NULL, NULL}
};
void parseconf_load_file(const char *path)
{
// 打开对应的miniftp.conf解析文件
FILE *fp = fopen(path, "r");
if(NULL == fp)
ERR_EXIT("parseconf_load_file");
char setting_line[MAX_SETTING_LINE_SIZE] = {0};
// fgets函数,用来读取文件中的一行元素,fp是对应的文件流指针,读取到的元素存储到setting_line字符数组中。
while(fgets(setting_line, MAX_SETTING_LINE_SIZE, fp) != NULL)
{
// 跳过注释行
if(setting_line[0]=='\0' || setting_line[0]=='#')
continue;
// 剔除换行和回车
str_trim_crlf(setting_line);
//解析配置行
parseconf_load_setting(setting_line);
// 清空字符数组,因为需要重新读取下一行数据
memset(setting_line, 0, MAX_SETTING_LINE_SIZE);
}
fclose(fp);
}
//listen_port=9100
void parseconf_load_setting(const char *setting)
{
char key[MAX_KEY_SIZE] = {0};
char value[MAX_VALUE_SIZE] = {0};
// 将miniftp.conf文件中的每一行数据分割为key和value.
str_split(setting, key, value, '=');
//查询str配置项
const struct parseconf_str_setting *p_str_setting = parseconf_str_array;
// 循环遍历结构体数组,如果为空,代表配置项查询结束
while(p_str_setting->p_setting_name != NULL)
{
// key值和参数名称相等的时候,将value值保存在tunable中的配置变量中。
if(strcmp(key, p_str_setting->p_setting_name) == 0)
{
const char **p_cur_setting = p_str_setting->p_variable;
if(*p_cur_setting)
free((char *)*p_cur_setting);
// 注意这里使用的函数为strdup,它用来拷贝字符串,并且在底层会分配空间,调用malloc函数。
*p_cur_setting = strdup(value);//malloc
return;
}
p_str_setting++;
}
//查询bool配置项
const struct parseconf_bool_setting *p_bool_setting = parseconf_bool_array;
while(p_bool_setting->p_setting_name != NULL)
{
if(strcmp(key, p_bool_setting->p_setting_name) == 0)
{
//转换为大写,value为YES或者NO,如果给定的是yes或者no的时候。
str_upper(value);
int *p_cur_setting = p_bool_setting->p_variable;
if(strcmp(value, "YES") == 0)
*p_cur_setting = 1;
else if(strcmp(value, "NO") == 0)
*p_cur_setting = 0;
else
ERR_EXIT("parseconf_load_setting");
return;
}
p_bool_setting++;
}
//查询int配置项
const struct parseconf_uint_setting *p_uint_setting = parseconf_uint_array;
while(p_uint_setting->p_setting_name != NULL)
{
if(strcmp(key, p_uint_setting->p_setting_name) == 0)
{
unsigned int *p_cur_setting = p_uint_setting->p_variable;
//char value[MAX_VALUE_SIZE] = {0};
// 这里特别需要注意的是value值是字符类型,所以必须要转化为整型,否则就会报错。
*p_cur_setting = atoi(value);
return;
}
p_uint_setting++;
}
}
主函数模块
该文件主要是用来控制FTP服务器的启动,这里的主进程模拟的是root进程,为了防止主进程退出,服务端和客户端一旦建立连接之后,通过while循环来不断的等待新的连接请求到来。
它的主要流程如下所示:
- 加载配置文件miniftp.conf到主进程中。
- 检测是否是root启动,普通用户没有权限启动ftp服务端。
- 主进程完成和客户端的连接,产生控制连接的套接字sockconn.
- 创建子进程,子进程用来开启会话,通过session结构体中的ctrl_fd来获得连接套接字。
- 父进程关闭sockconn.
代码如下所示:
//全局会话结构指针
session_t *p_sess;
int main(int argc,char *argv[])
{
//加载配置文件
parseconf_load_file("miniftp.conf");
//判断是否为root用户启动
if(getuid()!=0)
{
printf("miniftp : must be started as root.\n");
exit(EXIT_FAILURE);
}
//初始化会话结构体
session_t sess=
{
-1,-1,-1,-1,-1,-1,NULL,"","","",1,NULL,0,0,0
};
p_sess = &sess;
int listenfd=tcp_server(tunable_listen_address,tunable_listen_port);
printf("listenfd=%d\n",listenfd);
int sockConn;
struct sockaddr_in addrCli;
socklen_t addrlen;
while(1)
{
sockConn=accept(listenfd,(struct sockaddr*)&addrCli,&addrlen);
printf("sockConn=%d\n",sockConn);
if(sockConn<0)
{
perror("accept");
continue;
}
pid_t pid =fork();
if(pid==0)
{
//子进程关闭监听套接字
close(listenfd);
// 将创建好的控制连接套接字赋值给会话结构体中的ctrl_fd
sess.ctrl_fd=sockConn;
begin_session(&sess);
exit(EXIT_SUCCESS);
}
else{
//父进程关闭控制连接的套接字
close(sockConn);
}
}
}
会话体模块
session.h模块中定义了FTP会话所需要的数据成员,包含控制连接的套接字的文件描述符,父子进程通道描述符,数据连接描述符等信息。
#ifndef _SESSION_H_
#define _SESSION_H_
#include "common.h"
typedef struct session{
uid_t uid;
// 控制连接
int ctrl_fd;
// 父子进程
int parent_fd;
int child_fd;
// 传输数据时服务端的socket
int data_fd;
// 被动模式下的监听套接字
int pasv_listen_fd;
// PORT模式下用来获取客户端的IP地址和端口号
struct sockaddr_in *port_addr;
// 命令行参数字符数组
char cmdline[MAX_COMMOND_LINE_SIZE];
// 对应的命令
char cmd[MAX_CMD_SIZE];
// 对应的参数
char arg[MAX_ARG_SIZE];
// ftp协议状态
int is_ascii;
char *rnfr_name;
}session_t;
void begin_session(session_t *sess);
#endif
session.c代码如下:
#include"session.h"
#include"ftpproto.h"
#include "privsock.h"
#include"privparent.h"
void begin_session(session_t *sess)
{
// 初始化父子进程对应的套接字
priv_sock_init(sess);
pid_t pid = fork();
if(pid == -1)
ERR_EXIT("session fork");
if(pid == 0)
{
//ftp 服务进程
priv_sock_set_child_context(sess);
handle_child(sess);
}
else
{
priv_sock_set_parent_context(sess);
//nobody 进程
handle_parent(sess);
}
}
下面我们先看一下FTP服务进程的流程,这也是本次项目的最重要的模块,它完成了对FTP命令行参数的解析,鉴权登录,文件传输(上传下载),空闲断开(控制空闲断开和数据空闲断开)等一系列功能。
FTP服务进程模块
对应的步骤如下所示:
- 接收客户端发送过来的命令行,利用str.c里面的函数将命令行进行分割:分割为命令+参数,命令和参数以空格作为分割符,在解析命令行之前需要删除\r和\n,相关代码如下所示
例如:cmdline=USER mnitjh,cmd=USER,arg=mnitjh;cmdline=PASS 123456,cmd=PASS,arg=123456
void str_trim_crlf(char *str)
{
char *p = str + (strlen(str)-1);
while(*p=='\r' || *p=='\n')
*p-- = '\0';
}
void str_split(const char *str, char *left, char *right, char token)
{
// 用来确定字串的位置,pos指向的是token所在的位置。
char *pos = strchr(str, token);
// pos为空,即右侧没有参数。
if(pos == NULL)
strcpy(left, str);
// pos不为空的,将pos左侧的字串拷贝到left中,pos右侧的字串拷贝到right中。
else
{
strncpy(left, str, pos-str);
strcpy(right, pos+1);
}
}
- 建立命令映射关系:定义一个结构体变量:用来保存命令和对应处理函数的地址,如下所示:
typedef struct ftpcmd
{
const char *cmd; // 命令
void(*cmd_handler)(session_t *sess); //命令处理方法,函数指针,指向cmd_handler函数。
}ftpcmd_t;
// 常见的命令和对应的处理函数
ftpcmd_t ctrl_cmds[] =
{
{"USER", do_user},
{"PASS", do_pass},
{"PWD", do_pwd},
{"TYPE", do_type},
{"PORT", do_port},
{"PASV", do_pasv},
{"LIST", do_list},
{"CWD", do_cwd},
{"RMD" , do_rmd },
{"MKD" , do_mkd },
{"DELE", do_dele},
{"SIZE", do_size},
{"RNFR", do_rnfr},
{"RNTO", do_rnto},
{"RETR", do_retr},
{"STOR", do_stor},
{"REST", do_rest},
};
- 遍历所有的命令:
查找服务器接收到的参数是否存储在该结构体内,若存在,则响应相关的命令处理函数。处理完相关的命令之后,服务器向客户端回应响应代码。查找命令响应函数的代码如下所示:
while(1)
{
// 每次接收新的命令参数之前,需要将之前的字符数组清空。
memset(sess->cmdline, 0, MAX_COMMOND_LINE_SIZE);
memset(sess->cmd, 0, MAX_CMD_SIZE);
memset(sess->arg, 0, MAX_ARG_SIZE);
// 利用控制连接通道接收客户端发送过来的命令行参数。
int ret =recv(sess->ctrl_fd,sess->cmdline,MAX_CMD_SIZE,0);
if(ret==0)
exit(EXIT_SUCCESS);
if(ret<0)
ERR_EXIT("recv");
//对命令行的处理:剔除回车和换行,分割字符串。
str_trim_crlf(sess->cmdline);
str_split(sess->cmdline, sess->cmd, sess->arg, ' ');
//命令映射表部分
int table_size = sizeof(ctrl_cmds) / sizeof(ctrl_cmds[0]);
int i;
for(i=0; i<table_size; ++i)
{
// 循环遍历命令映射表,接收到的命令行参数和表中的匹配,那么执行相关的处理函数。
if(strcmp(sess->cmd, ctrl_cmds[i].cmd) == 0)
{
if(ctrl_cmds[i].cmd_handler)
ctrl_cmds[i].cmd_handler(sess);
//命令存在,对应的响应函数没有实现,回复502代码。
else
ftp_reply(sess, FTP_COMMANDNOTIMPL, "Unimplement command.");
break;
}
}
//命令不存在,回复500代码
if(i >= table_size)
ftp_reply(sess, FTP_BADCMD, "Unknown command.");
}
常用命令的解析
1.鉴权登录:需要使用到两个函数分别是getpwnam(passwordname)和getspname(shadow password)。其中getpwnam返回的是一个结构体指针,它的类型是passwd,结构体包含的内容如下所示。函数的返回值为NULL,如果当前匹配的用户不存在的话。
其中name是用户输入的名称。
struct passwd *getpwnam(const char *name)
passwd结构体中包含的内容如下所示,参数解释如下所示
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 */
};
USER响应函数的步骤:只需要将解析到的name作为参数传递给getpwnam即可,返回值若为空,代表客户端输入的用户名称不存在。之后回应相关的FTP响应代码即可,代码如下所示,这里需要注意一点:假设用户名称和系统中保存的用户名一致,我们需要存储当前用户ID的uid,为了验证密码所需要的。
static void do_user(session_t *sess)
{
struct passwd *pwd = getpwnam(sess->arg);
if(pwd != NULL)
sess->uid = pwd->pw_uid; //保存用户ID即uid
ftp_reply(sess, FTP_GIVEPWORD, "Please specify the password");
}
验证密码的步骤如下所示:这里读者可能会有困惑,为什么不直接获取输入的用户名称,而需要通过uid来获取结构体从而获得用户名,这不是多此一举嘛。其实是因为在USER命令向响应结束,那么下一次等待客户端发来PASS命令的时候,之前存储的命令行参数都会被清空,所以我们是没有办法直接获取到用户的名称的,所以需要定义用户的uid通过uid间接的访问到用户名。
其实也可以尝试一下,直接在session结构体中保存它的用户名,会觉得这样更方便一点。
此外,有读者可能困惑,为什么不直接使用明文进行对比呢,因为linux系统中,为了防止密码被泄漏,会使用加密算法对明文加密,加密之后的密码保存在shadow.h文件中。
我们没有办法从加密之后的密码推断之前的明文是什么,只能将客户端用户输入的密码利用crypt函数进行加密之后,和正确的明文加密密码进行对比,从而判断密码是否正确。
对应代码如下所示,需要注意的是需要更改当前用户的uid和当前用户的groupid,否则对应的用户名是root用户。
static void do_pass(session_t *sess)
{
//鉴权登录
struct passwd *pwd = getpwuid(sess->uid);
if(pwd == NULL)
{
//用户不存在
ftp_reply(sess, FTP_LOGINERR, "Login incorrect.");
return;
}
struct spwd *spd = getspnam(pwd->pw_name);
if(spd == NULL)
{
//用户不存在
ftp_reply(sess, FTP_LOGINERR, "Login incorrect.");
return;
}
//spd->sp_pwdp:encrypted password:加密之后的密码
char *encrypted_pw = crypt(sess->arg, spd->sp_pwdp);
// printf("the user password is %s\n",sess->arg);
// 将用户输入的加密密码和正确的加密密码进行比较,不相等的话,回复错误代码
if(strcmp(encrypted_pw, spd->sp_pwdp) != 0)
{
//密码错误
// printf("the user encrpted_true_password is %s\n",spd->sp_pwdp);
// printf("the user encrpted_password is %s\n",encrypted_pw );
ftp_reply(sess, FTP_LOGINERR, "Login incorrect.");
return;
}
// 更改ftp服务进程的名称为当前用户,如果不更改的话,利用ps -ef查看当前进程会发现nobody进程和用户进程都是root进程。
setegid(pwd->pw_gid);
seteuid(pwd->pw_uid);
chdir(pwd->pw_dir);
ftp_reply(sess, FTP_LOGINOK, "Login successful.");
}
后续再看:
void handle_child(session_t *sess)
{
// 测试fork之后:父子进程之间的信息就不共享了嗷,要是子进程改变了也会影响父进程,那就了不得了呀!
// memset(sess->test,49,5);
// printf("child_sess->test=%s\n",sess->test);
send(sess->ctrl_fd,"220 (miniftp1.0.1)\r\n",strlen("220 (miniftp1.0.1)\r\n"),0);
while(1)
{
memset(sess->cmdline, 0, MAX_COMMOND_LINE_SIZE);
memset(sess->cmd, 0, MAX_CMD_SIZE);
memset(sess->arg, 0, MAX_ARG_SIZE);
//设置对应的函数,这里要放在recv函数之前,recv是阻塞等待,接收数据的。如果客户端没有发送数据,一旦定时的时间到达,CPU的控制权就会到signal(SIGALRM,signal_handler)中的signal_handler中。
handle_idel_connection();
int ret =recv(sess->ctrl_fd,sess->cmdline,MAX_CMD_SIZE,0);
if(ret==0)
exit(EXIT_SUCCESS);
if(ret<0)
ERR_EXIT("recv");
str_trim_crlf(sess->cmdline);
str_split(sess->cmdline, sess->cmd, sess->arg, ' ');
// printf("cmdline=%s\n",sess->cmdline);
//命令映射
int table_size = sizeof(ctrl_cmds) / sizeof(ctrl_cmds[0]);
int i;
for(i=0; i<table_size; ++i)
{
if(strcmp(sess->cmd, ctrl_cmds[i].cmd) == 0)
{
if(ctrl_cmds[i].cmd_handler)
ctrl_cmds[i].cmd_handler(sess);
else
ftp_reply(sess, FTP_COMMANDNOTIMPL, "Unimplement command.");
break;
}
}
if(i >= table_size)
ftp_reply(sess, FTP_BADCMD, "Unknown command.");
}
}
2.数据连接的建立过程:PORT模式和PASV模式的建立。
这里我们需要知道FTP服务进程中需要的数据连接的套接字并不是由FTP服务进程本身创建的,而是通过nobody进程创建数据连接套接字,两个进程通过套接字通信的方式,将创建的套接字发送给FTP服务进程。
那么为什么要使用nobody进程呢?
PORT模式下:服务器需要connect客户端,服务器可能没有权限做这种事情,需要nobody进程来帮忙,此外普通用户没有权限绑定20端口。
PASV模式下:创建套接字以及对套接字的监听涉及到内核的相关操作,因此直接由ftp服务进程去做,是不安全的。
nobody进程和FTP服务进程的通信协议规定如下:
- 由于父子进程之间双方都需要发送和读取数据,所以使用socket_pair函数创建一对无名的,相互连接的套接字。socketpair创建的套接字是全双工通信,每一个套接字既可以读数据也可以写数据,例如:可以往sv[0]中写数据,从sv[1]中读数据;也可以往sv[1]中写数据,从sv[0]中读数据。
函数声明如下,其中domain为作用域type为套接字类型,protocol设置为0即可,sv代表创建的套接字。由于nobody进程和ftp服务进程用于本地通信,因此domain选用的是AF_UNIX,注意这里不再是我们经常使用的AF_INET(用于网络通信的)。
int socketpair(int domain, int type, int protocol, int sv[2]);
- 完成父子进程上下文的设置,由于文件描述符是父子进程共享的,因此需要关闭对方的描述符。
void priv_sock_set_parent_context(session_t *sess)
{
// 父进程需要关闭子进程的套接字
if(sess->child_fd != -1)
{
close(sess->child_fd );
sess->child_fd = -1;
}
}
//子进程需要关闭父进程的套接字
void priv_sock_set_child_context(session_t *sess)
{
if(sess->parent_fd != -1)
{
close(sess->parent_fd);
sess->parent_fd = -1;
}
}
- 内部通讯机制还定义了一系列的函数如下所示,主要关注的是最后一组函数,发送文件描述符和接收文件描述符。由于之前创建的控制连接的文件描述符是在fork之前创建的,因此子进程是可以共享父进程的信息的。而此时,我们需要在nobody进程中创建数据连接的套接字,发送给FTP服务进程,两个进程是独立的,互不影响的。因此不能直接传递4个字节的整型变量,而需要传递一个打开的文件描述符。
//FTP服务进程向nobody进程请求的命令
#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
//nobody 进程对FTP服务进程的应答
#define PRIV_SOCK_RESULT_OK 1
#define PRIV_SOCK_RESULT_BAD 2
void priv_sock_send_cmd(int fd, char cmd); //发送命令(子进程向父进程发送请求)
char priv_sock_get_cmd(int fd); //接收命令(父进程接收子进程命令)
void priv_sock_send_result(int fd, char res); //发送结果(父进程向子进程发送结果)
char priv_sock_get_result(int fd); //接收结果(子进程接收父进程发送的结果)
void priv_sock_send_int(int fd, int the_int); //发送一个整数
int priv_sock_get_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); //接收套接字
关于发送套接字和接收套接字的详细情况:可以看这篇博文:
实现的内部使用到的函数是sendmsg和recvmsg函数。
ssize_t sendmsg(int sockfd,const msghdr *msg,int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
需要对msghdr结构体内部的成员进行初始化,内部的结构比较复杂。有兴趣的同学可以配合上面的博文看一下具体实现的细节。
最终发送套接字实际上将套接字作为辅助数据进行发送的。
- 需要注意的是:将需要发送的文件描述符send_fd的长度作为参数传送给CMSG_SPACE,就可以得到cmsghdr整个结构体的大小。
之后填充msg_control和msg_controllen即可。 - 填充cmsghdr结构体的步骤:
利用宏函数CMSG_FIRSTHDR,将msg的地址传入,既可以得到msghdr指向的第一个cmsghdr结构体的地址,对内部变量cmsg_len,cmsg_level,cmsg_type填充即可。 - 使用CMSG_DATA宏,将填充好的cmsghdr结构体地址传入,即可以得到对应的辅助数据地址。将需要传输的文件描述符放入该地址即可。
此外我们可以看到,在内部通讯模块中,还定义了2组宏定义,其中A组宏定义用来表述FTP服务进程向nobody进程请求获取数据连接套接字。B组宏定义,它用来代表nobody进程队FTP服务进程的应答。
nobody进程中定义了4个函数分别用来对应A组宏定义,如下所示:
//获取主动连接套接字
static void privop_port_get_data_sock(session_t *sess);
//判定被动模式是否被激活
static void privop_pasv_active(session_t *sess);
//创建被动模式的监听套接字
static void privop_pasv_listen(session_t *sess);
//被动模式下接收连接
static void privop_pasv_accept(session_t *sess);
响应PORT命令时:
会话结构体中增加成员变量:port_addr:类型是struct sockaddr_in,用来保存客户端的IP地址和端口号。
因为后面在建立数据连接的时候,服务器20端口需要主动connect客户端,由于每次响应命令的时候,之前保存的参数是会被清空,因此需要保存记录客户端的ip地址和端口号。
响应PASV命令时:
1.从配置文件中获取对应的服务端的IP地址。
2.其次产生数据连接的监听套接字,会话结构体中增加成员变量pasv_listenfd。由于监听套接字也是由nobody进程产生的,故FTP服务进程会向nobody进程发送获取监听字的命令请求。
3.将获取到的服务端的ip地址和端口号格式化写入到text数组中,通过ftp_reply函数向客户端回复相应代码。
需要注意的是,这里响应成功并不能代表就可以成功建立数据连接,PORT命令中只是获取到了客户端的ip和port号,同理PASV命令中只是产生了监听套接字,等待客户端来连接。
对应的代码如下所示:
static void do_port(session_t *sess)
{
unsigned int v[6];
// 注意这里是将参数格式化输出到给定的v数组中,因为是无符号的整数,因此使用u来保存。
sscanf(sess->arg,"%u,%u,%u,%u,%u,%u,",&v[0],&v[1],&v[2],&v[3],&v[4],&v[5]);
sess->port_addr=(struct sockaddr_in*)malloc(sizeof(struct sockaddr_in));
unsigned char *p=(unsigned char*)&sess->port_addr->sin_port;
// 指针p是端口号因此指向的是v[4]和v[5]。
p[0]=v[4];
p[1]=v[5];
sess->port_addr->sin_family = AF_INET;
p = (unsigned char *)&(sess->port_addr->sin_addr);
// p指向的是结构体struct sockaddr_in中的port_addr成员中的ip地址。
// p是一个字符指针,可以获取v[0]的低8位,即192。p是字符型指针
p[0] = v[0];
p[1] = v[1];
p[2] = v[2];
p[3] = v[3];
ftp_reply(sess, FTP_PROTOK, "PORT command successful. Consider using PASV.");
}
static void do_pasv(session_t *sess)
{
// ftp服务进程向nobody进程请求需要监听套接字
priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_LISTEN);
// 获取被动模式下的监听字
sess->pasv_listen_fd =priv_sock_get_int(sess->child_fd);
char ip[16] = {0};
// 接收IP
int len=priv_sock_get_int(sess->child_fd);
priv_sock_recv_buf(sess->child_fd,ip,(unsigned int)len);
// 接收PORT
unsigned short port = (unsigned short)priv_sock_get_int(sess->child_fd);
//
unsigned v[4] = {0};
sscanf(ip, "%u.%u.%u.%u", &v[0], &v[1], &v[2], &v[3]);
char text[MAX_BUFFER_SIZE] = {0};
// 这里需要注意:ip地址为32位,端口号为16位二进制组成的,port>>8:获取高8位的端口号
// port&0x00ff:获取低8位的端口号。
sprintf(text, "Entering Passive Mode (%u,%u,%u,%u,%u,%u).",
v[0],v[1],v[2],v[3], port>>8, port&0x00ff);
//227 Entering Passive Mode (192,168,232,10,248,159).
ftp_reply(sess, FTP_PASVOK, text);
}
static void privop_pasv_listen(session_t *sess)
{
char ip[16]="192.168.138.8";
unsigned int v[4]={0};
// sscanf:把从ip中获取的内容,按照格式化输出到v中。
sscanf(ip,"%u.%u.%u.%u", &v[0], &v[1], &v[2], &v[3]);
// 创建监听套接字
int sockfd=tcp_server(ip,0);
sess->pasv_listen_fd = sockfd;
struct sockaddr_in addr;
socklen_t addrlen=sizeof(struct sockaddr);
if(getsockname(sess->pasv_listen_fd,(struct sockaddr*)&addr,&addrlen)<0)
ERR_EXIT("getsockname");
// 获取端口号和IP
// 需要注意的是:需要将网络字节序转换为host字节序哦。
unsigned short port = ntohs(addr.sin_port);
// 发送监听套接字
priv_sock_send_int(sess->parent_fd,sess->pasv_listen_fd);
//发送ip
priv_sock_send_int(sess->parent_fd, strlen(ip));
priv_sock_send_buf(sess->parent_fd, ip, strlen(ip));
//发送port
priv_sock_send_int(sess->parent_fd, (int)port);
}
建立数据连接的过程:
以list命令为例子:
- 主动连接被激活,使用主动模式进行通信
- 被动连接被激活,使用被动模式进行通信
- 服务器响应150代码
- 列表的传输
- 传输结束之后,服务器响应226代码
- 关闭数据连接
定义了get_transfer_fd函数,分为主动模式下的连接和被动模式下的连接。get_transfer_fd函数包含以下三种情况:
- 被动模式和主动模式均没有激活
- 只有主动模式被激活
- 只有被动模式被激活。
- 其他情况(两种模式均被激活)都是错误的。
情况1:获取主动模式下的数据连接套接字:get_port_fd函数:成功建立连接:函数返回0,否则返回1。
1.FTP服务进程向nobody进程发送1个字符的大小:PORT_GET_DATA_SOCK
2.通过之前的内部通讯模块中的send_buf函数,send_int函数来发送客户端ip地址和端口号。
3.nobody服务进程在while循环中不停的等待FTP服务进程发送过来的消息,recv函数会阻塞等待,接收到1个字符的:PROT_GET_DATA_SOCK,运行相关的处理函数
4.nobody进程接收相关的ip地址和端口号。
5.nobody进程创建服务端的数据连接套接字,通过connect函数来主动连接客户端。连接成功或者失败,均发送相关的应答字符。成功的时候,将服务端产生的套接字通过前文中提到的套接字发送函数发送给FTP服务进程即可。
6.FTP服务进程接收到套接字之后,将其存储在会话结构体中的data_fd数据成员中。
7.是否成功建立通过之前定义的nobody进程向FTP服务进程的应答请求来判断。
static void privop_port_get_data_sock(session_t *sess)
{
//ip
char ip[16] = {0};
int len = priv_sock_get_int(sess->parent_fd);
priv_sock_recv_buf(sess->parent_fd, ip, len);
//port
unsigned short port = (unsigned short)priv_sock_get_int(sess->parent_fd);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip);
int sock = tcp_client();
socklen_t addrlen = sizeof(struct sockaddr);
if(connect(sock, (struct sockaddr*)&addr, addrlen) < 0)
{
priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_BAD);
return;
}
priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_OK);
priv_sock_send_fd(sess->parent_fd, sock);
close(sock);
}
情况2:获取被动模式下的数据连接套接字:
和主动模式不同,被动模式下客户端connect服务端,因此只需要发送PORT_SOCK_PASV_ACCEPT请求即可。
对应的代码如下所示:
static void privop_pasv_accept(session_t *sess)
{
int sockConn;
struct sockaddr_in addr;
socklen_t addrlen;
// accept函数会返回一个新的套接字,该套接字是用来进行数据连接的。accept函数的第一个参数是监听套接字。
if((sockConn = accept(sess->pasv_listen_fd, (struct sockaddr*)&addr, &addrlen)) < 0)
{
priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_BAD);
return;
}
// 发送应答请求。
priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_OK);
// 发送数据连接套接字
priv_sock_send_fd(sess->parent_fd, sockConn);
//关闭监听套接字
close(sess->pasv_listen_fd);
sess->pasv_listen_fd = -1;
close(sockConn);
}
对应的获取被动模式的套接字的代码如下所示:
int get_pasv_fd(session_t *sess)
{
priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_ACCEPT);
char res = priv_sock_get_result(sess->child_fd);
if(res == PRIV_SOCK_RESULT_BAD)
return -1;
sess->data_fd = priv_sock_recv_fd(sess->child_fd);
return 0;
}
判断被动模式被激活有两种方法:
方法1:FTP服务进程向nobody进程发送PRIV_SOCK_PASV_ACTIVE请求,父进程产生监听套接字发送给子进程,通过判断套接字对应的文件描述符是否为-1,来判断被动模式是否被激活。
代码如下所示:
ftpproto.c
int pasv_active(session_t *sess)
{
priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_ACTIVE);
int active = priv_sock_get_int(sess->child_fd);
if(active != -1)
{
if(port_active(sess))
ERR_EXIT("both port an pasv are active");
return 1;
}
return 0;
}
privparent.c
static void privop_pasv_active(session_t *sess)
{
int active = -1; //未激活
if(sess->pasv_listen_fd != -1)
active = 1; //激活
priv_sock_send_int(sess->parent_fd, active);
}
方法2:需要注意的是fork之后的两个进程是读时共享,写时复制的原理。如果子进程中的成员变量不进行修改的话,拷贝后的数据指向内存中的同一块物理地址空间,虚拟地址空间是不一样的。如果子进程对父进程的资源进行修改的话,操作系统将为修改之后的资源重新分配物理空间,这样做的好处时可以节省内存,真的是太赞了!因此可以不定义privop_pasv_active这个函数,我们直接在PASV请求中,获取到对应的监听套接字,注意父子进程之间的数据只要发生改变了,它们之间是不会互相影响的,各自有自己的虚拟地址空间,是独立自主的。代码如下所示:获取到了被动模式下的监听套接字。
static void do_pasv(session_t *sess)
{
// ftp服务进程向nobody进程请求需要监听套接字
priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_LISTEN);
// 获取被动模式下的监听字
sess->pasv_listen_fd =priv_sock_get_int(sess->child_fd);
char ip[16] = {0};
// 接收IP
int len=priv_sock_get_int(sess->child_fd);
priv_sock_recv_buf(sess->child_fd,ip,(unsigned int)len);
// 接收PORT
unsigned short port = (unsigned short)priv_sock_get_int(sess->child_fd);
//
unsigned v[4] = {0};
sscanf(ip, "%u.%u.%u.%u", &v[0], &v[1], &v[2], &v[3]);
char text[MAX_BUFFER_SIZE] = {0};
// 这里需要注意:ip地址为32位,端口号为16位二进制组成的,port>>8:获取高8位的端口号
// port&0x00ff:获取低8位的端口号。
sprintf(text, "Entering Passive Mode (%u,%u,%u,%u,%u,%u).",
v[0],v[1],v[2],v[3], port>>8, port&0x00ff);
//227 Entering Passive Mode (192,168,232,10,248,159).
ftp_reply(sess, FTP_PASVOK, text);
}
pasv_active部分代码如下所示:
int pasv_active(session_t *sess)
{
// 监听套接字等于-1;
printf("sess->pasv_listen_fd==%d\n",sess->pasv_listen_fd);
if(sess->pasv_listen_fd != -1)
{
if(port_active(sess)==0)
{
printf("both mode can not be actived in the meantime");
exit(EXIT_FAILURE);
}
return 0;
}
return -1;
}
此外,判断主动模式是否被激活的代码如下所示,思路就是判断会话结构体中的port_addr指针是否为空,如果为空的话,则说明我们没有使用到主动模式的建立,因为给port_addr指向的结构体成员赋值的过程是在PORT命令中完成的。此外,还需要注意主动模式被激活之后,被动模式也不可以被激活,因此这种情况程序也会退出,报错。
int port_active(session_t *sess)
{
if(sess->port_addr)
{
if(pasv_active(sess)==0)
{
printf("both mode can not be actived in the meantime");
exit(EXIT_FAILURE);
}
else
return 0;
}
else
return -1;
}
最终整体的代码如下所示:
static int get_transfer_fd(session_t *sess)
{
//两种模式都没有被激活
if(port_active(sess)== -1 &&pasv_active(sess)== -1)
{
//425 Use PORT or PASV first.
ftp_reply(sess, FTP_BADSENDCONN, "Use PORT or PASV first.");
return -1;
}
// 主动模式被激活之后,需要释放之前malloc的结构体成员。
if(port_active(sess)==0&&get_port_fd(sess)==0)
{
if(sess->port_addr)
{
free(sess->port_addr);
sess->port_addr = NULL;
}
return 0;
}
//被动模式被激活
if(pasv_active(sess)==0&&get_pasv_fd(sess)==0)
{
return 0;
}
else
return -1;
}
list命令对应的代码如下所示:
static void do_list(session_t *sess)
{
//1 创建数据连接
if(get_transfer_fd(sess) != 0)
{
printf("the connection is not succeed\n");
ERR_EXIT("connect_faliure");
}
// printf("the connection is succeed\n");
//2 150
ftp_reply(sess, FTP_DATACONN, "Here comes the directory listing.");
//3 传输列表
list_common(sess);
//4 226
ftp_reply(sess, FTP_TRANSFEROK, "Directory send OK.");
//关闭数据连接
close(sess->data_fd);
sess->data_fd = -1;
}
下一章节将讨论文件的上传,下载,限速等功能的实现,呜呼,写的太累了!