具有最少安全功能的CLIENT-SERVER通信协议
实验要求
实现一个简单的 UDP 客户端-服务器应用程序,在客户端使用密码成功登录后,服务器将文件传输到客户端。
要求:您必须在两个不同的文件中实现两段代码: 客户端和服务器。下面是协议规范,它将为您提供实现这两个程序所害的详细信息。此外,我们还提供了数据包格式,它指定了客户端和服务器将交换的消息的内容。即使客户端和服务器运行在具有不同 endian 格式的体系结构上,您的实现也必须正确工作。
协议规范:
- 客户端发送JOIN REQ 分组以发起与服务器的通信。
- 服务器以 PASS REQ 数据包进行响应,该数据包是对用户的密码请求。
- 客户端将向服务器发送 PASS RESP 包,其中包括密码。
- 服条器将验证密码,如果密码正确,服务器将向客户端发送 PASS ACCEPT 数据包。
- 如果密码不正确,服务器将再次向客户端发送 PASS REQ 包。PASS REQ 数据包将最多重发三次。第三次之后,服务器向客户端发送拒绝消息。客户端关闭会话,服务器也退出。
- 一旦服务器向客户端发送 PASS ACCEPT 包,服务器就开始使用 DATA 包发送文件。文件被分成几个段(取决于文件的大小),每个段都使用 DATA 数据包传输。
- 当服务器完成发送文件时,它将发送一个 TERMINATE 数据包,该数据标志着文件下载的结束。此数据包中包含一个文件摘要 (SHA1 摘要) ,客户端将使用它来验证接收到的文件的完整性。
背景知识
UDP(UserDatagramProtocol)是一个简单的面向消息的传输层协议,它不保证向上层协议提供消息传递,并且UDP层在发送后不会保留UDP 消息的状态。因此,UDP被称为不可靠的数据报协议。
UDP的优点:简单,轻量化。
UDP的缺点:没有流控制,没有应答确认机制,不能解决丢包、重发、错序问题。
亮点
- 本次实验实现了从数据库中读取密码,使得客户端和服务器准确连接。
- 实验里修改了数据包结构,让整体实验更加清晰。
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;
Client_Socket_Up();
Send_Packet(JOIN_REQ);
Receive_Packet(PASS_REQ);
Send_Packet(PASS_RESP);
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;
Server_Socket_Up();
Recive_Packet(JOIN_REQ);
Send_Packet(PASS_REQ);
Recive_Packet(PASS_RESP);
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); S_socket = socket(AF_INET, SOCK_DGRAM, 0); memset(&sockAddr, 0, sizeof(sockAddr)); sockAddr.sin_family = AF_INET; sockAddr.sin_addr.s_addr = inet_addr(servername.c_str()); 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() { WSAStartup(MAKEWORD(2, 2), &wsaData); 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()); 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); mysql_options(&mysql, MYSQL_SET_CHARSET_NAME, "gbk"); 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); }
|
本实验中涉及到的数据库表如下:
.png)
数据包结构
在实验要求中,数据包被分为七种: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
#pragma pack(1) struct PACKET { short flag = NOT_DEFINED; int payload = 0; }packet; #pragma pack(0)
#pragma pack(1) struct PACKET_PWD { short flag = NOT_DEFINED; int payload = 0; char content[50]; }packet_pwd; #pragma pack(0)
#pragma pack(1) struct PACKET_DATA { short flag = DATA; int payload = 0; int packet_id = 0; }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 #define PALOADLEN 4
|
建立连接阶段
在SOCKET连接成功会,客户端和服务器会进行初始的连接,即Client向Server端发送JOIN_REQ数据包,以表明它的加入;Server端成功接收到JOIN_REQ数据包后返回PASS_RESP数据包,以表示它可以成功接收到Client端发来的消息,并做出回复;
客户端代码:
title1 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
| 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
| if (packet_flag == PASS_RESP) { bool send_success = 0; for (int i = 0; i < 3; i++) { 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; 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; } 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
| 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; 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; 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
| if (packet_flag == DATA) { FILE* fp = nullptr; ofstream fout; fp = fopen(filename, "wb"); if (!fp) { cout << "[ABOUT ]文件打开失败!" << endl; return 0; } int num = 1; while (1) { memset(buf, 0, SENDBUF); 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); num++; } else if (recv_packet3->flag == TERMINATE) { fclose(fp); string sha_1_recive = recv_packet4->content; cout << "[OK ]TERMINATE PACKET中的SHA-1: " << sha_1_recive << endl; 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
| if (packet_flag == 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++) { memset(buf, 0, SENDBUF); if (i == packet_num) { packet_pwd.flag = TERMINATE; 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); 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)); memcpy(buf + sizeof(PACKET_DATA), buffer, MAXBUF); sendto(S_socket, buf, SENDBUF, 0, (struct sockaddr*)&sockAddr, sizeof(sockAddr)); } } }
|
实验结果
数据库连接
.png)
主机-主机 文件传输
.png)
主机-虚拟机 文件传输
.png)
.png)