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用户登录验证

当客户端建立控制连接后,要进行用户登录验证,先确定用户是否存在,然后确定密码是否正确,流程如下:
image-20210216143205749

服务进程在接收到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;
        }
    }
}
posted @ 2021-02-16 22:07  Aspirant-GQ  阅读(74)  评论(0编辑  收藏  举报