从现在开始我们就要涉及到具体的协议了,开始自顶向下讲网络协议栈,首当其冲就是应用层。
起初我们在认识协议的时候,就是以类比的形式初步理解了一下协议的概念:协议就是一种约定,是通信双方必须遵守的规范。现在我们需要从代码层面深入理解一下协议。
套接字编程读写数据的时候,都是以“字符串”的形式发送接收的,如果我们需要传输结构化的数据呢?
// 结构化数据示例
struct message {
昵称:yyo;
头像:yyo.png;
消息:一起吃饭?;
时间:2022-7-28 18:16:33;
}
由于数据的长度是未知的,无法每次都准确的接收数据,因此不能直接传结构化的数据。
为什么要进行序列化和反序列化呢?
之前进行UDP TCP通信的时候并没有使用序列化和反序列化,因为我们没有结构化的数据,结构化的数据本质就是协议在代码层面的表现。
怎么进行序列化和反序列化呢?
也就是说如何把结构化的数据和字符串相互进行转化,这个造轮子的工作是相对麻烦的,可以使用别人提供的组件,比如jsoncpp。
$ yum install jsoncpp-devel # centos7安装jsoncpp
$ ls /usr/include/jsoncpp/json/ # json头文件
assertions.h autolink.h config.h features.h forwards.h json.h reader.h value.h version.h writer.h
$ ls /usr/lib64/libjsoncpp.so -l # json库文件
lrwxrwxrwx 1 root root 15 Jul 29 00:12 /usr/lib64/libjsoncpp.so -> libjsoncpp.so.0
int a = 1;
std::string s = "abc";
Json::Value root; //承装对象
root["dataInt"] = a;
root["dataString"] = s;
// 两种序列化方式
Json::FastWriter fwriter;
std::string fast_string = fwriter.write(root);
Json::StyledWriter swriter;
std::string style_string = swriter.write(root);
std::cout << fast_string << std::endl;
std::cout << style_string << std::endl;
// 反序列化方式
std::string json_string = R"({"dataInt":1,"dataString":"abc"})"; //原始字符串
// Json::Value root;
Json::Reader reader;
reader.parse(json_string, root);
int data1 = root["dataInt"].asInt();
std::string data2 = root["dataString"].asCString();
std::cout << data1 << " " << data2 << std::endl;
既然没有场景使用序列化,我们就实现一个服务器版的计算器。客户端发送数据和操作符,服务器进行计算,最后结果返回给客户端。
//Protocol.hpp
#pragma once
#include
//请求协议
struct request_t
{
int x; // 操作数1
int y; // 操作数2
char op; // 操作符 - "+-*/&"
};
//响应协议
struct response_t
{
int status; // 状态码 status为0表示计算正常,其他值表示异常退出
int result; // 计算结果
};
//序列化请求
std::string SerializeRequest(const request_t& req)
{
Json::Value root;
root["datax"] = req.x;
root["datay"] = req.y;
root["operator"] =req.op;
Json::FastWriter writer;
return writer.write(root);
}
// 反序列化请求
void DeserializeRequest(const std::string json_string, request_t& req)
{
Json::Value root;
Json::Reader reader;
reader.parse(json_string, root);
req.x = root["datax"].asInt();
req.y = root["datay"].asInt();
req.op = (char)root["operator"].asInt();
}
//序列化响应
std::string SerializeResponse(const response_t& resp)
{
Json::Value root;
root["status"] = resp.status;
root["result"] = resp.result;
Json::FastWriter writer;
return writer.write(root);
}
//反序列化响应
void DeserializeResponse(const std::string json_string, response_t& resp)
{
Json::Value root;
Json::Reader reader;
reader.parse(json_string, root);
resp.status = root["status"].asInt();
resp.result = root["result"].asInt();
}
通信双方都采用这个协议内容,都遵循变量的含义,这就是协议。
//CalClient.cc
#include
#include
#include
#include
#include "Socket.hpp"
#include "Protocol.hpp"
using namespace NSTcpSocket;
void Usage(std::string proc) {
std::cout << "Usage \n\t" << proc << "server_ip server_port" << std::endl;
exit(1);
}
int main(int argc, char* argv[])
{
if (argc != 3) {
Usage(argv[0]);
}
std::cout << "hello HttpClient" << std::endl;
int sock = TcpSocket::Socket();
TcpSocket::Connect(sock, argv[1], atoi(argv[2]));
request_t req;
std::cout << "Input the first data->";
std::cin >> req.x;
std::cout << "Input the second data->";
std::cin >> req.y;
std::cout << "Input the operator->";
std::cin >> req.op;
//序列化
std::string enjson_string = SerializeRequest(req);
TcpSocket::Send(sock, enjson_string);
//反序列化
response_t resp;
std::string buffer;
if (TcpSocket::Recv(sock, buffer)) {
DeserializeResponse(buffer, resp);
std::cout << "status[0:success]:" << resp.status ;
std::cout << " result:" << resp.result << std::endl;
}
return 0;
}
//CalServer.cc
#include
#include
#include
#include
#include
#include
#include "Socket.hpp"
#include "Protocol.hpp"
using namespace NSTcpSocket;
static void Usage(std::string proc) {
std::cout << "Usage \n\t" << proc << " port" << std::endl;
exit(1);
}
void* RequestHandler(void* args)
{
pthread_detach(pthread_self());
int sock = *(int*)args;
// 反序列化
std::string buffer;
if (TcpSocket::Recv(sock, buffer))
{
request_t req;
DeserializeRequest(buffer, req);
std::cout << req.x << req.op << req.y << std::endl;
response_t resp = { 0,0 };
switch (req.op) {
case '+':
resp.result = req.x + req.y;
break;
case '-':
resp.result = req.x - req.y;
break;
case '*':
resp.result = req.x * req.y;
break;
case '/':
if (req.y == 0) resp.status = 1;
else resp.result = req.x / req.y;
break;
case '&':
if (req.y == 0) resp.status = 2;
else resp.result = req.x & req.y;
break;
default:
resp.status = 3;
}
//序列化
std::string enjson_string = SerializeResponse(resp);
TcpSocket::Send(sock, enjson_string);
}
close(sock);
std::cout << "server done" << std::endl;
return nullptr;
}
int main(int argc, char* argv[])
{
if (argc != 2) {
Usage(argv[0]);
}
std::cout << "hello HttpServer" << std::endl;
uint16_t port = atoi(argv[1]);
int sock = TcpSocket::Socket(); // 创建套接字
TcpSocket::Bind(sock, port); // 绑定
TcpSocket::Listen(sock); // 监听
while (true)
{
int new_sock = TcpSocket::Accept(sock); // 接收连接
pthread_t tid;
pthread_create(&tid, nullptr, RequestHandler, (void*)&new_sock);
}
return 0;
}
顺手再对socket接口做封装一下。
//Socket.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#define BACK_LOG 5
namespace NSTcpSocket {
class TcpSocket {
public:
static int Socket() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock <= 0) {
std::cerr << "socket error" << std::endl;
exit(2);
}
return sock;
}
static void Bind(int sock, uint16_t port) {
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port);
if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
std::cerr << "bind error" << std::endl;
exit(3);
}
}
static void Listen(int sock) {
if (listen(sock, BACK_LOG) < 0) {
std::cerr << "listen error" << std::endl;
exit(4);
}
}
static int Accept(int sock) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(sock, (struct sockaddr*)&peer, &len);
if (new_sock <= 0) {
std::cerr << "accept error" << std::endl;
exit(5);
}
else {
std::cout << "accept success" << std::endl;
return new_sock;
}
}
static void Connect(int sock, const std::string& ip, int16_t port) {
struct sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_addr.s_addr = inet_addr(ip.c_str());
peer.sin_port = htons(port);
if (connect(sock, (struct sockaddr*)&peer, sizeof(peer)) < 0) {
std::cerr << "connect error" << std::endl;
exit(6);
}
else {
std::cout << "connect success" << std::endl;
}
}
static bool Send(int sock, std::string& buffer) {
ssize_t s = send(sock, buffer.c_str(), buffer.size(), 0);
if (s > 0) {
// TODO
}
else if (s == 0) {
std::cout << "send remote quit ..." << std::endl;
}
else {
std::cerr << "send error" << std::endl;
exit(7);
}
return s > 0;
}
static bool Recv(int sock, std::string& buffer) {
buffer.clear();
char tmp[1024];
ssize_t s = recv(sock, tmp, sizeof(tmp), 0);
if (s > 0) {
buffer = tmp;
}
else if (s == 0) {
std::cout << "Recv remote quit ..." << std::endl;
}
else {
std::cerr << "Recv error" << std::endl;
exit(8);
}
return s > 0;
}
};
}
我们刚刚写的CS模式的在线计算器,本质就是一个应用层的网络服务,我们实现了:
实现了这些,就可以称为一个应用层的网络服务。
回头看一下OSI模型的上三层:应用层对应着我们实现的业务逻辑和协议,表示层对应着我们实现的序列和反序列化内容,会话层对应基本通信代码。所以可以看出OSI模型停留在表面的原因,这些东西需要用户手动实现。
前面虽然说,应用层协议是程序员自己定制的,但实际上,已经有工程师定制好了一些非常好用的应用层协议,如超文本传输协议 HTTP。所以我们目前就是要学习HTTP协议。
既然HTTP协议也是应用层协议,本质上和我们刚刚编写的在线计算器没有区别,那么HTTP内部一定自行实现了网络通信、序列化和反序列化、协议细节。稍后我们就围绕这三点讲解HTTP协议。
URL其实就是我们俗称的”网址“,我们把在网络上请求的图片、网页、视频、音频、文档等等内容都称为“资源”,网址其实就是定位网络资源的一种方式。
IP加端口可以定位互联网上唯一一台主机,但如果我们还需要该主机上的资源文件,还要有路径。即我们通过IP+路径,就可以唯一地确认一个网络资源。
IP通常是以域名的方式呈现的,路径就是Linux主机上的目录,如下图所示:
用户登录信息现在都是以表单的形式呈现,不再放到域名前了。端口一般也被省略了,因为协议都使用固定的端口。
既然/
,:
,.
,?
这些符号在URL中已经具有特殊意义,所以它们在URL中想以普通字符的形式出现,就必须做转义。转移规则如下:
将需要转义的字符转为16进制,然后从左到右取四位(不足四位当四位处理),每两位作一位前面加上%
,编码成%XY
的格式。
URL转码工具
HTTP是以行(\n)为单位构建请求和响应的协议内容的,所以无论请求还是响应协议几乎都由3或4个部分组成:
组成 | 内容 |
---|---|
请求行 | 请求方法就是GET ,POST 之类的,url 就是请求的资源路径,http version 就是协议版本,以空格隔开 |
请求报头 | 有多组请求属性,每组属性都是键值对,每组属性结尾都是\n ,最后遇到空行\n 就表示报头结束 |
请求正文 | 空行之后的内容都是请求正文,正文允许为空。如果正文存在,报头中会存在属性Content-Length 来表示内容长度 |
用浏览器输入服务器的IP和端口,连接我们的网络服务,就可以打印浏览器发送的HTTP请求协议内容。
直接用recv去读取一个固定长度的缓冲区内容是不正确的,因为浏览器通常会连发几个请求,这样就不能保证准确地读取到所有请求。如何读取才是正确读取呢?
Content-Length
请求属性,从而确定请求正文的大小,就能准确读取所有内容。组成 | 内容 |
---|---|
状态行 | 分别是http version ,状态码,状态码描述,以空格分隔 |
响应报头 | 多组相应属性,都是键值对,以\n 分隔,遇空行则结束 |
响应正文 | 空行之后都是响应正文,允许为空。如果正文存在,报头中会存在属性Content-Length 来表示内容长度 |
不管是请求报头还是响应报头,都是由多部分组成,那HTTP协议如何解包、封装、分用的呢?
方法 | 说明 | 支持协议版本 |
---|---|---|
GET | 获取资源 | 1.0 1.1 |
POST | 传输实体资源 | 1.0 1.1 |
PUT | 传输文件 | 1.0 1.1 |
HEAD | 获取报头首部 | 1.0 1.1 |
DELETE | 删除文件 | 1.0 1.1 |
OPTIONS | 询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 要求用隧道协议连接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.0 |
UNLINK | 断开连接关系 | 1.0 |
HTTP方法中我们只学习GET,POST方法。其他方法只了解即可,而且一般Web服务器安全起见都不开放这些方法。
简单的看,普通用户的上网行为无非就是两种形式:
每次请求报头中请求行中的资源路径都是/
,这个/
的含义是 Web 根目录,具体路径在服务器程序中指定。
不过一般习惯是项目路径下的
wwwroot
目录文件,默认首页是 Web 根目录下的index.html
。
名称 | 场景 | 区别 | 隐私性 |
---|---|---|---|
GET 获取 | 常用的默认获取资源的方法,也可以提交参数 | 通过URL提交,url存在一定大小限制,具体和浏览器有关 | 直接回显在URL栏中 |
POST 推送 | 提交参数时较为常用的方法 | 通过正文提交,正文一般无限制,但需要属性Content-Length 表明长度 |
不会回显在URL栏中,较为私密但并不代表安全 |
不管是哪种方法,都是在将数据从前端发送到后端,故所谓HTTP协议处理,本质就是文本分析。
由于应用层协议参与到的人员太多,编程水平素养参差不齐,很多人不明白不了解知道如何使用THHP协议的状态码索性瞎填胡填。导致浏览器厂商为防用户流失也无法坚持协议标准,慢慢就导致状态码标准不一,状态码渐渐地也就失去作用了。
状态码 | 类别 | 翻译 | 解释 | 常见状态码 |
---|---|---|---|---|
1XX | Informational | 信息性状态码 | 接收的请求正在处理 | 不常见 |
2XX | Success | 成功状态码 | 请求正常处理完毕 | 200(OK) |
3XX | Redirection | 重定向状态码 | 需要进行附加操作以完成请求 | 302(Redirect) |
4XX | Client Error | 客户端错误状态码 | 服务器无法处理请求 | 403(Forbidden) 404(Not Found) |
5XX | Server Error | 服务端错误状态码 | 服务器处理请求出错 | 504(Bad Gateway) |
Location
使用。报头属性 | 解释 |
---|---|
Content-Type | 正文内容的数据类型 content-type对照表 |
Content-Length | 正文内容的长度 |
Connection | 请求的是否保持长连接,每次请求是复用已建立好的请求,还是重新建立新请求 |
Host | 客户端告知服务器,所请求的资源是在哪个主机的哪个端口上 |
User-Agent | 声明用户的操作系统和浏览器版本信息 |
referer | 当前页面是从哪个页面跳转过来的 |
location | 搭配 3XX 状态码使用,告诉客户端接下来要去哪里访问 |
Cookie | 用于在客户端存储少量信息.通常用于实现会话(session)的功能 |
要求是按照HTTP协议的请求与相应内容,用浏览器访问我们的服务器,实现最简单的HTTP服务器。
#include
#include
#include
#include
#include
#include
#include
#include "Socket.hpp"
using namespace NSTcpSocket;
#define WWWROOT "./wwwroot/"
#define HOME_PAGE "index.html"
void Usage(std::string proc) {
std::cout << "Usage: \n\t" << proc << " port" << std::endl;
exit(1);
}
void* HttpResquestHandler(void* args)
{
pthread_detach(pthread_self());
int sock = *(int*)args;
std::string buffer;
// 这种读法其实是不正确的
if (TcpSocket::Recv(sock, buffer)) {
std::cout << buffer; //打印HTTP的请求格式
std::string home_page = std::string(WWWROOT) + std::string(HOME_PAGE); // 默认首页路径
struct stat st;
stat(home_page.c_str(), &st); // 以待获取文件信息
std::string http_response;
http_response += "http/1.0 200 OK\n"; // 状态行
http_response += "Content-Type: text/html; charset=utf-8\n"; // 响应报头
http_response += "Content-Length: ";
http_response += std::to_string(st.st_size); // 补齐正文内容属性数据
http_response += "\n\n"; // 补齐属性的换行和空行
//响应正文是index.html的文件内容
std::ifstream ifs(home_page);
if (!ifs.is_open()) {
std::cerr << "ifs open fail" << std::endl;
}
else {
char buffer[st.st_size + 1]; // 以文件大小创建缓冲区
memset(buffer, 0, sizeof(buffer));
ifs.read(buffer, st.st_size); // 将文件内容读进缓冲区
http_response += buffer;
}
TcpSocket::Send(sock, http_response);
}
close(sock);
return nullptr;
}
int main(int argc, char* argv[]) {
if (argc != 2) {
Usage(argv[0]);
}
uint16_t port = atoi(argv[1]);
int listen_sock = TcpSocket::Socket();
TcpSocket::Bind(listen_sock, port);
TcpSocket::Listen(listen_sock);
while (true) {
int sock = TcpSocket::Accept(listen_sock);
if (sock > 0) {
pthread_t tid;
pthread_create(&tid, nullptr, HttpResquestHandler, (void*)&sock);
}
}
return 0;
}
HTTP协议本身是一种无状态的协议,即每次打开新网页都是一次新请求新连接。曾经的请求并没有记录,他只关心本次的请求是否成功。
但在我们访问某个网站时经常有这样的体验:第一次访问需要我们登录,之后的第二次第三次甚至一段时间内都不需要我们再次登录,可以说“网站是认识我们的”。
HTTP只是用来提供网络资源收发的基本功能,这样“会话保持”方便的上网体验是由 Cookie 提供的。
第一次发起HTTP登录请求的时候,如果用户名密码信息正确就会被浏览器保存在本地 cookie 中,可以保存在磁盘文件也可以是内存中,后续的请求都会携带上该 cookie,所以之后的访问就不用再手动登录了。
虽然现在很少有会把用户名密码放在cookie中了,有更安全的方式,但这确实是 cookie 的最初的用法,用来理解 cookie 的作用再好不过。
可以在响应报头中添加属性Set-Cookie
,让客户端每次请求都带上Set-Cookie
中要求的内容。
本地cookie文件会保存浏览器中类似于用户名密码、用户浏览记录这些非常私密的信息,如此一来一旦有人盗取Cookie文件,就可以以用户身份访问特定资源,或者盗取用户认证信息。
单纯使用Cookie是具有一定安全隐患的,因此就出现了 session。session 的核心方案是将用户的私密信息保存在服务器端。具体如下:
Set-Cookie: session_id=123
的属性,被称为当前用户的会话ID。session_id具有唯一性,每个用户都有一个session_id。如此 cookie 和 session 结合使用利用 session_id 就能避免用户私密信息在网络上传输的尴尬情况。虽然没有彻底解决信息被盗的情况,但也由此衍生出各种方案,如检查常用IP归属地,邮箱短信二次认证等。
不管cookie还是session本质都是为了更好更安全的用户体验。
HTTPS 相比 HTTP 就多了一个安全环节即 TLS/SSL 属于数据的加密解密层,对于开发而言并不是重点,是网安工程师的任务,我们只需要有宏观认识即可。
TLS和SSL属于应用层协议,以至于数据只有流到应用层时才会被加密,也就是数据在网络中总是被加密的,而在应用层以下的传输层、网络层、链路层时没有加密的。
事实上,HTTPS的所有内容如状态行、报头、正文等都是被加密的。整体看来只有应用层才有加密解密的行为,其他层看来都和HTTP一样。
接下来认识一下加密解密的方式,同样也是认识原理,不作深入了解。
所谓对称加密,就是加密解密的密钥相同,用该密钥加密也用该密钥解密。可以简单化类比异或运算的特点:你异或他等于我,我异或他等于你。
server_data = client_data ^ key; // 客户端数据用key加密后发送给服务端
client_data = server_data ^ key; // 服务端数据用key解密后得客户端数据
如果我们想用对称加密,就需要先让通信双方获得密钥,这就存在一个先有鸡还是先有蛋的问题,显然是不可取的。
非对称加密,就是存在两个密钥,一个是公钥一个是私钥。
也就是说,数据可以安全地从客户端到服务端,反之则不行,即只能进行单向加密。
如果采用两对公私密钥的方式,虽然可以双向加密但非对称加密时间成本太高,同样不可取。
实际使用时,既不是纯对称加密,也不是纯非对称加密,而是混合起来的。
既然非对称加密可以将数据安全地从一方送往另一方,密钥本质上也是一种数据,那么将数据替换成密钥,该密钥就能安全地被通信双方获取到。既然如此,通信双方使用该密钥对称加密即可。即:
这样做真的就万无一失了吗?其实不然,在上述密钥协商阶段是存在中间人私自更换密钥的风险的。
在网络通信中,随时都可以存在中间人来偷窥、修改通信双方的数据,所以我们只能左右该数据是否被加密。
S
时,被中间人截取,换成了中间人的公钥M
并发给了客户端。X
并使用公钥M
将其加密,然后将加密数据M+(X)
发送给服务器。M'
解密,得到密钥X
,再用服务端的公钥S
加密后发往服务器。S'
对其解密得到密钥X
。自此通信双方的对称加密密钥X
被中间人获取,数据加密形同虚设。
产生上述问题的关键在于:客户端不能判断获取到的公钥是否是合法服务端发来的。因此,我们必须赋予客户端辨别公钥是否被篡改的能力。
那么如何防止数据的内容被篡改呢?网络中的数据的公开的,所谓防止也就是能够识别到文本是否被篡改。
由此出现一种权威机构 CA证书机构,具有绝对权威的地位。
只要服务商将自身基本信息如域名、公钥等提交给CA证书机构,证书机构就会采用上述生成步骤生成证书颁发给服务端。
只要服务商有证书,客户端就信任该服务商,采用上述校验步骤校验数据内容是否被篡改,就不会出现问题了。
A'
因此无法进行修改证书。一旦中间人修改了证书中任意内容,客户端都能在校验中对比数据摘要时发现,因此中间人无法修改。中间人没有证书机构的私钥A'
,就无法对修改后的证书重新进行加密生成数字签名,一旦修改,客户端就能发现。