c项目- 实现轻量化FTP服务器I:项目框架及数据连接的建立

FTP协议的介绍

FTP协议又称文件传输协议,它的组成包括两个部分:FTP服务端和FTP客户端,FTP服务器用来存储文件,其中20端口是传输数据的端口,21端口是传输控制信息的端口。

项目简介

本项目主要的功能是实现一个FTP服务器,客户端使用的是leapftp,是一个交互开发的过程,项目具体功能如下

  1. 可以实现FTP内部标准命令:UESR,PASS,PORT,PASV,LIST,STOR等。
  2. 实现配置文件的解析和用户的鉴权登录功能
  3. 实现FTP主动(port)模式和被动(pasv)模式的数据连接的建立
  4. 实现文件的上传下载,续传续载以及限速功能
  5. 实现系统的空闲断开(数据连接断开和控制连接断开)以及系统的连接数限制(客户连接数限制和每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个部分

  1. 蓝色部分为配置文件加载模块,主要是仿照vsftpd中的配置文件,直接在miniftp.conf中修改配置项即可更改FTP相关的参数配置,方便用户操作。

  2. 橙色部分为系统公共模块,其中common.h:包含系统头文件,sysutil.c包含项目所需要的公有函数,str.c模块主要是用来解析命令行参数。

  3. 紫色部分为项目的重点模块,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循环来不断的等待新的连接请求到来。
它的主要流程如下所示:

  1. 加载配置文件miniftp.conf到主进程中。
  2. 检测是否是root启动,普通用户没有权限启动ftp服务端。
  3. 主进程完成和客户端的连接,产生控制连接的套接字sockconn.
  4. 创建子进程,子进程用来开启会话,通过session结构体中的ctrl_fd来获得连接套接字。
  5. 父进程关闭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服务进程模块

对应的步骤如下所示:

  1. 接收客户端发送过来的命令行,利用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);
	}
}
  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},
};

  1. 遍历所有的命令:
    查找服务器接收到的参数是否存储在该结构体内,若存在,则响应相关的命令处理函数。处理完相关的命令之后,服务器向客户端回应响应代码。查找命令响应函数的代码如下所示:
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服务进程的通信协议规定如下:

  1. 由于父子进程之间双方都需要发送和读取数据,所以使用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])
  1. 完成父子进程上下文的设置,由于文件描述符是父子进程共享的,因此需要关闭对方的描述符。
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;
	}
}
  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); //接收套接字

关于发送套接字和接收套接字的详细情况:可以看这篇博文:

传递文件描述符
关于msghdr的详细讲解

实现的内部使用到的函数是sendmsg和recvmsg函数。

ssize_t sendmsg(int sockfd,const msghdr *msg,int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);  

需要对msghdr结构体内部的成员进行初始化,内部的结构比较复杂。有兴趣的同学可以配合上面的博文看一下具体实现的细节。
最终发送套接字实际上将套接字作为辅助数据进行发送的。

  1. 需要注意的是:将需要发送的文件描述符send_fd的长度作为参数传送给CMSG_SPACE,就可以得到cmsghdr整个结构体的大小。
    之后填充msg_control和msg_controllen即可。
  2. 填充cmsghdr结构体的步骤:
    利用宏函数CMSG_FIRSTHDR,将msg的地址传入,既可以得到msghdr指向的第一个cmsghdr结构体的地址,对内部变量cmsg_len,cmsg_level,cmsg_type填充即可。
  3. 使用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命令为例子:

  1. 主动连接被激活,使用主动模式进行通信
  2. 被动连接被激活,使用被动模式进行通信
  3. 服务器响应150代码
  4. 列表的传输
  5. 传输结束之后,服务器响应226代码
  6. 关闭数据连接

定义了get_transfer_fd函数,分为主动模式下的连接和被动模式下的连接。get_transfer_fd函数包含以下三种情况:

  1. 被动模式和主动模式均没有激活
  2. 只有主动模式被激活
  3. 只有被动模式被激活。
  4. 其他情况(两种模式均被激活)都是错误的。

情况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;
}

下一章节将讨论文件的上传,下载,限速等功能的实现,呜呼,写的太累了!

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
目前市面上有很多c语言ftp文件传输库,但大多数都比较臃肿,不太适合嵌入式系统等资源有限的环境下使用。因此,本文介绍一种轻量化ftp文件传输库,可以满足大多数基本需求。 1. 基本功能 该ftp文件传输库支持以下基本功能: - 匿名登录和账户登录 - 文件上传和下载 - 目录创建和删除 - 文件重命名和删除 2. 实现方法 该ftp文件传输库基于TCP/IP协议实现,使用socket编程进行网络通信。具体实现细节如下: - 建立连接 客户端和服务器端通过socket连接进行通信。客户端使用ftp协议的默认端口21连接服务器端,建立控制连接。通过控制连接,客户端可以发送各种ftp指令,服务器端可以返回相应的响应码。 - 登录认证 在建立控制连接后,客户端需要进行登录认证。ftp支持匿名登录和账户登录两种方式。匿名登录只需要提供用户名"anonymous"和密码"guest"即可。账户登录需要提供用户名和密码。登录成功后,服务器端返回响应码"230"。 - 文件传输 客户端和服务器端通过数据连接进行文件传输数据连接可以使用主动模式或被动模式。在主动模式下,客户端向服务器端发起数据连接请求;在被动模式下,服务器端向客户端发起数据连接请求。数据连接可以是二进制模式或ASCII模式。在二进制模式下,文件以原始字节形式传输;在ASCII模式下,文件以文本格式传输,不同操作系统的换行符会被转换为"\r\n"。 - 目录操作 ftp支持目录的创建和删除。客户端可以通过"MKD"指令创建目录,通过"RMD"指令删除目录。目录的重命名需要使用"RNFR"和"RNTO"指令。 - 文件操作 ftp支持文件的上传和下载。客户端可以通过"STOR"指令上传文件,通过"RETR"指令下载文件。文件的重命名需要使用"RNFR"和"RNTO"指令,文件删除需要使用"DELE"指令。 3. 应用场景 该ftp文件传输库适用于嵌入式系统等资源有限的环境下使用。比如,可以用于嵌入式系统的远程升级、数据采集等应用。也可以用于一些轻量级的文件传输应用,比如小型网站的文件上传和下载等。 4. 总结 本文介绍了一种轻量化ftp文件传输库,可以满足大多数基本需求。该传输库采用TCP/IP协议实现,使用socket编程进行网络通信。它支持匿名登录和账户登录,文件上传和下载,目录创建和删除,文件重命名和删除等基本功能。该传输库适用于嵌入式系统等资源有限的环境下使用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值