具有最少安全功能的CLIENT-SERVER通信协议

实验要求

实现一个简单的 UDP 客户端-服务器应用程序,在客户端使用密码成功登录后,服务器将文件传输到客户端。

要求:您必须在两个不同的文件中实现两段代码: 客户端和服务器。下面是协议规范,它将为您提供实现这两个程序所害的详细信息。此外,我们还提供了数据包格式,它指定了客户端和服务器将交换的消息的内容。即使客户端和服务器运行在具有不同 endian 格式的体系结构上,您的实现也必须正确工作。

协议规范:

  1. 客户端发送JOIN REQ 分组以发起与服务器的通信。
  2. 服务器以 PASS REQ 数据包进行响应,该数据包是对用户的密码请求。
  3. 客户端将向服务器发送 PASS RESP 包,其中包括密码。
  4. 服条器将验证密码,如果密码正确,服务器将向客户端发送 PASS ACCEPT 数据包。
  5. 如果密码不正确,服务器将再次向客户端发送 PASS REQ 包。PASS REQ 数据包将最多重发三次。第三次之后,服务器向客户端发送拒绝消息。客户端关闭会话,服务器也退出。
  6. 一旦服务器向客户端发送 PASS ACCEPT 包,服务器就开始使用 DATA 包发送文件。文件被分成几个段(取决于文件的大小),每个段都使用 DATA 数据包传输。
  7. 当服务器完成发送文件时,它将发送一个 TERMINATE 数据包,该数据标志着文件下载的结束。此数据包中包含一个文件摘要 (SHA1 摘要) ,客户端将使用它来验证接收到的文件的完整性。

背景知识

UDP(UserDatagramProtocol)是一个简单的面向消息的传输层协议,它不保证向上层协议提供消息传递,并且UDP层在发送后不会保留UDP 消息的状态。因此,UDP被称为不可靠的数据报协议。

UDP的优点:简单,轻量化。

UDP的缺点:没有流控制,没有应答确认机制,不能解决丢包、重发、错序问题。

亮点

  1. 本次实验实现了从数据库中读取密码,使得客户端和服务器准确连接。
  2. 实验里修改了数据包结构,让整体实验更加清晰。

UDP通讯部分

整体流程
用draw.io画的流程图

过程:

首先Server端先连接数据库,获取可以成功连接到它的密码,并且存储下来,然后建立SOCKET连接,等待Client端的接入;Client建立SOCKET后,向Server端发送JOIN_REQ数据包,以表明它的加入;Server端成功接收到JOIN_REQ数据包后返回PASS_RESP数据包,以表示它可以成功接收到Client端发来的消息,并做出回复;Client端收到这个数据包后,即开始身份的验证,它会发送携带密码的PASS_RESP数据包给Server端,并等待Server端返回的验证消息:若Server端返回了PASS_ACCEPT,即表示密码正确,可以继续发送文件;若Server端返回了PASS_REQ,表明Client端发送的密码不正确但是还有机会,此时Client端可以继续尝试别的密码;若Server端返回了REJECT,说明Client端已经发送了三次错误的密码,此时Server会断开连接,所以收到REJECT数据包的Client也会断开连接。

密码正确后,Server端就会向Client传输文件,Server端首先会计算文件的大小,然后切割成若干数据包,以DATA数据包传输给Client端,全部传输完成后,会发送一个TERMINATE数据包表示传输完成,并且里面携带了传输文件的SHA-1摘要;Client端收到TERMINATE数据包后,会读取接收文件并计算其SHA-1摘要与TERMINATE数据包中携带的摘要值进行比对,若相同则代表文件传输成功。

实验配置

本次实验采用Windows10+Visual Studio 2019+MYSQL 8.0.18+QT,使用Visual Studio 2019连接MYSQL数据库时,需要安装以下驱动:

驱动

实验代码

Client端:

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
cout << "==================Client===================" << endl;
cout << "输入Server IP:";
cin >> servername;
cout << "输入Server port:";
cin >> serverport;
cout << "输入三个密码:" ;
for (int i = 0; i < 3; i++) {
cin >> pwds[i];
}
cout << "输入接收文件命名:";
cin >> filename;

//建立UDP SOCET
Client_Socket_Up();

//发送JOIN_REQ
Send_Packet(JOIN_REQ);

//接收PASS_REQ
Receive_Packet(PASS_REQ);

//发送PASS_RESP+密码 3次
Send_Packet(PASS_RESP);

//接收DATA和TERMINATE 即文件
Receive_Packet(DATA);

Server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cout << "==================Server===================" << endl;
//连接数据库
Connect_mysql();
cout << "输入Server IP:";
cin >> servername;
cout << "输入Server port:";
cin >> serverport;
cout << "输入文件名:";
cin >> filename;

//建立UDP SOCET
Server_Socket_Up();

//接收JOIN_REQ
Recive_Packet(JOIN_REQ);

//发送PASS_REQ
Send_Packet(PASS_REQ);

//接收PASS_RESP+密码
Recive_Packet(PASS_RESP);

//发送DATA
Send_Packet(DATA);

其中,Send_Packet()和Recive_Packet()会根据数据包的具体类型完成相应的步骤,具体代码在之后会详细介绍。

SOCKET编程

SOCKET编程通过SOCKET关键词实现服务器和客户端通讯,在本实验中被封装成为Server_Socket_Up()和Client_Socket_Up()函数,具体如下:

1
2
3
4
5
6
7
8
9
10
void Server_Socket_Up() {
WSAStartup(MAKEWORD(2, 2), &wsaData);//希望用户使用2.2版本的Socket
S_socket = socket(AF_INET, SOCK_DGRAM, 0);//地址类型是IPv4,服务类型是UDP所以是数据报套接字,协议是UDP;
memset(&sockAddr, 0, sizeof(sockAddr)); //将结构体清零
sockAddr.sin_family = AF_INET;//IPv4
sockAddr.sin_addr.s_addr = inet_addr(servername.c_str());//服务器IP
sockAddr.sin_port = htons(serverport);//端口
len = sizeof(sockAddr);
bind(S_socket, (sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[OK ]Server Up!" << endl;
1
2
3
4
5
6
7
8
9
10
11
void Client_Socket_Up() {
//建立UDP SOCET
WSAStartup(MAKEWORD(2, 2), &wsaData);//希望用户使用2.2版本的Socket
sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
memset(&sockAddr, 0, sizeof(sockAddr)); //将结构体清零
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr(servername.c_str());//指定服务器IP
sockAddr.sin_port = htons(serverport);//指定服务器端口
len = sizeof(sockAddr);
cout << "[OK ]Client Up!" << endl;
}

数据库连接

在本次实验中,连接数据库的目的主要是为了获取所有的登陆密码,以便用户可以正确连接,算是在实验完成基础上进行了一些合理化的扩展,具体内容封装在Connect_mysql()函数中,具体代码如下:

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
void Connect_mysql() {
MYSQL mysql;
MYSQL_RES* res;
MYSQL_ROW row;
mysql_init(&mysql);
//这里的话最好使用gbk,如果表中有中文utf8会出乱码
mysql_options(&mysql, MYSQL_SET_CHARSET_NAME, "gbk");
//这里填自己的服务名,用户名,密码,选择的数据库,端口号应该都是3306不用修改
if (mysql_real_connect(&mysql, "localhost", "root", "123456", "udp", 3308, NULL, 0) == NULL)
{
printf("[ABOUT ]连接失败!\n");
}
else {
cout<<"[OK ]数据库连接成功!" << endl;
}
mysql_query(&mysql, "select * from users;");
res = mysql_store_result(&mysql);
while (row = mysql_fetch_row(res))
{
true_pwds[sqlnum] = row[2];
sqlnum++;

}
mysql_free_result(res);
}

本实验中涉及到的数据库表如下:

4image (1)

数据包结构

在实验要求中,数据包被分为七种:JOIN_REQ、PASS_REQ 、PASS_RESP 、PASS_ACCEPT 、DATA、 TERMINATE、REJECT,观察数据包结构,发现JOIN_REQ、PASS_REQ 、PASS_ACCEPT 、REJECT四种数据包只携带数据包类型且有效载荷为0;PASS_RESP、TERMINATE数据包有效载荷为携带字符串的长度,并且在数据包类型和有效载荷之外还添加了密码或者摘要值,这两个从本质上来讲都是字符串;DATA数据包携带了数据包类型、有效载荷长度、数据包ID和文件内容;所以可以把实验中提供的七种数据包类型分成三大类:

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
//数据包类型
#define NOT_DEFINED 0
#define JOIN_REQ 1
#define PASS_REQ 2
#define PASS_RESP 3
#define PASS_ACCEPT 4
#define DATA 5
#define TERMINATE 6
#define REJECT 7

//基本数据包 类型+有效载荷(有效载荷为0)
#pragma pack(1)
struct PACKET {
short flag = NOT_DEFINED;//2字节
int payload = 0;//4字节
}packet;
#pragma pack(0)

//携带密码/摘要-数据包 类型+有效载荷+密码/摘要内容
#pragma pack(1)
struct PACKET_PWD {
short flag = NOT_DEFINED;//2字节
int payload = 0;//4字节
char content[50];
}packet_pwd;
#pragma pack(0)

//携带数据-数据包 类型(DATA固定)+有效载荷(数据包大小)+数据包id
#pragma pack(1)
struct PACKET_DATA {
short flag = DATA;//2字节
int payload = 0;//4字节
int packet_id = 0;//4字节
}packet_data;
#pragma pack(0)

另外,在此对PACKET.h中的其他定义做出解释说明:

1
2
3
4
5
//有效载荷
const int MAXBUF = 512; //文件读取
const int SENDBUF = MAXBUF + sizeof(PACKET_DATA);//一次传输的字节
#define PACKETLEN 2 //数据包头部len
#define PALOADLEN 4 //有效载荷len

建立连接阶段

在SOCKET连接成功会,客户端和服务器会进行初始的连接,即Client向Server端发送JOIN_REQ数据包,以表明它的加入;Server端成功接收到JOIN_REQ数据包后返回PASS_RESP数据包,以表示它可以成功接收到Client端发来的消息,并做出回复;

客户端代码:

title
1
2
3
4
5
6
7
//发送JOIN_REQ
if (packet_flag == JOIN_REQ) {
packet.flag = JOIN_REQ;
memcpy(buf, &packet, PACKETLEN);
sendto(sock, buf, PACKETLEN, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[OK ]Client成功发送JOIN_REQ PACKET!" << endl;
}

服务器代码:

1
2
3
4
5
6
7
8
9
10
11
//接收JOIN_REQ
if (packet_flag == JOIN_REQ) {
recvfrom(S_socket, buf, PACKETLEN, 0, (sockaddr*)&sockAddr, &len);
PACKET* recv_packet = (PACKET*)buf;
if (recv_packet->flag == JOIN_REQ) {//检查标记位和校验和
cout << "[OK ]Server成功接收到JOIN_REQ!" << endl;
}
else {
cout << "[ABOUT ]ERROR2!Server收到了错误包" << endl;
}
}

密码传输与验证

在本次实验中,文件从服务器传向客户端,在传输前需要对客户端的身份进行验证,客户端会发送携带密码的PASS_RESP数据包给Server端,并等待Server端返回的验证消息:若Server端返回了PASS_ACCEPT,即表示密码正确,可以继续发送文件;若Server端返回了PASS_REQ,表明Client端发送的密码不正确但是还有机会,此时Client端可以继续尝试别的密码;若Server端返回了REJECT,说明Client端已经发送了三次错误的密码,此时Server会断开连接,所以收到REJECT数据包的Client也会断开连接。

在客户端中定义了布尔值标志变量send_success,用于记录是否服务端和客户端是否验证成功;

客户端代码:

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
//发送PASS_RESP+密码 3次
if (packet_flag == PASS_RESP) {
bool send_success = 0;
for (int i = 0; i < 3; i++) {
//使用packet_pwd
packet_pwd.flag = PASS_RESP;
memset(buf, 0, SENDBUF);
string password = pwds[i];
packet_pwd.payload = password.size();
strcpy(packet_pwd.content, password.c_str());
//填入密码
memcpy(buf, &packet_pwd, SENDBUF);
sendto(sock, buf, SENDBUF, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[OK ]Client成功发送PASS_RESP + PASSWORD["<<i<<"] " << endl;
//接收PASS_ACCEPT
memset(buf, 0, SENDBUF);
recvfrom(sock, buf, PACKETLEN, 0, (sockaddr*)&sockAddr, (&len));
PACKET* recv_packet = (PACKET*)buf;
//收到了正确包 密码正确
if (recv_packet->flag == PASS_ACCEPT) {
cout << "[OK ]密码正确!Client成功收到PASS_ACCEPT PACKET!" << endl;
send_success = 1;
break;
}
//密码错误但是还有机会
else if (recv_packet->flag == PASS_REQ) {
cout << "[ABOUT ]密码错误,还可以尝试" << 2 - i<<"次" << endl;
continue;
}
//第三次输入错误 得到REJECT包
else if (recv_packet->flag == REJECT) {
cout << "[ABOUT ]所有密码均错误,断开连接!" << endl; return 0;
}
}
}

在服务器端,定义了错误链接数量变量error_num,用于决定客户端的下一步操作;另外,客户端使用Password_Ture(pwd)函数对比密码的正确与否,该函数的作用为遍历比较数据库所有存储的密码和服务器端收到的密码是否一致,一致返回True,否则返回False:

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
//接收PASS_RESP
if (packet_flag == PASS_RESP) {
int error_num = 0;//错误链接数量
while (error_num < 3)
{
memset(buf, 0, SENDBUF);
recvfrom(S_socket, buf, SENDBUF, 0, (sockaddr*)&sockAddr, &len);
PACKET_PWD* recv_packet2 = (PACKET_PWD*)buf;
//memcpy(password, buf+ PACKETLEN+ PALOADLEN, SENDBUF-PACKETLEN-PALOADLEN);//获取密码
string pwd = recv_packet2->content;
if (recv_packet2->flag == PASS_RESP) {
//密码正确
if (Password_Ture(pwd)==1) {
cout << "[OK ]Server成功接收到 PASS_RESP! password:" << recv_packet2->content << " 长度:" << recv_packet2->payload << endl;
//发送PASS_ACCEPT
memset(buf, 0, SENDBUF);
packet.flag = PASS_ACCEPT;
memcpy(buf, &packet, PACKETLEN);
sendto(S_socket, buf, PACKETLEN, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[OK ]Server发送了PASS_ACCEPT" << endl;
break;
}
//密码已经错误两次
else if (error_num == 2) {
memset(buf, 0, SENDBUF);
packet.flag = REJECT;
memcpy(buf, &packet, PACKETLEN);
sendto(S_socket, buf, PACKETLEN, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[ABOUT ]三次密码错误,Server发送了REJECT" << endl;return 0;
}
else {
memset(buf, 0, SENDBUF);
packet.flag = PASS_REQ;
memcpy(buf, &packet, PACKETLEN);
sendto(S_socket, buf, PACKETLEN, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
cout << "[OK ]Server发送了PASS_REQ" << endl;
error_num++;
}
}
}
}

文件传输与验证

在密码配对成功后,就可以进行文件传输,服务端计算文件大小,根据MAXBUF的大小确定数据包的数量,分段传输文件后,发送TERMINATE数据包用于传输该文件的SHA-1值,用于确保客户端收到的文件的完整性;客户端收到文件后将该文件完整存储下来,再重新计算SHA-1值,和服务器端传来的SHA-1值相比较,如果相同,则代表试验成功。

本次SHA-1值得计算使用的函数是SHA1(),为本小组另一个同学编写,作用为传入一个当前文件夹下的文件名字符串(或者传入文件的绝对路径),返回该文件的SHA-1值。

另外,文件的打开方式为二进制方式,便于所有文件格式的传输,本实验既可以传输文本文件,也可以传输图片、音频等文件。

客户端代码:

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
//接收DATA
if (packet_flag == DATA) {
//接收DATA 即文件
FILE* fp = nullptr;
ofstream fout;
fp = fopen(filename, "wb");
if (!fp)//打开文件
{
cout << "[ABOUT ]文件打开失败!" << endl;
return 0;
}
int num = 1;
while (1) {
//清空buffer
memset(buf, 0, SENDBUF);
//接收到的数据包放在buf中
recvfrom(sock, buf, SENDBUF, 0, (sockaddr*)&sockAddr, (&len));
PACKET_DATA* recv_packet3 = (PACKET_DATA*)buf;
PACKET_PWD* recv_packet4 = (PACKET_PWD*)buf;
if (recv_packet3->flag == DATA) {
//接收文件
//传来的数据写入文件
cout << "[OK ]Client成功收到DATA PACKET,ID:" << int(recv_packet3->packet_id);
cout << " LENGTH:" << int(recv_packet3->payload) << endl;
fwrite(buf + sizeof(PACKET_DATA), int(recv_packet3->payload), 1, fp);
//cout << "[OK ]SUCCESSFULLY WRITTEN FILE" << endl;
num++;
}
//收到并发送了最后一个数据包
else if (recv_packet3->flag == TERMINATE) {
fclose(fp);
//拿到数据包中的SHA-1
string sha_1_recive = recv_packet4->content;
cout << "[OK ]TERMINATE PACKET中的SHA-1: " << sha_1_recive << endl;
//计算接受保存的文件的SHA-1
string sha_1_compete = SHA1(filename);
cout << "[OK ]计算接收文件的SHA-1: " << sha_1_compete << endl;
//比较
if (sha_1_recive == sha_1_compete) {
cout << "[OK ]SHA-1值相同!" << endl;
cout << "[OK ]SUCCESS!!!" << endl;
}
break;
}
}
}

服务器代码:

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
//发送DATA
if (packet_flag == DATA) {
//发送DATA 即文件
ifstream f_send;
f_send.open(filename, ios::binary | ios::in);
if (!f_send.is_open()) {
cout << "[ABOUT ]文件打开失败 " << endl;
return 0;
}
//计算文件大小
f_send.seekg(0, f_send.end);
int filelen = f_send.tellg();
f_send.seekg(0, f_send.beg);
int ext = 0;
if (filelen % MAXBUF != 0) {
ext = 1;
}
int packet_num = filelen / MAXBUF + ext;
//需要传包的数量
cout << "[OK ]Server即将传输" << packet_num << "个数据包" << endl;
for (int i = 0; i <= packet_num; i++) {
//清空buf buf存取整个数据包内容
memset(buf, 0, SENDBUF);
//数据包类型放入buff
if (i == packet_num) {
//发送摘要
packet_pwd.flag = TERMINATE;
//填入SHA1摘要
string send_sha_1 = SHA1(filename);
cout << "[OK ]传输文件的SHA-1值为: " << send_sha_1 << endl;
strcpy(packet_pwd.content, send_sha_1.c_str());
//传输
memcpy(buf, &packet_pwd, sizeof(PACKET_PWD));
sendto(S_socket, buf, SENDBUF, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
}
else {
//开空间放信息存储发送的信息
f_send.seekg(i * MAXBUF, std::ios::beg);
//buffer存储文件内容
memset(buffer, 0, MAXBUF);
f_send.read(buffer, MAXBUF);
//发送正常包
packet_data.flag = DATA;
packet_data.packet_id = i;
if (i == packet_num - 1) {
packet_data.payload = filelen % MAXBUF;
}
else { packet_data.payload = MAXBUF; }
cout << "[OK ]Server 传送进度: " << packet_data.packet_id + 1 << "/" << packet_num << endl;
memcpy(buf, &packet_data, sizeof(PACKET_DATA));
//文件内容放入buff
memcpy(buf + sizeof(PACKET_DATA), buffer, MAXBUF);
//传输
sendto(S_socket, buf, SENDBUF, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr));
}
}
}

实验结果

数据库连接

5image (1)

主机-主机 文件传输

9image (1)

8image (1)主机-虚拟机 文件传输

6image (1)

7image (1)