miniFTP项目实战三
项目简介:
在Linux环境下用C语言开发的Vsftpd的简化版本,拥有部分Vsftpd功能和相同的FTP协议,系统的主要架构采用多进程模型,每当有一个新的客户连接到达,主进程就会派生出一个ftp服务进程来为客户提供服务。同时每个ftp服务进程配套了nobody进程(内部私有进程),主要是为了做权限提升和控制。
实现功能:
除了基本的文件上传和下载功能,还实现模式选择、断点续传、限制连接数、空闲断开、限速等功能。
用到的技术:
socket、I/O复用、进程间通信、HashTable
欢迎技术交流q:2723808286
项目开源!!!
miniFTP项目实战一
miniFTP项目实战二
miniFTP项目实战三
miniFTP项目实战四
miniFTP项目实战五
miniFTP项目实战六
3.1 服务进程处理FTP命令
服务进程负责处理FTP命令,包括对FTP命令的解析和调用对应的操作函数,服务进程在会话创建之后进入handle_child函数,在这个函数中,不断接收来自客户端的命令,并且解析命令,根据命令调用相关操作函数:
/*
* 循环从客户端接收数据,并解析命令和参数
*/
void handle_child(session_t *sess)
{
int ret, i = 0;
//连接成功时,向客户端发送220命令主要是命令的格式:220后面加一个空格
ftp_relply(sess, FTP_GREET, "(GQ_miniFTP 0.1)");
while (1) {
//只初始化命令相关信息
memset(sess->cmdline, 0, MAX_COMMAND_LINE);
memset(sess->cmd, 0, MAX_COMMAND);
memset(sess->arg, 0, MAX_ARG);
start_cmdio_alarm(); //处理完之后 重新设置闹钟,进行下一次计时
ret = readline(sess->ctl_fd, sess->cmdline, MAX_COMMAND_LINE); //读取命令
if (ret == -1) { //出错
ERR_EXIT("readline");
} else if (ret == 0) { //客户端断开连接,关闭服务进程,nobody进程暂时没有关闭
exit(EXIT_SUCCESS);
}
//解析处理FTP标准命令参数 开头是命令,空格之后的是参数, 所以要分割字符串
str_trim_crlf(sess->cmdline); //去除\r\n
str_split(sess->cmdline, sess->cmd, sess->arg, ' '); //分隔字符串提取cmd arg
str_upper(sess->cmd); //统一为大写字母
printf("%s=%s\n", sess->cmd, sess->arg);
//遍历命令映射 处理命令
int size = sizeof(ctrl_cmds_map) / sizeof(ctrl_cmds_map[0]);
for (i = 0; i < size; ++i) {
if (strcmp(ctrl_cmds_map[i].cmd, sess->cmd) == 0) {
if (ctrl_cmds_map[i].cmd_func != NULL) {
ctrl_cmds_map[i].cmd_func(sess); //调用相应操作函数
} else {
ftp_relply(sess, FTP_COMMANDNOTIMPL, "command Unimplement.");
}
break;
}
}
if (i == size) ftp_relply(sess, FTP_BADCMD, "command Unkonwn.");
}
}
3.2 配置文件读取
vsftp都有一个配置文件,用来设置FTP服务器在连接过程中的各项参数。如下:
pasv_enable=true
port_enable=yes
listen_port=5021
max_clients=3
max_per_ip=2
accept_timeout=60
connect_timeout=60
idle_session_timeout=300
data_connection_timeout=900
local_umask=077
upload_max_rate=10240
download_max_rate=102400
listen_address=192.168.3.15
配置文件中读取配置配置项的值,代码中通过读取配置项的值来判断,配置项分为三类:
-
开关型的配置项可以用int来表示
-
整数参数的配置项可以用unsigned int
-
字符串类型配置项目 可以const char*
所以我们要分别建立三种对应关系,每种关系都用一个表格来表示,如下:
static struct parseconf_bool_setting
{
const char *p_setting_name;
int *p_variable;
};
struct parseconf_bool_setting parseconf_bool_array[] =
{
{ "pasv_enable", &tunable_pasv_enable },
{ "port_enable", &tunable_port_enable },
{ NULL, NULL }
};
static struct parseconf_uint_setting {
const char *p_setting_name;
unsigned int *p_variable;
};
struct parseconf_uint_setting 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 }
};
static struct parseconf_str_setting {
const char *p_setting_name;
const char **p_variable;
};
struct parseconf_str_setting parseconf_str_array[] = {
{ "listen_address", &tunable_listen_address },
{ NULL, NULL }
};
将配置项名称(字符串)与配置项的值放在一个结构体中,每种数据类型的配置项都建立一个结构体数组来存储,配置文件的相关配置项。
在初始化的时候,就将配置选项字符串写进去
配置 配置文件的时候,使用两个接口:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MqCfLGHQ-1613482775198)(F:\destop\m笔记\图\image-20210205084817145.png)]
实现
先定义配置项名称,都是以字符串的形式放在tunable中,.c定义 .h声明,这些配置项都是要有初始值的。
然后我们自己创建一个配置文件。
然后编写代码去读取这个配置文件,读取相应的配置项。配置文件中配置项前后不要有空格和分号以方便读取,然后将此文件暂时放在程序目录下。
void parseconf_load_file(const char *path); //加载配置文件,读出每一项配置
void parseconf_load_setting(const char *setting); //对配置项进行解析,写入配置变量
如下:
//加载配置文件,逐行读取配置信息,对文件的解析
void parseconf_load_file(const char *path)
{
FILE *fp;
char setting_line[1024] = {0};
fp = fopen(path, "r");
if (fp == NULL) {
ERR_EXIT("confg open filed");
}
//循环读取配置文件中选项,写入相应变量
while (fgets(setting_line, 1023, fp) != NULL) {
if (strlen(setting_line) == 0 //未读取到
|| setting_line[0] == '#' //注释的指令
|| str_all_space(setting_line) == 1) //全是空格
continue;
str_trim_crlf(setting_line); //去除\r\n
parseconf_load_setting(setting_line); //将配置文件中的选项加载到相应变量中
// memset(setting_line, 0, strlen(setting_line)); strlen不行,因为strlen是以结束符\0为标志的,上面去除\r\n没了
memset(setting_line, 0, sizeof(setting_line));
}
fclose(fp);
}
//将配置文件加载到相应的配置项,对配置项的解析
//遍历三张配置信息表,将配置信息放入相应的变量中
void parseconf_load_setting(const char *setting)
{
char key[128] = {0};
char val[128] = {0};
while (isspace(*setting)) { //去除左边可能存在的空格
++setting;
}
str_split(setting, key, val, '=');
if (strlen(val) == 0) { //无配置内容,出错提示
fprintf(stderr, "missing value in config file for:%s", key);
exit(EXIT_FAILURE);
}
//字符串配置选项读取
const struct parseconf_str_setting *p_str_setting = parseconf_str_array;
while (p_str_setting->p_setting_name != NULL) {
if (strcmp(p_str_setting->p_setting_name, key) == 0) {
const char **p_cur_setting = p_str_setting->p_variable; //
if (*p_cur_setting != NULL) {
free((char*)p_cur_setting);
}
//申请一块内存用来存放字符串,因为之前只是一个二级指针
*p_cur_setting = strdup(val); //malloc+strcpy
return ;
}
++p_str_setting;
}
free(*(char*)p_str_setting);
//布尔值配置选项读取
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) {
str_upper(val);
if (strcmp(val, "TRUE") == 0
|| strcmp(val, "YES") == 0
|| strcmp(val, "1") == 0 ) {
*p_bool_setting->p_variable = true;
}
else if (strcmp(val, "FALSE") == 0
|| strcmp(val, "NO") == 0
|| strcmp(val, "0") == 0 ) {
*p_bool_setting->p_variable = false;
} else {
fprintf(stderr, "bad bool value in config file for: %s\n", key);
exit(EXIT_FAILURE);
}
return ;
}
++p_bool_setting;
}
//整数配置选项读取
//遍历unint表中的配置选项
const struct parseconf_uint_setting *p_uint_setting = parseconf_uint_array;
while (p_uint_setting->p_setting_name != NULL) {
if (strcmp(p_uint_setting->p_setting_name, key) == 0) {
if (val[0] == '0')
*(p_uint_setting->p_variable) = str_octal_to_uint(val);
else
*(p_uint_setting->p_variable) = atoi(val);
return ;
}
++p_uint_setting;
}
}
3.3用户登录验证
当客户端建立控制连接后,要进行用户登录验证,先确定用户是否存在,然后确定密码是否正确,流程如下:
服务进程在接收到USER命令之后解析出用户名,根据用户名,通过getpwnam获取用户的相关信息,如果用户不存在getpwnam返回NULL,如果用户存在,保存uid,以便下面进行密码验证:
static void do_user(session_t *sess)
{
//命令响应 后面记得加上\r\n 330后面记得加上空格
struct passwd *pw;
pw = getpwnam(sess->arg); //根据用户名获取密码信息结构体 与/etc/passwd对应
if (pw == NULL) { //用户不存在
ftp_relply(sess, FTP_LOGINERR, "user not exist.");
return ;
}
sess->uid = pw->pw_uid;
ftp_relply(sess, FTP_GIVEPWORD, "Please specify the password");
}
接下来就是密码的验证了!!!
用户存在后,客户端会发送密码,此时进入do_pass操作函数,但是!!!这里解析出来的只是密码,并没有说明是哪一个用户的!!!
这时候就用到上面放在sess中的uid了,通过sess的uid就可以锁定用户,进行密码验证了。getpwuid函数可以通过uid获取passwd结构体,在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 */
};
可以看到结构体中有pw_passwd,那意味着直接比较解析出来的密码与pw_passwd吗???
不是的,实际密码是经过加密放在影子文件中的,可以通过getspnam来获取影子文件的相关信息(root用户),其函数声明如下:
#include <shadow.h>
struct spwd *getspnam(const char *name);
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 expires to warn user to change it */
long sp_inact; /* # of days after password expires until account is disabled */
long sp_expire; /* Date when account expires (measured in days since 970-01-01 00:00:00 +0000 (UTC)) */
unsigned long sp_flag; /* Reserved */
};
加密后的密码放在sp_pwdp中,我们只需要将解析出来的密码进行加密,然后与sp_pwdp比较就可以知道密码是否正确了,通过crypt函数对密码进行加密,函数原型如下,使用crypt函数之后要在链接的时候加上-lcrypt
:
char *crypt(const char *key, const char *salt);
//crypt()算法会接受一个最长可达8字符的密钥(即key),并施以数据加密算法(DES)的一种变体。salt参数指向一个两个字符的字符串,
//用来改变DES算法。该函数返回一个指针,指向长度13个字符的字符串
char* encrypted_pass = crypt(sess->arg, sp->sp_pwdp); //获取加密后的密码
通过比较加密获得的密码,与影子文件中的密码,就可以知道密码是否正确。
完整的验证过程如下:
static void do_pass(session_t *sess)
{
struct passwd *pw;
struct spwd *sp;
pw = getpwuid(sess->uid);
if (pw == NULL) { //用户不存在
ftp_relply(sess, FTP_LOGINERR, "user not exist.");
return ;
}
//实际密码是保存在影子文件中,getspnam 可以根据用户名获取影子文件信息
//如下的操作只有root才可以,一般用户会返回NULL,所以在session中只将nobody进程中才设置uid
sp = getspnam(pw->pw_name);
if (sp == NULL) {
ftp_relply(sess, FTP_LOGINERR, "user not exist."); //首次运行的时候出错
return ;
}
//影子文件中的密码是加密之后的,所以要将明文密码进行加密,与影子文件中加密密码比较使用crypt()函数
//char *crypt(const char *key, const char *salt); 第一个参数是明文,第二个参数是种子(也就是加密过的密码)
char* encrypted_pass = crypt(sess->arg, sp->sp_pwdp); //链接的时候-lcrypt
if (strcmp(encrypted_pass, sp->sp_pwdp) == 0) { //密码正确
signal(SIGURG, handle_sigurg);
activate_sigurg(sess->ctl_fd); //开启接收信号
//验证之后,此时进程拥有者是root 要将进程转交给登录的用户
umask(tunable_local_umask);
setegid(pw->pw_gid);
seteuid(pw->pw_uid);
chdir(pw->pw_dir);
ftp_relply(sess, FTP_LOGINOK, "Login successful.");
} else {
ftp_relply(sess, FTP_LOGINERR, "err password.");
}
}
验证成功之后,此时的服务进程还是属于root用户,我们需要将进程转交给登录的用户,即改变进程的uid、gid,将工作目录移动到当前用户目录。
3.4 nobody进程与服务进程的内部通信
nobody进程与服务进程之间通过socketpair产生的socket进行通信,如下:
// 内部进程自定义协议
// 用于FTP服务进程和nobody进程进行通信
//主要用于PASV模式下绑定20端口 和PORT模式下获取数据连接套接字
// 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_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);
//其中cmd就是上面的宏定义 nobody进程与服务进程之间就通过下面的函数通信
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);
内部通信初始化就是创建一对socket然后分配给sess,父子进程要分别关闭对方的socket:
void priv_sock_init(session_t *sess)
{
int sockfds[2]; //分别是父、子进程用到的socketfd
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockfds) < 0)
ERR_EXIT("session socketpair");
sess->parent_fd = sockfds[0];
sess->child_fd = sockfds[1];
}
nobody进程执行handle_parent函数来不断接收服务进程的消息:
void handle_parent(session_t *sess)
{
char cmd;
/* 以root用户启动的时候 gid、uid都是0,所以要获取用户登录相关信息 */
struct passwd *pw = getpwnam("nobody"); //获取用户登录相关信息
if (setegid(pw->pw_gid) < 0) ERR_EXIT("session setegid"); //先设置组ID,然后设置用户ID
if (seteuid(pw->pw_uid) < 0) ERR_EXIT("session seteuid");
minimize_privilege(); //获取绑定20端口权限
while (1) {
//从服务进程读取信息,这里的命令不是FTP标准命令,而是内部命令,nobody进程与客户端之间才是FTP标准命令
// read(sess->parent_fd, &cmd, 1);
cmd = priv_sock_get_cmd(sess->parent_fd);
//解析FTP内部命令参数
switch (cmd)
{
case PRIV_SOCK_GET_DATA_SOCK:
privop_pasv_get_data_sock(sess);
break;
case PRIV_SOCK_PASV_ACTIVE:
privop_pasv_active(sess);
break;
case PRIV_SOCK_PASV_LISTEN:
privop_pasv_listen(sess);
break;
case PRIV_SOCK_PASV_ACCEPT:
privop_pasv_accept(sess);
break;
default:
break;
}
}
}