0%

网络相关知识:FTP协议

FTP 协议简析

FTP(File Transfer Protocol)是 TCP/IP 协议组中的协议之一,FTP 的工作就是完成两台计算机之间的拷贝

在 TCP/IP 协议中, 需要两个端口,一个是数据端口,一个是控制端口

  • 控制端口一般为21,而数据端口不一定是20,这和 FTP 的应用模式有关:
    • 如果是主动模式,应该为20
    • 如果为被动模式,由服务器端和客户端协商而定
  • FTP 协议要用到两个 TCP 连接:
    • 一个是命令链路,用来在 FTP 客户端与服务器之间传递命令
    • 另一个是数据链路,用来上传或下载数据

主动模式与被动模式

FTP 支持两种模式,一种方式叫做 Standard(也就是 PORT 方式,主动方式),一种是 Passive(也就是 PASV,被动方式),下面介绍一个这两种方式的工作原理:

主动 FTP :

  • 命令连接:客户端向服务器的 FTP 端口(默认是21)发送连接请求,服务器接受连接,建立一条命令链路
    • 客户端 >1024 端口 → 服务器 21 端口
  • 数据连接:客户端在命令链路上用 PORT 命令告诉服务器自己开启的端口,于是服务器从自己的 20 端口去连接客户端开启的端口并传输数据
    • 客户端 >1024 端口 ← 服务器 20 端口

被动 FTP :

  • 命令连接:客户端向服务器的 FTP 端口(默认是21)发送连接请求,服务器接受连接,建立一条命令链路
    • 客户端 >1024 端口 → 服务器 21 端口
  • 数据连接:客户端在命令链路上用 PASV 命令告诉服务器使用被动模式,于是服务器开启一个端口专门提供给客户端使用,然后客户端主动连接服务器端口来传输数据
    • 客户端 >1024 端口 → 服务器 > 1024 端口

FTP 服务器的代码实现

FTP 需要实现的功能如下:

  • 读取配置信息
  • 与目标主机建立连接(使用 socket)
  • 实现 FTP 命令集

这里就以轻量级的 FTP 服务器 LightFTP 为例,来学习一下 FTP 服务器的代码实现

LightFTP 读取配置信息

LightFTP 中,使用 config_parse 函数来读取文件信息:

1
2
3
4
5
6
int config_parse(
const char *pcfg,
const char *section_name,
const char *key_name,
char *value,
unsigned long value_size_max)

该函数的参数如下:

  • pcfg:初始化函数 config_init 返回的缓冲区(配置文件 fftp.conf 中的内容被 Read 入其中)
  • section_name:配置段名称,在配置文件 fftp.conf 中被大括号 [] 括起来的内容,用于区分某段配置(初始化配置名称为 ftpconfig
  • key_name:关键字名称,记录有在配置文件 fftp.conf 中写入的关键信息,程序初始化时会把等号 = 后面的内容读入内存
  • value:用于外传数据的指针(程序会把目标 key_name 对应的值写入 value 中)
  • value_size_max:用于表示 value 的最大值

为了理解这些参数,我们需要其他代码进行辅助,就从 config_init 函数开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
char *config_init(char *cfg_filename)
{
int f_config;
char *buffer = NULL;
off_t fsz;

f_config = open(cfg_filename, O_RDONLY);
while (f_config != -1)
{
fsz = lseek(f_config, 0L, SEEK_END) + 1;
lseek(f_config, 0L, SEEK_SET);

buffer = x_malloc(fsz);

fsz = read(f_config, buffer, fsz);
buffer[fsz] = 0;
break;
}

if (f_config != -1)
close(f_config);

return buffer;
}
  • 程序默认的 cfg_filename 就是 fftp.conf
  • 这个函数的功能就是把配置文件读取到本地内存 buffer 中,然后返回 buffer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (argc > 1)
cfg = config_init(argv[1]);
else
cfg = config_init(CONFIG_FILE_NAME);

......

g_cfg.BindToInterface = inet_addr("127.0.0.1");
if (config_parse(cfg, CONFIG_SECTION_NAME, "interface", textbuf, bufsize))
g_cfg.BindToInterface = inet_addr(textbuf);

g_cfg.ExternalInterface = inet_addr("0.0.0.0");
if (config_parse(cfg, CONFIG_SECTION_NAME, "external_ip", textbuf, bufsize))
g_cfg.ExternalInterface = inet_addr(textbuf);
  • config_init 的返回值,将会放入 config_parse 中(作为第一个参数 pcfg

接下来看一看配置文件 fftp.conf 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[ftpconfig]
port=21 /* 要将服务器绑定到的端口号 */
maxusers=10 /* 与服务器的最大连接数,可同时建立 */
interface=0.0.0.0 /* 要绑定到的接口IP,使用0.0.0.0侦听任何可用接口,默认值:127.0.0.1 */
local_mask=255.255.255.255 /* 本地网络的IP掩码,这将有助于服务器区分本地客户端和互联网客户端,默认值:255.255.255.0 */

minport=30000 /* 数据连接的端口范围,您可以使用它在网关设备上配置端口转发 */
maxport=60000

goodbyemsg=Goodbye!
keepalive=1 /* 发送激活数据包(某些NAT可能需要这样做) */

[anonymous]
pswd=* /* 表示“任何密码都匹配” */
accs=readonly /* 不允许登录 */
root=/server/data/

[uploader]
pswd=Weakuploaderpassword111
accs=upload
root=/home/user/ftpshare

[webadmin]
pswd=VeryStrongadminpassword222
accs=admin
root=/home/user/ftpshare
  • 大括号容就是 section_name
  • 等号左边的内容就是 key_name,右边的内容就是将要被设置的值

使用函数 config_parse 需要指定 section_namekey_name,该函数会把对应的值提取到参数 value 中,然后外传到上层函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
int config_parse(
const char *pcfg,
const char *section_name,
const char *key_name,
char *value,
unsigned long value_size_max)
{
unsigned long sp;
char vname[256], *p = (char *)pcfg;

if (value_size_max == 0)
return 0;
--value_size_max;

while (*p != 0)
{
p = skip_comments_and_blanks(p);
if (*p == 0)
break;
if (*p != '[')
{
++p;
continue;
}
++p;
sp = 0;
while (
(*p != ']') &&
(*p != 0) &&
(*p != '\n') &&
(sp < 255)
)
{
vname[sp] = *p;
++sp;
++p;
}
vname[sp] = 0;
if (*p == 0)
break;
if (*p == '\n')
continue;
++p;
if (strcmp(vname, section_name) == 0)
{
do {
p = skip_comments_and_blanks(p);
if ((*p == 0) || (*p == '['))
break;
sp = 0;
while (
(*p != '=') &&
(*p != ' ') &&
(*p != 0) &&
(*p != '\n') &&
(sp < 255)
)
{
vname[sp] = *p;
++sp;
++p;
}
vname[sp] = 0;
if (*p == 0)
break;
while (*p == ' ')
++p;
if (*p != '=')
break;
p++;
if (strcmp(vname, key_name) == 0)
{
sp = 0;
while (
(*p != '\n') &&
(*p != 0)
)
{
if (sp < value_size_max)
value[sp] = *p;
else
return 0;
++sp;
++p;
}
value[sp] = 0;
return 1;
}
else
{
while (
(*p != '\n') &&
(*p != 0)
)
++p;
}
} while (*p != 0);
}
else
{
do {
p = skip_comments_and_blanks(p);
if ((*p == 0) || (*p == '['))
break;
while (
(*p != '\n') &&
(*p != 0)
)
++p;
} while (1);
}
}
return 0;
}

函数 config_parse 总体的思路就是利用2个 while 循环来变量所有的 section_namekey_name

  • 外层的循环会匹配大括号 [],用一个 while 读取出 section_name 然后进行匹配:
1
if (strcmp(vname, section_name) == 0)
  • 内层的循环会匹配等号 =,用一个 while 读取出 key_name 然后进行匹配:
1
if (strcmp(vname, key_name) == 0)
  • 最后用一个 while 把等号右边的数据存储到 value 中:
1
2
3
4
5
6
7
8
9
while ((*p != '\n') &&(*p != 0)) {
if (sp < value_size_max)
value[sp] = *p;
else
return 0;
++sp;
++p;
}
value[sp] = 0;

函数 config_parse 最后返回的 value 将被会存储在结构体 FTP_CONFIG 中:

1
2
3
4
5
6
7
8
9
10
11
typedef struct _FTP_CONFIG {
char* ConfigFile;
unsigned int MaxUsers;
unsigned int EnableKeepalive;
in_port_t Port;
in_port_t PasvPortBase;
in_port_t PasvPortMax;
in_addr_t BindToInterface;
in_addr_t ExternalInterface;
in_addr_t LocalIPMask;
} FTP_CONFIG, *PFTP_CONFIG;

LightFTP 与目标主机建立连接

把服务器中 socket bind listen 的代码提取出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    ftpsocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); /* 基于IPv4和TCP */
if ( ftpsocket == INVALID_SOCKET )
{
printf("\r\n socket create error\r\n");
return 0;
}

rv = 1;
setsockopt(ftpsocket, SOL_SOCKET, SO_REUSEADDR, &rv, sizeof(rv));
/* SO_REUSEADDR:打开或关闭地址复用功能 */

scb = (SOCKET *)x_malloc(sizeof(SOCKET)*g_cfg.MaxUsers);
for (i = 0; i<g_cfg.MaxUsers; i++)
scb[i] = INVALID_SOCKET; /* 初始化为'-1' */

memset(&laddr, 0, sizeof(laddr));
laddr.sin_family = AF_INET;
laddr.sin_port = htons(g_cfg.Port);
laddr.sin_addr.s_addr = g_cfg.BindToInterface;
socketret = bind(ftpsocket, (struct sockaddr *)&laddr, sizeof(laddr));
if ( socketret != 0 ) {
printf("\r\n Failed to start server. Can not bind to address\r\n\r\n");
free(scb);
close(ftpsocket);
return 0;
}

writelogentry(NULL, success220, "");

socketret = listen(ftpsocket, SOMAXCONN); /* socket的排队个数为SOMAXCONN,内核规定的最大连接数 */
  • setsockopt 函数可以对 socket 进行详细的设置
  • SO_REUSEADDR 参数提供如下四个功能:
    • SO_REUSEADDR 允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将此端口用做他们的本地端口的连接仍存在(这通常是重启监听服务器时出现,若不设置此选项,则 bind 时将出错)
    • SO_REUSEADDR 允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地 IP 地址即可(对于 TCP,我们根本不可能启动捆绑相同 IP 地址和相同端口号的多个服务器)
    • SO_REUSEADDR 允许单个进程捆绑同一端口到多个套接口上,只要每个捆绑指定不同的本地 IP 地址即可。这一般不用于 TCP 服务器
    • SO_REUSEADDR 允许完全重复的捆绑:当一个 IP 地址和端口绑定到某个套接口上时,还允许此 IP 地址和端口捆绑到另一个套接口上(一般来说,这个特性仅在支持多播的系统上才有,而且只对UDP套接口而言,TCP不支持多播)

然后再循环里面 accept 客户端的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
while ( socketret == 0 ) {
memset(&laddr, 0, sizeof(laddr));
asz = sizeof(laddr);
clientsocket = accept(ftpsocket, (struct sockaddr *)&laddr, &asz);
/* 一次大循环连接一台客户端主机 */

if (clientsocket == INVALID_SOCKET) /* 没有accept时则直接continue */
continue;

rv = -1;
for (i=0; i<g_cfg.MaxUsers; i++)
if ( scb[i] == INVALID_SOCKET ) {

if (g_cfg.EnableKeepalive != 0)
socket_set_keepalive(clientsocket);
/* 调用setsockopt对socket进行设置 */

scb[i] = clientsocket;
rv = pthread_create(&th, NULL, (void * (*)(void *))ftp_client_thread, &scb[i]);
if ( rv != 0 ) /* pthread_create返回'0'代表创建成功 */
scb[i] = INVALID_SOCKET;

break;
}

if ( rv != 0 ) {
sendstring_plaintext(clientsocket, NOSLOTS);
close(clientsocket);
}
}
  • g_cfg.MaxUsers:服务器可以并发处理的客户端最大数目

LightFTP 实现 FTP 命令集

实现 FTP 命令集的函数就是 ftp_client_thread

在此之前需要介绍一下 LightFTP 中的一个结构体数组:

1
2
3
4
5
6
7
8
9
10
static const FTPROUTINE_ENTRY ftpprocs[MAX_CMDS] = {
{"USER", ftpUSER}, {"QUIT", ftpQUIT}, {"NOOP", ftpNOOP}, {"PWD", ftpPWD },
{"TYPE", ftpTYPE}, {"PORT", ftpPORT}, {"LIST", ftpLIST}, {"CDUP", ftpCDUP},
{"CWD", ftpCWD }, {"RETR", ftpRETR}, {"ABOR", ftpABOR}, {"DELE", ftpDELE},
{"PASV", ftpPASV}, {"PASS", ftpPASS}, {"REST", ftpREST}, {"SIZE", ftpSIZE},
{"MKD", ftpMKD }, {"RMD", ftpRMD }, {"STOR", ftpSTOR}, {"SYST", ftpSYST},
{"FEAT", ftpFEAT}, {"APPE", ftpAPPE}, {"RNFR", ftpRNFR}, {"RNTO", ftpRNTO},
{"OPTS", ftpOPTS}, {"MLSD", ftpMLSD}, {"AUTH", ftpAUTH}, {"PBSZ", ftpPBSZ},
{"PROT", ftpPROT}, {"EPSV", ftpEPSV}, {"HELP", ftpHELP}, {"SITE", ftpSITE}
};
  • 乍一看这个结构有点像 Python 中的字典(用于把 “FTP 命令名称” 和 “对应的函数指针” 绑定)
  • 但这其实是 FTPROUTINE_ENTRY 类型的数组
1
2
3
4
typedef struct _FTPROUTINE_ENTRY {
const char* Name;
FTPROUTINE Proc;
} FTPROUTINE_ENTRY, *PFTPROUTINE_ENTRY;

使用这个结构体数组的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
while ( ctx.ControlSocket != INVALID_SOCKET ) {
if ( !recvcmd(&ctx, rcvbuf, sizeof(rcvbuf)) ) /* 从客服端中读取数据 */
break;

i = 0;
while ((rcvbuf[i] != 0) && (isalpha(rcvbuf[i]) == 0)) /* 检查输入值是否为字母,并用while跳过所有的非字母 */
++i;

cmd = &rcvbuf[i]; /* 确定ftp命令的起始地址 */
while ((rcvbuf[i] != 0) && (rcvbuf[i] != ' ')) /* 用while跳过所有的非空格(方便计算出ftp命令的长度) */
++i;

cmdlen = &rcvbuf[i] - cmd;
while (rcvbuf[i] == ' ')
++i;

if (rcvbuf[i] == 0)
params = NULL;
else
params = &rcvbuf[i];

cmdno = -1;
rv = 1;
for (c=0; c<MAX_CMDS; c++)
if (strncasecmp(cmd, ftpprocs[c].Name, cmdlen) == 0)
{ /* 遍历数组ftpprocs中所有的ftp命令,并执行目标命令 */
cmdno = c;
rv = ftpprocs[c].Proc(&ctx, params);
break;
}

if ( cmdno != FTP_PASSCMD_INDEX )
writelogentry(&ctx, " @@ CMD: ", rcvbuf);
else
writelogentry(&ctx, " @@ CMD: ", "PASS ***");

if ( cmdno == -1 )
sendstring(&ctx, error500);

if ( rv <= 0 )
break;
};
  • 一个简单的遍历匹配

最后单独介绍几个 FTP 命令的实现:

  • 匹配密码和登录设置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
int ftpPASS(PFTPCONTEXT context, const char *params)
{
char temptext[256];

if ( params == NULL )
return sendstring(context, error501);

memset(temptext, 0, sizeof(temptext));

/*
* we have login name saved in context->FileName from USER command
*/
if (!config_parse(g_cfg.ConfigFile, context->FileName, "pswd", temptext, sizeof(temptext))) /* 提取密码 */
return sendstring(context, error530_r);

if ( (strcmp(temptext, params) == 0) || (temptext[0] == '*') )
{ /* 密码匹配成功,或者密码设置为'*' */
memset(context->RootDir, 0, sizeof(context->RootDir));
memset(temptext, 0, sizeof(temptext));

config_parse(g_cfg.ConfigFile, context->FileName, "root", context->RootDir, sizeof(context->RootDir)); /* 提取根目录路径 */
config_parse(g_cfg.ConfigFile, context->FileName, "accs", temptext, sizeof(temptext)); /* 提取登录设置 */

context->Access = FTP_ACCESS_NOT_LOGGED_IN;
do {

if ( strcasecmp(temptext, "admin") == 0 ) {
/* 登录设置为"admin":启用所有功能 */
context->Access = FTP_ACCESS_FULL;
break;
}

if ( strcasecmp(temptext, "upload") == 0 ) {
/* 登录设置为"upload":
允许:创建新目录,存储新文件附加
禁用:重命名,删除 */
context->Access = FTP_ACCESS_CREATENEW;
break;
}

if ( strcasecmp(temptext, "readonly") == 0 ) {
/* 登录设置为"readonly":只需读取目录和下载文件 */
context->Access = FTP_ACCESS_READONLY;
break;
}

return sendstring(context, error530_b);
} while (0);

writelogentry(context, " PASS->successful logon", "");
}
else
return sendstring(context, error530_r);

return sendstring(context, success230);
}
  • 列出目标目录中所有的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int ftpLIST(PFTPCONTEXT context, const char *params)
{
struct stat filestats;
pthread_t tid;

if (context->Access == FTP_ACCESS_NOT_LOGGED_IN)
return sendstring(context, error530);
if (context->WorkerThreadValid == 0)
return sendstring(context, error550_t);

if (params != NULL)
{
if ((strcmp(params, "-a") == 0) || (strcmp(params, "-l") == 0))
params = NULL;
}

ftp_effective_path(context->RootDir, context->CurrentDir, params, sizeof(context->FileName), context->FileName); /* 通过设置的根目录来获取目标目录的绝对路径(结果存放于context->FileName) */

while (stat(context->FileName, &filestats) == 0)
{ /* 使用stat函数获取文件信息(已经获取了文件的绝对路径) */
if ( !S_ISDIR(filestats.st_mode) ) /* filestats.st_mode是否是一个目录 */
break;

sendstring(context, interm150);
writelogentry(context, " LIST", (char *)params);
context->WorkerThreadAbort = 0;

pthread_mutex_lock(&context->MTLock);

context->WorkerThreadValid = pthread_create(&tid, NULL, (void * (*)(void *))list_thread, context);
/* 设置一个线程
用于完成:打开目录,读取文件信息,组织输出等一系列工作 */
if ( context->WorkerThreadValid == 0 )
context->WorkerThreadId = tid;
else
sendstring(context, error451);

pthread_mutex_unlock(&context->MTLock);

return 1;
}

return sendstring(context, error550);
}
  • 从服务器上下载目标文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int ftpRETR(PFTPCONTEXT context, const char *params)
{
struct stat filestats;
pthread_t tid;

if (context->Access == FTP_ACCESS_NOT_LOGGED_IN)
return sendstring(context, error530);
if (context->WorkerThreadValid == 0)
return sendstring(context, error550_t);
if ( params == NULL )
return sendstring(context, error501);

if ( context->File != -1 ) {
close(context->File);
context->File = -1;
}

ftp_effective_path(context->RootDir, context->CurrentDir, params, sizeof(context->FileName), context->FileName); /* 获取目标文件的绝对路径 */

while (stat(context->FileName, &filestats) == 0)
{ /* 获取该文件的状态 */
if ( S_ISDIR(filestats.st_mode) )
break;

sendstring(context, interm150);
writelogentry(context, " RETR: ", (char *)params);
context->WorkerThreadAbort = 0;

pthread_mutex_lock(&context->MTLock);

context->WorkerThreadValid = pthread_create(&tid, NULL, (void * (*)(void *))retr_thread, context); /* 设置一个线程来完成下载操作 */
if ( context->WorkerThreadValid == 0 )
context->WorkerThreadId = tid;
else
sendstring(context, error451);

pthread_mutex_unlock(&context->MTLock);

return 1;
}

return sendstring(context, error550);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
f = open(context->FileName, O_RDONLY); /* 打开目标文件 */
context->File = f;
if (f == -1)
break;

offset = lseek(f, context->RestPoint, SEEK_SET); /* 重新设置文件指针(如果文件过大则需要分多次进行传输) */
if (offset != context->RestPoint)
break;

sent_ok = 1;
while ( context->WorkerThreadAbort == 0 ) {
sz = read(f, buffer, buffer_size); /* 把文件内容读取到本地内存 */
if (sz == 0)
break;

if (sz < 0)
{
sent_ok = 0;
break;
}

if (send_auto(clientsocket, TLS_datasession, buffer, sz) == sz)
{ /* 发送数据到客户端 */
sz_total += sz;
}
else
{
sent_ok = 0;
break;
}
}