【Linux网络系列】:打破 HTTP 明文诅咒,在Linux 下用 C++ 手搓 HTTPS 服务器全过程!(附实现源码)

【Linux网络系列】:打破 HTTP 明文诅咒,在Linux 下用 C++ 手搓 HTTPS 服务器全过程!(附实现源码)
🔥 本文专栏:Linux网络
🌸作者主页:努力努力再努力wz

在这里插入图片描述
💪 今日博客励志语录成人的世界里,情绪是最廉价的成本。你可以崩溃,但请记得设置闹钟。哭完之后,账单还在,生活还得继续,最能治愈焦虑的永远不是鸡汤,而是账户里的余额和手里的专业技能。

★★★ 本文前置知识:

Http


引入

在之前的讲解中,我们探讨了HTTP 协议并实现了一个基于HTTP 的 Web 服务器。然而,HTTP存在一个根本性的安全缺陷,即明文传输。我们知道,在客户端(通常为浏览器)与服务端通信的大多数场景中,客户端会向服务端发送GETPOST 请求。这两种请求均可用于提交数据。对于GET 请求,其提交的表单数据以查询参数的形式附加在请求行中的 URL 之后,表现为键值对。由于 URL 本身存在长度限制,GET 请求只能传递较简单的表单数据,无法传输体积较大的内容(例如文件)。此外,提交后,浏览器地址栏会完整显示 URL 及其包含的查询参数,这意味着所提交的表单数据会直接暴露在地址栏中。如果提交的是敏感信息(如登录认证所用的用户名和密码),这些信息将以键值对的形式出现在 URL 的查询参数部分,因此存在安全隐患。

虽然 POST 请求将表单数据放在请求正文中,不会直接显示在地址栏,对普通用户而言不可见,但其私密性依然无法得到保证。我们需要理解,请求报文会从本地主机发送至默认网关(通常为路由器),再经由运营商的路由器通过中间节点逐跳转发,最终到达目标主机。

所谓HTTP 报文是“明文”,指的是我们在传输过程中未对报文数据进行任何加密处理。这意味着,在网络上传输的请求与响应报文对应的字节流,即为原始的报文数据。如果攻击者意图截取客户端与服务端之间的通信内容(即请求或响应报文),他们通常会从运营商的路由器等中间转发节点入手。

我们知道,所有网络传输设备均可通过 TCP/IP 四层模型进行描述,这四层分别是应用层、传输层、网络层和数据链路层。路由器具备接收和转发数据包的能力,同样适用于该模型,其主要工作在下三层(网络层、数据链路层及以下)。在转发过程中,路由器会执行逐层解封装。当数据包交付至网络层进行路由判断时,数据链路层的头部已被移除。此时,若路由器被植入恶意程序,该程序只需跨越网络层(IP)和传输层(TCP)首部的偏移量,即可精确定位并窃取应用层载荷中的明文信息,从而使客户端与服务端之间的全部通信内容暴露给攻击者。

随着互联网技术的发展,网络安全问题日益受到重视。在当前互联网环境下,我们使用浏览器访问网站时会发现,绝大多数站点都已采用HTTPS 协议,而非HTTPHTTPS并非一个完全重新设计的全新协议,从其名称可以看出,它只比 HTTP 多了一个“S”。这实际上意味着HTTPS 是在原有HTTP 协议基础上进行了改善与增强,多出的“S”即代表安全(Security),核心在于对传输数据的加密。接下来,让我们正式开始对HTTPS 的学习。

HTTPS

原理

那么根据上文,我们已经知道了http明文传输的风险,而我们知道网络设备传输数据是可以通过TCP/IP四层模型来进行描述,分别是应用层以及传输层和网络层以及数据链路层,那么而数据传输就是每一层添加各自的协议然后将添加该层协议的数据报递交至下一层,而数据的接收则是从下层开始,然后处理该层的协议,并进行子层解封装 ,然后交付至上一层,是一个对称 的过程

而在此前,我们的应用层协议主要就是针对的是应用层数据的格式以及序列化反序列化,其目的就是为了让接收方能够正确的解析应用层数据的各个字段,但是这里除了要对应用层的数据进行相应的格式化以及序列化之后,这里不能直接递交给下一层,而是还得再多一个环节,那么这个环节就是加密层,也就是将格式化以及序列化后的应用层数据进行加密

而读者读到这里,那么首先的疑问,我们知道这里应用层的数据要进行加密,因为http是明文传输,但是为什么这里将加密这个功能交给应用层实现,而不是交给下层的传输层或者网络层实现呢?

那么这里我们要知道的就是,应用层代表的实现的负责是由我们程序员来完成,而传输层以及网络层的负责则是交给了操作系统,之所以将传输层以及网络层的视线交给操作系统。因为传输层以及网络层的功能或者说协议是固定的,适合交给稳定的操作系统来完成,而应用层的协议是多种多样并且会不断进行更新的,因为其和具体的业务逻辑有关,是由我们程序员自己去定义的,不适合交给操作系统来实现,否则操作系统会不断的更新来支持新的应用层协议,并且一旦某一个应用层协议出问题,不仅要修改应用层本身还得修改操作系统

同理,对于加密层,那么加密的算法是多种多样的,并且会随着时间的推移,不断更新出更强的加密算法,意味着加密算法的功能以及内容是不是固定或者说一层不变的,而且和业务的逻辑也有关联,所以这里加密层理应放在应用层,也就是由我们程序员自己去实现


了解这一点后,接下来我们将进入具体的加密层原理部分。在讲解加密的具体原理之前,首先需要理解加密的基本思想。在介绍加密的基本思想时,我仍通过一个例子来引入:

在古代,相隔两地的两人若要进行通信,通常依靠飞鸽传书。飞鸽传送的是一封信,而信件在运送过程中可能被他人截获,从而泄露通信内容。为确保通信内容不被外人得知,就必须对信件进行加密。加密的方式是在信件中加入一些无关文字干扰阅读,但通信双方知道如何解密。例如,信件真正的内容位于每一行的开头,接收方将每行开头的字连接起来,即可获得发送方要传达的真实信息。

通过这个例子,我想说明加密的基本思想:发送方会生成一个密钥,并将原文密钥进行特定运算,得到的结果称为密文。所谓的密钥,就是与原文不相关的内容,其目的就是作为干扰信息,打破原文的逻辑结构或者统计规律,在上面的例子中,密钥就是那些无关文字,运算则是在原文基础上添加这些文字。而在计算机中,原文与密钥的运算通常指模运算异或运算等。

举例来说,假设我们实现了一个基于 HTTP 的 Web 服务器,向客户端提供计算器服务。客户端获取用户输入的两个操作数和运算符,以表单形式提交给服务器。由于 HTTP 是明文传输,这意味着黑客可直接获取计算所用的操作数,因此此处需要加密。其中一种加密的实现方式,可以是让操作数统一加 5、减 5 或与 5 进行异或等。运算结果即为密文。服务端收到客户端发来的请求报文后,首先获取完整的 TCP 请求报文,然后进行解密。由于服务端知晓加密规则,解密即是对称操作:对操作数减 5、加 5 或再次异或 5,得到原始数据。接着用原始数据进行计算,再将运算结果按约定方式加密为密文,返回给客户端。这里的“5”即为密钥

假如我是黑客,在数据包转发的中间节点劫持了该数据包。劫持后,下一步需对数据包进行解密,因为数据已被加密。要获取原始数据,黑客只能采取穷举法,猜测加密所使用的算术运算类型及密钥值。可能的组合方案极多,而即使尝试了某种方案,黑客也无法判断得到的是否为真正的原始数据,因此解密成本极高,几乎不可行。黑客无法成功解密的核心原因主要有两点:一是不知道双方使用的加密算法,二是不清楚运算所使用的密钥

因此,理论上只要在应用层自行实现一套加密算法,并确保服务端与客户端均知晓该算法及其所用密钥,即可实现安全通信。加密与解密的过程可类比为得到两个互逆二元一次方程的输出:加密时,加密算法相当于一个二元一次方程,接收两个输入——明文密钥,并输出密文;解密时,解密算法对应另一个二元一次方程,接收密文密钥作为输入,并输出明文。这两个方程之间构成互逆关系,即其计算过程相互可逆。具体而言,若将加密算法对应的方程中位于等式左侧的一个输入变量(即密钥)移至等式右侧,则此时等式右侧所表示的就是解密算法对应的方程。

加密: f ( 明文,密钥 ) = 密文 加密:f(明文,密钥)=密文 加密:f(明文,密钥)=密文

解密: f ( 密文,密钥 ) = 原文 解密:f(密文,密钥)=原文 解密:f(密文,密钥)=原文

可见,若我们自行设计加密与解密算法,就相当于独立构造两个互逆的二元一次方程,并让通信双方均知晓其形式。然而,仅知晓算法并不足以完成解密,因为接收方虽然获得密文并知道方程形式,但二元一次方程必须同时已知两个变量(此处为密文与密钥)才能输出原文。因此,客户端与服务端必须事先约定相同的加密算法、解密算法以及密钥。

这些算法与密钥仅由服务端和客户端预先约定内置,意味着该秘密仅限于通信双方知晓。对于第三方(如黑客)而言,其既不知道二元一次方程(算法)的形式,更不知道密钥,因此几乎永远无法破解该密文。

然而,上述情况过于理想化,甚至是一种乌托邦。首先,加密与解密算法很难完全由我们手动设计实现。虽然前文举了“操作数加 5”这样的例子,其中“加 5”即为加密算法,“5”即为密钥,但该场景过于简单。实际传输的数据往往是文本内容甚至是大文件,不可能仅通过简单算术运算完成加密。此外,HTTP 协议本身是一种具有固定格式的文本协议,黑客可通过频率分析等手段尝试破解:例如,HTTP 请求与响应报文的请求行、请求头末尾均包含回车换行符,且请求头以键值对表示,键与值之间存在空格。如果对文本进行某种加密后得到乱码,黑客可分析乱码中各个字符出现的频率,推测出现频率最高的乱码可能对应回车换行符或空格。黑客可结合回车换行符或空格的 ASCII 码,与高频乱码进行比对,尝试反推加密运算方式,再尝试还原其他内容,直至破解出可读的 HTTP 报文。这只是黑客破解的其中一种方式。

之所以提到这一点,是因为如果加密与解密算法由我们自行设计,其背后涉及数学、密码学等多方面知识,必须设计出足够强大且完善的算法才能抵御攻击。这类算法通常由数学家、密码学家和计算机科学家共同设计完成,因此我们很难手动实现。

但这还不是最关键的问题。假设我们有能力设计出一套强大的加密算法,接下来的环节是让服务器与客户端“心有灵犀”地知晓该算法,也就是必须在客户端和服务端同时内置该算法。这意味着服务器和客户端(如浏览器)都必须由我们自己设计与实现。而实际场景中,服务器通常不会只与单一客户端通信。例如,访问百度网站时,既可使用 Google 浏览器,也可使用搜狐、QQ 或夸克浏览器。不可能要求只有“百度浏览器”才能访问百度,这意味着服务端需允许多种不同类型的客户端访问。其他类型的客户端并非由我们实现,因此它们不会知道我们自行设计的加密算法。这样一来,我们只能将算法公开。而一旦公开,除了客户端,黑客也会知道这个算法(即二元一次方程的形式是什么)。

在实际应用场景中,客户端与服务端往往不由同一开发者实现,因此无法在双方内置同一套加密算法。这样一来,将加密算法本身公开便成为一种可行方案。我们可以在公开渠道学习该算法的原理与实现。此时读者可能会产生疑问:算法完全公开,攻击者自然也能获悉,这是否会降低通信的安全性?

回顾前文,加密与解密过程可类比为一个二元一次方程组。公开算法,相当于只公开了方程的形式;加密过程则是将两个自变量——明文与密钥——代入方程,得到输出结果即密文。对攻击者而言,即便截获了数据包,得到的也只是密文。解密相当于加密的逆过程,即已知方程形式与输出值(密文),求解输入值(明文)。然而,要解出明文,仅知道密文是不够的,还必须获得密钥。若攻击者无法获取密钥,则只能通过穷举法尝试所有可能的密钥值。只要密钥长度足够大,穷举所需时间可能超过宇宙寿命。因此,即使加密算法完全公开,只要密钥不泄露,攻击者依然无法从密文中恢复原文。

但这里又引出另一个实际问题:服务端通常需与多种客户端通信,而客户端与服务端往往由不同开发者或团队实现,双方无法预先约定加密所用的密钥。因此,在正式通信之前,双方必须经过一个协商(握手)过程,主要确认两点:第一,选择使用哪一种加密算法;第二,确定后续加密通信所使用的密钥。因为服务端和客户端在通信过程中都会承担发送方接收方的角色,双方均需执行加密解密操作。正如前文所述,加密与解密可类比为将两个自变量——密钥与明文(或密文)——代入对应的互逆的算法中(即对应的二元一次方程),从而得到输出结果密文(或明文)。因此,双方必须预先知晓同一个密钥,才能正确进行加密与解密。

问题恰恰出在这个协商阶段。此时双方尚未建立加密通道,所有通信只能以明文形式传输,而这些握手报文同样可能被攻击者截获。若发送方为防止密钥泄露而对密钥进行加密,接收方却因处于握手阶段而无法知晓该加密行为,发送方就不得不额外告知对方“密钥已被加密、加密算法是什么、解密所需密钥又是什么”。如此一来,为了传递密钥需要加密,而传递加密密钥又需要新的密钥,形成了一种“先有鸡还是先有蛋”的无限递归困境。这就好比要把一把钥匙安全交给对方,为防止钥匙被窃而将其锁进保险箱,但对方却没有保险箱的钥匙;所以又必须额外提供该保险箱的钥匙,若再将保险箱的钥匙传递过去,则同样面临被窃的风险,如此循环,没有终点。

上述困境的根源在于当前使用的是对称加密体制,即加密与解密使用相同的密钥。由于密钥相同,加解密过程对应的“方程组”本质上也是相同的,只是计算方向互为逆过程。


非对称加密

鉴于对称加密存在密钥分发问题,通信双方无法采用对称加密,而需使用非对称加密。如前文所述,加密与解密算法可类比为两个二元一次方程,分别接收两个输入变量,代入方程后得到输出结果。其中一个输入变量是密钥。在非对称加密中,用于加密方程和解密方程的密钥是不同的,因此需要引入公钥私钥的概念。

在客户端与服务器建立加密通信之前,双方会先进行握手协商,以确定加密算法与密钥。该过程必须通过明文传输完成,因为此时仍处于协商阶段,而非正式加密通信阶段,双方必须明确密钥信息。由于传输为明文,若第三方(攻击者)在此环节监听,必然能够获取“暴露”的密钥。

因此,在第一次握手时,客户端与服务器先确定加密算法。随后,客户端向服务器请求加密通信所需的密钥。服务器收到握手请求后,由于采用非对称加密(而非对称加密),会生成一对公钥私钥。私钥由服务器自行保存,不对外公开;公钥则通过响应报文以明文形式发送给客户端。若攻击者截获该报文,无疑将获知公钥的具体内容。

此时读者可能会产生疑问:攻击者获取公钥后,是否就能解密通信内容?事实上,这个过程尚未结束。客户端收到服务器发送的公钥后,随即开始正式加密通信:客户端使用该公钥对原始数据加密,并将密文发送给服务器。此时,持有公钥的攻击者也可能截获该密文,并尝试对其进行解密。

由于加密与解密算法是公开的,且握手阶段为明文传输,攻击者已掌握以下信息:加密方程(算法)、密文(输出)以及公钥(一个输入变量)。按照一般理解,攻击者似乎可利用这些信息反向推导出原文。然而实际上,攻击者仅凭公钥无法加密得到原文,因为非对称加密的核心特性是:用公钥加密的内容,只能通过对应的私钥解密。

私钥仅由服务器持有,攻击者无法获取,因此只有服务器能够将密文还原为原文。

了解了“公钥加密、私钥解密”这一特性后,读者很可能会追问其背后的原理。要深入理解这一点,需要探究非对称加密的数学基础,其中涉及数论相关知识。下面我们以 RSA 算法为例进行说明。

RSA 是一种广泛使用的非对称加密算法,其本质是一个模幂运算公式:

( m e )   %   n = c (m^e) \ \% \ n=c (me) % n=c
其中,指数 e 与模数 n 组成的二元组 (e, n) 即为公钥;底数 m 是密钥(或明文); c 为密文。

在握手过程中,客户端向服务器请求公钥,服务器生成公钥 (e, n) 与私钥 d ,私钥由服务器保管,公钥则发送给客户端。客户端收到公钥后,随机生成一个密钥 m ,利用公钥对其进行加密:

( m e )   %   n = c (m^e) \ \% \ n=c (me) % n=c
所得密文 c 发送至服务器,服务器再用私钥 d 解密,还原出密钥 m 。

接下来的关键,在于私钥 d 的形式及其解密机制。私钥 d 满足以下关系:

( d ∗ e )   % ϕ ( n ) = 1 (d*e) \ \% ϕ(n)=1 (d∗e) %ϕ(n)=1
其中 ϕ(n)为欧拉函数,其含义将在后文说明。由于模 ϕ(n) 余数为 1,上式等价于存在整数 k 使得:

d ∗ e = k ∗ ϕ ( n ) + 1 d*e=k *ϕ(n)+1 d∗e=k∗ϕ(n)+1
服务器收到密文 c 后,使用私钥 d 进行如下解密运算:

( c d )   %   n (c^d) \ \% \ n (cd) % n
由于c = (m^e) % n ,代入上式可得:

( c d )   %   n = ( ( ( m e )   % n ) d )   %   n (c^d) \ \% \ n=(((m^e) \ \%n)^d) \ \% \ n (cd) % n=(((me) %n)d) % n
而这里需要注意,内层括号中对 m^e 取模 n 后,该结果又被整体作为底数进行 d 次乘方(即乘以自身 d-1 次)。我们可以将其理解为:将 (( m^e) % n) 这个结果连续自乘 d 次,然后再对最终的乘积取模 n 。用公式表示即:
( ( m e   % n ) ∗ ( m e   % n ) ∗ . . . . . . . . ∗ ( m e   % n ) )   % n ((m^e\ \%n)*(m^e \ \%n)*........*(m^e \ \%n))\ \%n ((me %n)∗(me %n)∗........∗(me %n)) %n
这里需要明确:对一个数进行一次模 n 运算后,其结果已经落在 0 到 n-1 的范围内,再次对它进行模 n 运算并不会改变其值,即 (a % n) % n = a % n。因此,表达式中的每个因子实际上只需进行一次模 n 运算,重复的模运算可以被省略。于是,上式等价于:
( m e ∗ m e ∗ m e ∗ . . . . . ∗ m e )   % n = ( m e d )   % n (m^e*m^e*m^e*.....*m^e)\ \%n=(m^{ed})\ \%n (me∗me∗me∗.....∗me) %n=(med) %n
然后公式被简化为
( m e d )   % n (m^{ed})\ \%n (med) %n
由于上文已得到关系式 e*d=k *ϕ(n)+1 ,可将其代入指数部分,从而将原式等价变换为:

m k ⋅ ϕ ( n ) + 1   % n = ( m k ⋅ ϕ ( n ) ∗ m )   % n m^{k \cdot \phi(n) + 1} \ \%n=(m^{k \cdot \phi(n)}*m)\ \%n mk⋅ϕ(n)+1 %n=(mk⋅ϕ(n)∗m) %n
而解密过程的关键,在于理解欧拉函数 ϕ(n) 的含义及其相关定理。欧拉函数 ϕ(n)定义为:在 1 到 n 的整数中,与 n 互质的数的个数。解密的数学原理正是基于欧拉定理:

( m ϕ ( n ) )   %   n = 1 (m^{ϕ(n)}) \ \% \ n = 1 (mϕ(n)) % n=1
欧拉定理指出:若整数 m 与 n 互质,则 m 的 ϕ(n) 次方与 n 取模的结果恒等于 1 。该定理的严格证明涉及数论知识,鉴于本文重点在于阐述加密原理而非数学推导,此处不予展开。对程序员而言,重要的是理解并应用其结论。

我们回到之前的等式:

( m k ⋅ ϕ ( n ) ∗ m )   % n (m^{k \cdot \phi(n)}*m)\ \%n (mk⋅ϕ(n)∗m) %n
我们可以将等式进一步转换:

( m k ⋅ ϕ ( n ) ∗ m )   % n = ( ( m k ⋅ ϕ ( n )   % n ) ) ∗ ( m   % n ) = ( ( m ϕ ( n ) ) k   % n ) ) ∗ ( m   % n ) (m^{k \cdot \phi(n)}*m)\ \%n=((m^{k \cdot \phi(n)}\ \%n))*(m\ \%n)=((m^{\phi(n)})^k \ \%n))*(m\ \%n) (mk⋅ϕ(n)∗m) %n=((mk⋅ϕ(n) %n))∗(m %n)=((mϕ(n))k %n))∗(m %n)
最后,借助欧拉定理以及模运算的分配律,我们将等式化为最终形式:

( ( m ϕ ( n ) )   % n ) ∗ ( ( m ϕ ( n ) )   % n ) ∗ . . . . . ∗ ( ( m ϕ ( n ) )   % n ) ∗ ∗ ( m   % n ) = m   % n ((m^{\phi(n)})\ \%n)*((m^{\phi(n)})\ \%n)*.....*((m^{\phi(n)})\ \%n)**(m\ \%n)=m\ \%n ((mϕ(n)) %n)∗((mϕ(n)) %n)∗.....∗((mϕ(n)) %n)∗∗(m %n)=m %n
这里有一个细节需要注意:在模 n的运算世界里,所有的数字最终都会被‘收束’到 0到 n-1的范围内。因此,RSA 算法要求客户端随机生成的密钥 m必须小于 n。这就好比我们要在一个刻度为 n的量筒里装水,如果你的明文 m小于 n,它能完整地装进量筒里,解密时我们看一眼刻度,就能原封不动地读出 m。如果水(明文 m)溢出了量筒,服务端解密时就只能看到‘溢出’的那部分,而无法还原出原始的水量了。比如一个刻度只有 100 的量筒(n=100)。如果你往里倒入 105 升水(m=105),最终你看到的刻度是 5。

不过不用担心,在实际应用中,模数 n 通常是一个极其巨大的数字(2048 位),它大到足以容纳任何我们需要的对称密钥。

最后服务器使用私钥 d 对密文 c 进行模幂运算,即可准确还原出客户端生成的密钥 m:
( c d )   %   n = m (c^d) \ \% \ n=m (cd) % n=m
在 RSA 加密中,模数 n 通常由两个大质数 p 和 q 相乘得到,即 n = p *q 。实际应用中, n 的位数一般为 2048 位,以确保足够的安全性。欧拉函数 ϕ(n) 表示在 1 到 n 之间与 n 互质的正整数的个数。为了计算 ϕ(n) ,我们可以从总数中减去与 n 不互质的数的个数。

由于 n = p *q ,在 1 到 n 的范围内,与 n 不互质的数即为 p 或 q 的倍数:

  • p 的倍数有: p, 2p, 3p,…, qp ,共 q 个。
  • q 的倍数有: q, 2q, 3q, …, pq ,共 p 个。

需要注意的是, pq 同时出现在两个序列中,因此被重复计算了一次。故与 n 不互质的数的总数为 p + q - 1 。

由于从 1 到 n 共有 n = p * q 个整数,因此与 n 互质的数的个数为:

p ∗ q − ( p + q − 1 ) = p ( q − 1 ) − q + 1 = p ( q − 1 ) − ( q − 1 ) = ( p − 1 ) ( q − 1 ) p*q-(p+q-1)=p(q-1)-q+1=p(q-1)-(q-1)=(p-1)(q-1) p∗q−(p+q−1)=p(q−1)−q+1=p(q−1)−(q−1)=(p−1)(q−1)
因此,欧拉函数 ϕ(n) = (p-1)(q-1) 。该结果在 RSA 算法的密钥生成与解密过程中起着核心作用。

安全性方面,攻击者即使获取公钥 (e, n) 与密文 c ,并知晓加密公式 c = (m^e) % n ,仍难以破解出 m 。原因如下:

  1. 直接求解方程:由 m^e = n k + c ,需枚举 k 并计算 e 次方根,计算量极大,且无法验证所得 m 是否为真实密钥。
  2. 尝试推导私钥:攻击者需从 n 分解出 p 和 q 以计算 ϕ(n) ,进而通过(d*e) % ϕ(n)=1 得到私钥 d 。然而将大整数 n (例如 2048 位)分解为两个质因数,在当前计算能力下不可行。

因此,非对称加密算法是安全的。总结其与对称加密的区别:非对称加密使用不同的密钥进行加密与解密,且加密与解密对应不同的二元函数。加密过程可抽象为:

加密: f ( 原文,公钥 ) = 密文 加密:f(原文,公钥)=密文 加密:f(原文,公钥)=密文

解密: g ( 密文,私钥 ) = 原文 解密:g(密文,私钥)=原文 解密:g(密文,私钥)=原文

其中 f 与 g 为不同的变换函数。


对称加密

在学习了非对称加密算法的原理后,上文曾提到客户端与服务端在正式通信之前会先进行握手协商。在这一过程中,双方首先协商加密算法,随后协商密钥。服务端会向客户端发送一个公钥,客户端使用该公钥加密一段信息,并将密文发送回服务端。

然而,这里读者容易产生一个疑问:此时被加密的并非 HTTP 请求报文,而是一个密钥。既然非对称加密是一种加密算法,理论上应可直接用于加密报文,为何要先加密密钥呢?

我们知道,非对称加密算法本质上是模幂运算。HTTP 请求报文中可能包含正文,若正文为文件,则报文体积可能很大。由于待加密数据的大小不能超过模数n ,因此需将报文对应的二进制序列分割为多个数据块( m1, m2, … ),并对每个数据块分别进行模幂运算。该过程计算量很大,非常耗时,因此非对称加密不适用于直接加密完整报文。

为此,实际中常结合非对称加密与对称加密。客户端与服务端在握手协商阶段确定加密算法后,客户端会向服务端请求公钥。服务端生成一对公钥和私钥,私钥自行保存,公钥发送给客户端。客户端随机生成一个小于 n 的对称密钥,用公钥加密后发送给服务端。服务端收到密文后,用私钥解密,至此双方均获得该对称密钥

对称密钥的作用在于后续的对称加密通信。前文提到,对称加密的安全性取决于密钥的保密性。若在握手阶段以明文协商该密钥,第三方可能截获密钥。而非对称加密的作用,正是确保该对称密钥仅由客户端与服务端知晓,避免泄露。双方获得对称密钥后,即可开始加密通信。

以主流 AES对称加密算法为例,客户端随机生成一个128位 的对称密钥。加密时,首先将报文对应的二进制序列按 128位16字节)分块,每块转换为一个4x4矩阵(即用二维数组存储 128 位数据)。这是对称加密的第一步,称为矩阵变换

矩阵变换后,进行行移位操作,目的是打乱原始报文的逻辑结构。由于 HTTP 请求与响应格式固定,攻击者可基于此进行频率分析等破解尝试。行移位以行为单位:第一行保持不动,第二行循环左移一字节,第三行循环左移两字节,第四行循环左移三字节。每行可视为环形结构,例如第二行左移后,行首元素移至行尾,后续元素依次前移。

[s00,s01,s02,s03][s00,s01,s02,s03][s10,s11,s12,s13][s11,s12,s13,s10][s20,s21,s22,s23]-------->[s22,s23,s20,s21][s30,s31,s32,s33][s33,s30,s31,s32]

接下来进入列混淆步骤。行移位在水平方向上打乱数据,列混淆则在竖直方向上进一步混淆。该步骤对每一列乘以一个固定的矩阵,即进行矩阵乘法运算:4×4 矩阵与 4×1 列向量相乘,得到新的 4×1 列向量,从而彻底消除原有矩阵的特征。

在这里插入图片描述

最后是轮密钥加步骤,即将 128 位的对称密钥转换为 4×4 矩阵,与列混合后的矩阵按位进行异或运算,得到最终的密文

攻击者可能截获对称加密的密文,并且了解对称加密的算法,理论上可通过逆过程还原原文。但由于缺少对称密钥(该密钥仅由客户端与服务端持有,不公开),攻击者无法解密。服务端收到密文后,通过再次异或运算(利用“同一值异或两次等于原值”的性质),然后依次执行逆操作,最终恢复出原始报文。


中间人攻击以及数字证书

读到这里,我们可能认为非对称加密结合对称加密已是最优方案,理论上足够安全。然而,实际部署中该方案仍存在缺陷,这正是接下来要讨论的主题——中间人攻击

我们知道,这里采用非对称加密配合对称加密的方式。如果黑客试图直接破解密文,那么非对称加密与对称加密的结合几乎无懈可击。根据非对称加密的原理,只有服务器持有私钥,黑客没有私钥,因此根本无法解密密文。

于是黑客采取一种更巧妙的方式:在客户端与服务端握手协商的阶段进行介入。该阶段中,客户端与服务端之间以明文通信,服务端会与客户端协商加密算法,并将自己生成的公钥发送给客户端。黑客可以在数据包路由的某个中间节点截获这个数据包,扮演“中间人”的角色。截获数据包后,黑客会伪装成服务器。由于非对称加密算法是公开的,中间人可以自行生成一对公钥私钥,其中公钥是一个二元组 (e,n)。接着,中间人将自己伪造的公钥发送给客户端。

客户端收到公钥后,会理所当然地认为它来自真正的服务端。客户端仍按正常流程生成一个128位的随机对称密钥,但此时它是用伪造的“服务端”公钥对该对称密钥进行加密,并将加密后的密文发送出去。

中间人再次截获客户端发送的密文。利用非对称加密的特性,中间人使用自己的私钥解密密文,从而获得客户端生成的对称密钥。为了不被双方察觉,中间人下一步会使用之前截获的真正服务端的公钥,重新加密这个对称密钥,并将新生成的密文发送给服务端——此刻,中间人又伪装成了“客户端”。服务端收到密文后,不会意识到它已被中间人解密过。之后,客户端与服务端进入正常的加密通信阶段,使用该对称密钥进行后续的请求与响应报文的加密传输。然而实际上,双方并未意识到他们的通信并未真正加密,一直处于“裸奔”状态。这就是大名鼎鼎的中间人攻击:中间人站在服务端与客户端之间,对两端进行欺骗,手法十分巧妙。

了解中间人攻击的原理后,要解决它,首先需分析其成功的根本原因。本质在于,客户端无法判断服务端发来的公钥是否合法

因此,防范中间人攻击需要引入数字证书

具体来说,服务端会向权威的第三方机构(CA数字证书认证机构)申请证书。申请时需准备一些材料,包括域名组织信息地理位置等身份资料,以及服务端自己生成的公钥(私钥自行保存)。这些内容会被打包成一个 CSR 文件,提交给 CA 机构。CA 作为可信机构,会对提交的信息进行审核,例如验证域名是否属于该公司、公司信息是否真实,有时甚至实地走访。审核通过后,CA 会使用指定的哈希算法(如 SHA-256)对 CSR 文件进行计算,生成一个固定长度的值,即数字摘要。接着,CA 会使用自己的私钥对该摘要进行加密,得到数字签名。最后,CA 将原始 CSR 文件(即原文)与该数字签名合并,形成数字证书

现在我们对之前的流程进行修正:在客户端与服务端的握手协商环节,服务端不再仅明文发送公钥,而是发送数字证书。接下来,我们从黑客的视角来看,数字证书如何完美防御中间人攻击。

黑客仍可截获握手报文,其中包含数字证书。该证书由原文和数字签名两部分组成。如果黑客尝试修改原文——比如将公钥替换为自己生成的公钥,那么当客户端收到被篡改的数字证书后,会首先检查证书是否被篡改。客户端会分离出原文和数字签名,并使用原文中注明的哈希算法对原文进行计算,得到一个哈希值 H1。同时,客户端(如浏览器)内置了多家权威 CA 的公钥,可利用对应 CA 的公钥对数字签名进行解密,得到哈希值 H2(即原始摘要)。接着,客户端比较 H1 和 H2 是否相等。在上述篡改情形下,两者显然不相等,客户端立即判定证书已被篡改

哈希原文 → 得到 H1。 解密签名 → 得到 H2。 对比结果:H1 != H2。 

如果黑客尝试同时修改原文和数字签名呢?需注意,CA 的公钥是公开的,黑客可获取该公钥并对数字签名进行解密,得到摘要(即一个哈希值)。然而哈希算法并非可逆的加密算法,无法从摘要反推原始内容。哪怕黑客拿到了 CA 的公钥并解密了签名,他看到的也只是一个 摘要(Digest)(比如一串 256 位的二进制数)。这串数字就像是把一本书扔进粉碎机后吐出来的纸屑。黑客看着纸屑,根本不可能反推出书中原本的公钥长什么样。更令黑客绝望的是,解密数字签名虽然容易,但要重新生成一个有效的签名却几乎不可能。因为他可以将数字签名解密得到摘要,但若想将篡改后的内容“加密”回一个合法的签名,则必须使用 CA 的私钥——而该私钥是不公开的。这意味着黑客没有能力为其篡改后的内容重新生成有效的数字签名,这对黑客来说构成了一个无法逾越的障碍。对客户端来说,收到证书后计算原文哈希,并对数字签名尝试解密(甚至可能解密失败),得到的结果一定与 H1 不同。

黑客或许还会想到第三种方式:既然无法伪造证书,何不向 CA 申请一个合法的证书?黑客可以使用自己掌控的域名和真实信息向 CA 申请证书,获得一个“正规”的数字证书,然后将这个真证书发给客户端。客户端用 CA 公钥验证时,签名是有效的,这样是否就能欺骗客户端呢?

这里需要注意,客户端验证证书时不仅仅比较哈希值,还会检查证书原文中的内容,尤其是域名等信息。黑客申请证书时,其填写的域名只能是其自己拥有的域名,而无法使用如www.baidu.com 等他人的域名。因此,客户端在验证时,仅通过域名一项即可识别证书与当前访问的网站不匹配,无需比较哈希值就能判定证书是被调包过的。


读者可能还有一个疑问:我们知道客户端与服务端在正式通信前会进行握手协商。当客户端向服务端获取公钥时,服务端会发送一个数字证书,客户端可通过该证书验证公钥的合法性,即确认证书是否被篡改。但数字证书并非在握手一开始就传递,而是在传递公钥的阶段才发送。

客户端与服务端之间会进行TLS握手,其中第一次握手的主要目的是协商加密算法。客户端会向服务端发送一个握手报文,其中包含客户端支持的所有加密算法列表。服务端收到该请求后,会选择一个强度最高的加密算法(例如RSA+AES),并通过响应报文告知客户端,双方随后确认使用该算法。

然而,黑客除了可能窃取服务端发送给客户端的公钥之外,还可能劫持该握手报文,实施“降级攻击”。即黑客故意将算法列表篡改为一些强度较低的加密算法。由于此握手阶段尚未涉及数字证书,因此攻击者可能成功实施降级攻击。那么,这个问题是如何解决的呢?

这就需要详细说明TLS握手的完整环节。TLS握手可概括为三次交互:

  1. 第一次握手:协商加密算法。
  2. 第二次握手:客户端获取服务端的公钥,服务端获取客户端生成的对称密钥
  3. 第三次握手:客户端将此前所有握手报文内容汇总发送给服务端进行验证。

解决降级攻击的方式,本质上是一种“后验”机制:即便第一次握手的内容可能被篡改,但从第二次握手开始,数字证书已参与流程,保证了后续通信的可信。在第三次握手中,客户端生成对称密钥(如AES密钥)后,会将从第一步开始到当前所有收发过的报文内容(包括可能被篡改的算法列表)整体计算一个哈希值,并使用协商好的会话密钥对该哈希值进行加密,然后发送给服务端。

服务端收到后,使用相同的会话密钥解密,并基于自己记录的原始握手报文独立计算哈希值,再与客户端发来的哈希值进行比较。如果两者不一致,服务端即可判断第一次握手的内容曾被篡改,从而主动终止连接

然而,即便数字签名、非对称加密与对称加密相结合已构成相当完备的安全机制,现实中仍不存在绝对的完美,任何体系都可能存在可被利用的薄弱环节。例如,攻击者可以注册一个与合法网站相似的域名,并为该域名申请一张正规且受信任的数字证书。假设攻击者的域名为www.hack_baidu.com ,其页面设计也完全模仿百度,那么用户在输入时若稍有疏忽,就可能误访问该钓鱼网站,进而与其建立“安全”连接,导致敏感信息泄露。

此外,攻击者的目标未必仅限于客户端,也可能指向服务端。攻击者可伪装成合法的客户端(例如模拟浏览器行为),凭借其拥有的有效证书与服务端建立 TLS 连接。一旦连接建立,攻击者便可能以看似合法的身份与服务端通信,进而实施针对服务端的各类攻击,例如窃取业务数据、尝试注入攻击或探测服务端漏洞。这说明,仅依赖证书本身的合法性,并不能完全防范身份仿冒与意图恶意的连接。

HTTPS服务器

在掌握HTTPS基本原理的基础上,接下来我们便可动手实现一个HTTPS服务器。实现该服务器无需从头编写所有代码,只需在原有HTTP服务器基础上增加一层“加密层”即可。数据包在发送前需经过加密处理,而我们无需自行实现加密算法,此处引入第三方库——OpenSSL库。OpenSSL库主要由两个核心部分组成:libcrypto,其中包含多种加密算法;以及libssl,其实现了完整TLS协议逻辑的相关模块。

HTTPS本质上是基于HTTP,在应用层对报文进行加密传输。由于HTTP基于TCP传输协议,因此对于HTTPS,客户端首先仍需与服务器建立TCP连接,完成三次握手。连接建立后,双方进行TLS握手,成功后正式进入加密通信阶段。在TLS握手过程中,第二次握手(即客户端向服务端请求公钥)时,服务器会向客户端发送数字证书,客户端借此验证公钥的合法性,即检查数字证书是否被篡改。申请合法数字证书通常需准备包含域名、公司信息、地理位置等身份材料及公钥,并打包成CSR文件,由CA机构审核(常涉及费用)。对学生群体而言,通常没有自己的域名,且该审核流程较为繁琐,但HTTPS服务器确实需要数字证书。

为模拟真实的HTTPS服务器环境,此处我们将“扮演”CA机构,使用自签名证书。这里需要使用openssl命令完成以下三件事:首先,伪造一个“CA”机构身份,生成CA私钥及其自签名证书;其次,生成服务器自身的公钥和私钥对,并填写域名等身份信息,将这些信息与公钥打包成CSR文件;最后,用之前生成的CA私钥对该CSR文件进行签名,生成最终的数字证书。

openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365

该命令会在当前工作目录下生成server.crtserver.key两个文件,分别包含带有原文及数字签名的数字证书,以及服务器的私钥。

完成准备工作后,接下来进入代码编写阶段。此前,我们将服务器抽象为https_server类,将与通信相关的系统调用封装到该类的成员函数。在https_server类中,除包含string类型的IP地址、整型的端口号及bool类型的监听标志外,还会包含一个SSL_CTX类型的成员变量。

SSL_CTX类型可理解为一个全局配置对象,其中包含底层支持的加密算法列表、数字证书、公钥及私钥等信息。此处使用指针而非对象本身,主要因SSL_CTX包含大量加密相关配置信息,若在https_server内直接维护对象,会显著增加服务器对象的体积。此外,我们需包含openssl/ssl.h头文件,其中声明了该全局配置对象。由于该头文件仅包含声明,定义需在链接阶段与库文件连接时确定,在编译阶段编译器无法得知对象大小,故此处使用指针。指针大小仅取决于机器位数(32位系统为4字节,64位为8字节),指针指向的实际对象在运行时动态分配。除了SSL_CTXopenssl/ssl.h还声明了后续会使用的会话实例SSL对象及SSL_METHOD等。

由于该对象为全局配置,而我们的HTTPS服务器设计采用了线程池模型,在获取新连接后会构造Task对象,将其放入缓冲区供消费者线程执行run函数。run函数的上下文即为加密通信环节。每个会话实例在通信前,均需读取SSL_CTX内部信息以获取公钥、数字证书等属性,这意味着每个Task对象内部理论上需维护一个SSL_CTX对象。然而,HTTPS服务器可能同时接收大量新连接,创建大量Task对象。若每个Task对象都持有SSL_CTX对象的副本,将造成巨大的内存开销。因此,这里采用指针形式共享同一个SSL_CTX对象,且该对象在多个线程间只读不写,是线程安全的。

因此,在https_server中我们将维护一个SSL_CTX*类型的成员变量。

std::string _default ="0.0.0.0";extern log lg;classHttps_server{public:Https_server(std::string _ip = _default,uint16_t _port =80):ip(_ip),port(_port),islistening(false){}// ...private:uint16_t port; std::string ip; sock listen_socket;bool islistening; SSL_CTX* ctx;};

在之前实现的HTTP服务器中,httpserver对象的init函数负责创建监听套接字、绑定IP地址及端口号。在https_server对象中,除了完成上述操作,首先还需在堆上创建一个SSL_CTX对象,即调用SSL_CTX_new函数。该函数接收一个SSL_METHOD*参数,用于决定服务器使用的协议版本及运行角色(客户端或服务器):

  • TLS_server_method()(推荐):这是目前OpenSSL中最通用的函数,会自动协商双方支持的最高TLS版本(如TLS 1.2或TLS 1.3)。
  • TLSv1_2_server_method():强制只使用TLS 1.2版本。
  • SSLv23_server_method():这是旧版OpenSSL使用的函数(现已废弃),用于兼容旧协议。

由于我的CentOS服务器版本较旧,所附带的OpenSSL库也是旧版本,因此此处只能传递SSLv23_serevr_method()。在此情况下,SSL_CTX_new函数不会自动加载加密算法库及错误描述符字符串,需手动调用相关函数进行加载。若使用支持新版OpenSSL的Linux系统,传递TLS_server_method()即可,该函数会自动完成上述加载工作。

SSL_library_init();// 初始化加密算法库SSL_load_error_strings();// 加载错误描述字符串 ctx =SSL_CTX_new(SSLv23_server_method());

调用SSL_CTX_new后,需检查返回值,若失败则返回nullptr,此时应打印错误信息并退出。OpenSSL库维护了一个错误栈,相关函数的错误码均记录其中。我们需要定义一个整型变量保存从栈顶弹出的错误码,即调用ERR_get_error函数(声明于openssl/err.h),随后将该错误码转换为人类可读的字符串,可通过ERR_error_string_n函数实现,之后记录日志并退出。

成功创建SSL_CTX对象后,接下来将目录中的server.crt(数字证书)和server.key(私钥)加载到该对象中。这需要分别调用SSL_CTX_use_certificate_fileSSL_CTX_use_PrivateKey_file函数。这两个函数均接收三个参数:第一个为SSL_CTX*,第二个为文件路径,第三个为文件类型。加载证书和私钥时,最常用的格式是PEM,因此该参数通常为:

  • SSL_FILETYPE_PEM:最常见的格式,文件内容以"-----BEGIN CERTIFICATE-----"开头,采用Base64编码。之前通过openssl命令生成的.crt.key文件默认即为此格式。
  • SSL_FILETYPE_ASN1(亦称DER):二进制格式。若证书文件为.der结尾的原始二进制流,则需使用此参数。

这两个函数的返回值:1表示成功,≤0表示失败,失败时需记录日志并退出。

最后,还需调用SSL_CTX_check_private_key函数,接收一个SSL_CTX*参数,用于检查数字证书与私钥是否匹配。因为证书中包含公钥,而server.key包含私钥,该函数会提取两者并进行数学校验。返回1表示成功,0表示失败。因此,init函数将依次调用上述接口并传递正确参数。

voidinit(){SSL_library_init();// 初始化加密算法库SSL_load_error_strings();// 加载错误描述字符串 listen_socket.socket(); listen_socket.bind(ip, port); ctx =SSL_CTX_new(SSLv23_server_method());if(ctx ==nullptr){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_new error: %s", errbuf);exit(1);}if(SSL_CTX_use_certificate_file(ctx,"server.crt", SSL_FILETYPE_PEM)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_use_certificate_file error: %s", errbuf);exit(1);}if(SSL_CTX_use_PrivateKey_file(ctx,"server.key", SSL_FILETYPE_PEM)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_use_PrivateKey_file error: %s", errbuf);exit(1);}if(SSL_CTX_check_private_key(ctx)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_check_private_key error: %s", errbuf);exit(1);}}

后续的start函数负责接收新连接、构建Task对象并放入缓冲区,这部分逻辑与之前实现的HTTP服务器一致,此处不再赘述。

接下来,让我们聚焦于 Task 对象的 run 函数。我们知道,run 函数的核心是实现加密通信的上下文。在此场景下,由于通信是加密的,因此不能直接使用已连接套接字对应的文件描述符,以及 read 或 recv 等接口进行通信。此时,通信的句柄应为 SSL 对象。

首先,需要调用SSL_new 创建一个 SSL 对象。该函数会从 SSL_CTX 对象中读取相关配置信息,其中包括数字证书和私钥。接着,调用SSL_set_fd 函数,它接受两个参数:SSL 对象和文件描述符。此操作相当于将该文件描述符交由 SSL 对象管理。

随后,应调用SSL_accept 执行 TLS 握手,并检查其返回值。若返回值为 1,表示握手成功;若返回值小于等于 0,则表示失败。若握手失败,需释放相关资源,即 SSL 对象和套接字。注意释放顺序:应先释放 SSL 对象,再关闭套接字。这是因为 SSL 对象在清理过程中可能需要访问套接字,向对端发送加密的关闭连接通知。如果先关闭套接字,会导致错误。SSL 对象的清理应通过SSL_shutdown 函数实现。

此外,在读取已完成的 TCP 报文时,不应再使用recv 接口,而应调用SSL_read 函数。该函数接受三个参数:SSL 对象、缓冲区及要读取的长度。SSL_read在底层会通过文件描述符读取加密的完整 TCP 报文,并执行解密操作,将明文存入缓冲区。其返回值为实际读取的字节数,若返回值小于等于 0,表示调用失败或连接已关闭。

同理,发送数据时也不应使用writesend 接口,而应使用SSL_write 函数。它接受 SSL 对象、缓冲区及要写入的字节长度作为参数,会将明文加密后再发送。返回值是成功写入的明文字节数,小于等于 0 则表示调用失败。

classTask{public:Task():socketfd(-1){}Task(int _socketfd,SSL_CTX* _ctx):socketfd(_socketfd),ctx(_ctx){}Task(int _socketfd,SSL_CTX* _ctx):socketfd(_socketfd),ctx(_ctx){}voidrun(){ SSL* ssl=SSL_new(ctx);SSL_set_fd(ssl,socketfd);if(SSL_accept(ssl)<=0){unsignedlong err=ERR_get_error();char errbuf[256];ERR_error_string_n(err,errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_accept error:%s",errbuf);SSL_free(ssl);close(socketfd);return;} Http_Request hr;bool get_result =Get_HttpRequest(ssl,hr);if(get_result ==false){ lg.logmessage(Fatal,"get http request error");SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;} std::string res;if(hr.method =="GET"){ res=Http_Get_Handler(hr);}elseif(hr.method =="POST"){ res=Http_Post_Handler(hr);}else{ lg.logmessage(warning,"unsupported method:%s",hr.method.c_str());SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;}int send_bytes=SSL_write(ssl,res.c_str(),res.size());if(send_bytes<0){ lg.logmessage(Fatal,"SSL_write error");SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;}SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);}private:int socketfd; SSL_CTX* ctx;static std::unordered_map<std::string, std::string> map;};

至此便是https服务器实现原理的全部讲解

源码

Https_server.hpp:

#pragmaonce#include"Socket.hpp"#include"log.hpp"#include"Threadpool.h"#include<arpa/inet.h>#include<netinet/in.h>#include<string>#include<cstring>#include<functional>#include<openssl/ssl.h>#include<openssl/err.h> std::string _default ="0.0.0.0";extern log lg;classHttps_server{public:Https_server(std::string _ip = _default,uint16_t _port=443):ip(_ip),port(_port),islistening(false){}// 析构函数,用于释放HTTPS服务器相关的资源~Https_server(){// 检查SSL上下文指针是否有效if(ctx!=nullptr){// 释放SSL上下文资源SSL_CTX_free(ctx);// 将指针置为nullptr,防止悬垂指针 ctx=nullptr;}}/** * 初始化SSL服务器环境 * * 该函数负责初始化SSL服务器所需的所有组件,包括: * 1. 初始化OpenSSL库 * 2. 创建并配置监听socket * 3. 创建SSL上下文 * 4. 加载服务器证书和私钥 * 5. 验证证书和私钥的匹配性 * * 如果在任何步骤中发生错误,函数会记录错误信息并退出程序。 */voidinit(){// 初始化SSL库,这是使用OpenSSL其他函数的前提SSL_library_init();// 加载SSL错误字符串,便于后续错误信息的可读化输出SSL_load_error_strings();// 创建并初始化监听socket listen_socket.socket();// 将socket绑定到指定的IP地址和端口 listen_socket.bind(ip, port);// 创建新的SSL上下文,使用SSLv23_server_method()表示支持多种SSL/TLS版本 ctx =SSL_CTX_new(SSLv23_server_method());if(ctx ==nullptr){// 获取OpenSSL错误码unsignedlong err =ERR_get_error();char errbuf[256];// 将错误码转换为可读的错误信息ERR_error_string_n(err, errbuf,sizeof(errbuf));// 记录致命错误并退出程序 lg.logmessage(Fatal,"SSL_CTX_new error:%s", errbuf);exit(1);}// 加载服务器证书文件if(SSL_CTX_use_certificate_file(ctx,"server.crt", SSL_FILETYPE_PEM)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_use_certificate_file error:%s", errbuf);exit(1);}// 加载服务器私钥文件if(SSL_CTX_use_PrivateKey_file(ctx,"server.key", SSL_FILETYPE_PEM)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_use_privateKey_file error:%s", errbuf);}// 验证私钥是否与证书匹配if(SSL_CTX_check_private_key(ctx)<=0){unsignedlong err =ERR_get_error();char errbuf[256];ERR_error_string_n(err, errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_CTX_check_private_key error:%s", errbuf);exit(1);}}/** * 启动服务器监听功能 * 该方法用于启动服务器,开始接受客户端连接 */voidstart(){// 开始监听socket listen_socket.listen();// 检查服务器是否已经在监听状态if(islistening){// 如果已在监听,记录警告日志并返回 lg.logmessage(warning,"server is already listening");return;}// 设置监听状态为true islistening =true;// 获取线程池单例实例并启动 threadpool& tp = threadpool::getinstance(); tp.start();// 定义客户端地址结构体structsockaddr_in client;// 设置客户端地址长度 socklen_t client_len =sizeof(client);// 清零客户端地址结构体memset(&client,0, client_len);// 循环接受客户端连接while(islistening){// 接受新的客户端连接 size_t client_fd=listen_socket.accept(&client,&client_len);// 创建新任务 Task t(client_fd,ctx);// 将任务添加到线程池 tp.push(t);}}private:uint16_t port; std::string ip; sock listen_socket;bool islistening; SSL_CTX* ctx;};

Https_server.cpp:

#include"https_server.hpp"#include"log.hpp"#include<string>extern log lg;voidusage(std::string progmaname){ std::cout <<"usage wrong: "<< progmaname <<" <port>"<< std::endl;}/** * 程序主入口函数 * * 该函数实现了一个简单的HTTPS服务器启动逻辑: * 1. 验证命令行参数 * 2. 解析端口号 * 3. 创建并初始化HTTPS服务器 * 4. 启动服务器 */intmain(int argc,char* argv[]){// 检查命令行参数数量是否正确// 正确格式应为:./程序名 端口号if(argc !=2){// 如果参数数量不对,打印使用说明并退出usage(argv[0]);exit(-1);}// 将字符串形式的端口号转换为无符号16位整数uint16_t port = std::stoi(argv[1]);// 创建HTTPS服务器实例// _default: "0.0.0.0"表示监听所有网络接口// port: 监听端口号 Https_server hs(_default, port);// 初始化服务器(包括SSL上下文、socket等) hs.init();// 启动服务器,开始监听和处理请求 hs.start();// 正常退出程序return0;}

Task.hpp:

#pragmaonce#include<functional>#include<string>#include<sys/types.h>#include<unistd.h>#include<fstream>#include<unordered_map>#include"protocol.hpp"#include"log.hpp"#include<openssl/ssl.h>#include<openssl/err.h>#defineBUFFER_SIZE1024extern log lg; std::string path="./wwwroot";/** * 从SSL连接中读取并解析HTTP请求 * * ssl SSL连接对象指针,用于安全通信 * hr HTTP请求对象的引用,用于存储解析后的请求信息 * bool 解析成功返回true,失败返回false * * 该函数实现以下功能: * 1. 从SSL连接读取HTTP请求数据 * 2. 解析请求头和请求体 * 3. 处理Content-Length字段 * 4. 将解析结果存储到Http_Request对象中 */boolGet_HttpRequest(SSL* ssl, Http_Request& hr){ std::string data;// 存储完整的HTTP请求数据char buffer[BUFFER_SIZE];// 临时缓冲区// 循环读取数据直到找到完整的HTTP头部(以\r\n\r\n结尾)while(true){ ssize_t read_bytes =SSL_read(ssl, buffer, BUFFER_SIZE -1);if(read_bytes <=0){ lg.logmessage(Fatal,"SSL_read error");returnfalse;}// 将读取的数据追加到总数据中 data.append(buffer, read_bytes);// 检查是否已读取完整的HTTP头部if(data.find("\r\n\r\n")!= std::string::npos){break;}}// 提取HTTP头部数据(包含\r\n\r\n) std::string head = data.substr(0, data.find("\r\n\r\n")+4);// 查找Content-Length字段,用于确定请求体大小 size_t pos = data.find("Content-Length:");if(pos != std::string::npos){// 找到Content-Length字段的结束位置 ssize_t endpos = head.find("\r\n", pos);// 提取Content-Length的值 std::string content_length_str = head.substr(pos +15, endpos - pos -15); size_t content_length = std::stoi(content_length_str);// 计算已读取的请求体大小 size_t remaining = data.size()- head.size();// 如果已读取的请求体小于Content-Length,需要继续读取if(remaining < content_length){// 提取已读取的部分请求体 std::string body = data.substr(data.find("\r\n\r\n")+4, remaining);// 计算还需要读取的字节数int to_read = content_length - remaining;char body_buffer[BUFFER_SIZE];// 循环读取剩余的请求体数据while(to_read >0){// 计算本次要读取的字节数 ssize_t bytes_to_recv = std::min(BUFFER_SIZE -1, to_read); ssize_t read_bytes =SSL_read(ssl, body_buffer, bytes_to_recv);if(read_bytes <=0){ lg.logmessage(Fatal,"SSL_read error");returnfalse;}// 将读取的数据追加到请求体中 body.append(body_buffer, read_bytes);// 更新剩余需要读取的字节数 to_read -= read_bytes;}// 保存完整的请求体 hr.text = body;}else{// 如果请求体数据已经完整,直接提取 hr.text = data.substr(data.find("\r\n\r\n")+4, content_length);}}// 反序列化HTTP头部bool res = hr.Deserialization(head);// 打印调试信息 hr.debugprint();// 返回解析结果return res;}/** * 读取文件内容到字符串 * * file_path 要读取的文件路径 * return std::string 返回文件内容,如果文件打开失败返回空字符串 * * 该函数实现以下功能: * 1. 以二进制模式打开文件 * 2. 获取文件大小 * 3. 一次性读取整个文件内容 * 4. 将文件内容作为字符串返回 * * 注意事项: * - 使用二进制模式打开文件,确保正确处理所有类型的文件 * - 如果文件打开失败,会记录日志并返回空字符串 * - 适合读取中小型文件,大文件可能需要考虑内存使用 */ std::string read_file(std::string file_path){// 以二进制模式打开文件,确保能正确处理所有类型的文件内容 std::ifstream file(file_path, std::ios::binary);if(!file.is_open()){// 如果文件打开失败,记录日志信息 lg.logmessage(info,"file not found:%s", file_path.c_str());// 返回空字符串表示读取失败return"";}// 记录文件当前位置(文件开头) std::streampos start = file.tellg();// 移动到文件末尾 file.seekg(0, std::ios::end);// 获取文件末尾位置 std::streampos end = file.tellg();// 计算文件大小 size_t file_size = end - start;// 创建适当大小的字符串用于存储文件内容 std::string content; content.resize(file_size);// 回到文件开头 file.seekg(0, std::ios::beg);// 一次性读取整个文件内容到字符串中 file.read(&content[0], file_size);// 关闭文件 file.close();// 返回文件内容return content;} std::string Http_Get_Handler(Http_Request& hr);/** * 处理错误请求,返回400 Bad Request响应 * * return std::string 返回HTTP格式的错误响应消息 */ std::string process_bad_request(){// 记录致命级别的错误日志,提示请求体有问题 lg.logmessage(Fatal,"bad request body");// 构造HTTP响应状态行 std::string headler_line="HTTP/1.0 400 Bad Request\r\n";// 构造HTTP响应头 std::string header="Connection: close\r\n";// 设置响应内容 std::string content="Bad Request";// 添加内容长度头 header+="Content-Length: "+std::to_string(content.size())+"\r\n";// 添加内容类型头 header+="Content-Type: text/plain\r\n";// 添加空行,表示头部结束 header+="\r\n";// 返回完整的HTTP响应(状态行+头部+内容)return headler_line+header+content;}/** * 处理HTTP请求中的计算操作 * * val 包含计算参数的哈希表,需要包含: * - "a": 第一个操作数 * - "b": 第二个操作数 * - "op": 运算符(支持URL编码格式) * result 计算结果的输出参数 * return bool 计算成功返回true,失败返回false * * 该函数实现以下功能: * 1. 从参数中提取两个操作数和运算符 * 2. 处理URL编码的运算符(如+号可能被编码为%2B) * 3. 执行四则运算 * 4. 处理除零等异常情况 * * 支持的运算符: * - 加法:+ 或 %2B/%2b * - 减法:- 或 %2D/%2d * - 乘法:* 或 %2A/%2a * - 除法:/ 或 %2F/%2f */boolprocess_calculation(std::unordered_map<std::string, std::string>& val,int& result){// 从参数中提取并转换操作数int a = std::stoi(val["a"]);int b = std::stoi(val["b"]);// 获取运算符 std::string op = val["op"];// 处理URL编码的运算符,统一转换为标准符号if(op =="+"|| op =="%2B"|| op =="%2b"){ op ="+";}elseif(op =="-"|| op =="%2D"|| op =="%2d"){ op ="-";}elseif(op =="*"|| op =="%2A"|| op =="%2a"){ op ="*";}elseif(op =="/"|| op =="%2F"|| op =="%2f"){ op ="/";}else{// 不支持的运算符,记录错误并返回失败 lg.logmessage(Fatal,"unsupported operator:%s", op.c_str());returnfalse;}// 根据运算符执行相应的计算switch(op[0]){case'+': result = a + b;break;case'-': result = a - b;break;case'*': result = a * b;break;case'/':// 检查除数是否为0if(b ==0){ lg.logmessage(warning,"division by zero");returnfalse;} result = a / b;break;}// 计算成功,返回truereturntrue;}/** * 处理HTTP POST请求的函数 * hr HTTP请求对象,包含URL和请求体等信息 * return 返回处理后的HTTP响应字符串 */ std::string Http_Post_Handler(Http_Request& hr){ std::string res;// 存储最终的HTTP响应 std::unordered_map<std::string, std::string> val;// 存储解析后的键值对 size_t start =0;// 用于标记字符串查找的起始位置// 检查请求URL是否为"/calc"if(hr.url =="/calc"){ std::string body = hr.text;// 获取请求体 size_t pos1 = body.find("&");// 查找第一个分隔符// 如果找不到分隔符,说明请求格式错误if(pos1 == std::string::npos){returnprocess_bad_request();}// 解析第一个键值对 std::string expression = body.substr(start, pos1); size_t pos2 = expression.find("=");// 查找等号// 如果找不到等号,说明请求格式错误if(pos2 == std::string::npos){returnprocess_bad_request();}// 提取键和值,并存入map中 std::string result_key_str = expression.substr(start, pos2); std::string result_value_str = expression.substr(pos2 +1); val[result_key_str]= result_value_str;// 更新查找起始位置 start = pos1 +1; pos1 = body.find("&", start);// 如果找不到分隔符,说明请求格式错误if(pos1 == std::string::npos){returnprocess_bad_request();}// 解析第二个键值对 pos2 = body.find("=", start);// 检查等号是否存在且位置正确if(pos2 == std::string::npos || pos2 > pos1){returnprocess_bad_request();}// 提取第二个键值对的键和值 result_key_str = body.substr(start, pos2 - start); result_value_str = body.substr(pos2 +1, pos1 - pos2 -1); val[result_key_str]= result_value_str;// 更新查找起始位置 start = pos1 +1; pos2 = body.find("=", start);// 如果找不到等号,说明请求格式错误if(pos2 == std::string::npos){returnprocess_bad_request();}// 解析第三个键值对 result_key_str = body.substr(start, pos2 - start); result_value_str = body.substr(pos2 +1); val[result_key_str]= result_value_str;int calc_result;// 存储计算结果// 处理计算,如果处理失败则返回错误响应if(process_calculation(val, calc_result)==false){returnprocess_bad_request();}// 构建HTTP响应头 std::string headler_line ="HTTP/1.0 200 OK\r\n"; std::string header ="Connection: close\r\n"; header +="Content-Type: text/html\r\n";// 构建响应内容 std::string content ="<html><head><meta charset='UTF-8'></head><body>"; content +="<h2>计算结果展示</h2>"; content +="<p>结果为: "+ std::to_string(calc_result)+"</p>"; content +="<a href='/'>返回首页</a>"; content +="</body></html>"; header +="Content-Length: "+ std::to_string(content.size())+"\r\n"; header +="\r\n"; res = headler_line + header + content;return res;}else{// 记录不支持的POST URL lg.logmessage(Fatal,"unsupported post url:%s", hr.url.c_str());returnprocess_bad_request();}}classTask{public:Task():socketfd(-1){}Task(int _socketfd,SSL_CTX* _ctx):socketfd(_socketfd),ctx(_ctx){}/** * 处理文件后缀名,返回对应的MIME类型 * suffix 文件后缀名,如".html"、".css"等 * return 对应的MIME类型字符串,如果找不到则返回默认的".html"对应的MIME类型 */static std::string suffix_handler(std::string suffix){// 在map中查找对应的后缀名auto pos=map.find(suffix);// 如果找不到对应的后缀名if(pos == map.end()){// 返回默认的".html"对应的MIME类型return map[".html"];}// 返回找到的对应MIME类型return map[suffix];}/** * 运行SSL通信处理函数 * 该函数负责处理SSL连接,接收HTTP请求,并根据请求方法(GET/POST)调用相应的处理函数 * 最后将处理结果通过SSL连接返回给客户端 */voidrun(){// 创建新的SSL结构体 SSL* ssl=SSL_new(ctx);// 将套接字文件描述符与SSL关联SSL_set_fd(ssl,socketfd);// 尝试接受SSL连接if(SSL_accept(ssl)<=0){// 获取并记录SSL错误信息unsignedlong err=ERR_get_error();char errbuf[256];ERR_error_string_n(err,errbuf,sizeof(errbuf)); lg.logmessage(Fatal,"SSL_accept error:%s",errbuf);// 清理SSL资源和套接字SSL_free(ssl);close(socketfd);return;}// 创建HTTP请求对象 Http_Request hr;// 获取HTTP请求数据bool get_result =Get_HttpRequest(ssl,hr);if(get_result ==false){// 记录获取HTTP请求失败日志 lg.logmessage(Fatal,"get http request error");// 关闭SSL连接并清理资源SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;} std::string res;// 存储HTTP响应结果// 根据HTTP请求方法调用相应的处理函数if(hr.method =="GET"){ res=Http_Get_Handler(hr);// 处理GET请求}elseif(hr.method =="POST"){ res=Http_Post_Handler(hr);// 处理POST请求}else{// 记录不支持的HTTP方法日志 lg.logmessage(warning,"unsupported method:%s",hr.method.c_str());// 关闭SSL连接并清理资源SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;}// 通过SSL发送响应数据int send_bytes=SSL_write(ssl,res.c_str(),res.size());if(send_bytes<0){// 记录发送数据失败日志 lg.logmessage(Fatal,"SSL_write error");SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);return;}SSL_shutdown(ssl);SSL_free(ssl);close(socketfd);}private:int socketfd; SSL_CTX* ctx;static std::unordered_map<std::string, std::string> map;}; std::unordered_map<std::string, std::string> Task::map={{".html","text/html"},{".css","text/css"},{".png","image/png"},{".jpg","image/jpeg"}};/** * 处理HTTP GET请求,返回静态文件内容 * * hr HTTP请求对象的引用,包含URL等信息 * return std::string 返回完整的HTTP响应,包括状态行、响应头和响应体 * * 该函数实现以下功能: * 1. 处理根路径和index.html的请求 * 2. 根据文件扩展名确定MIME类型 * 3. 读取并返回请求的文件内容 * 4. 处理文件不存在的情况,返回404错误页面 * 5. 构建符合HTTP协议的响应格式 * * 注意事项: * - 需要确保path变量已正确初始化为网站根目录 * - 依赖read_file函数读取文件内容 * - 依赖Task::suffix_handler获取MIME类型 * - 所有响应都使用Connection: close */ std::string Http_Get_Handler(Http_Request& hr){// 定义文件路径、内容类型和响应字符串 std::string file_path;// 存储请求文件的完整路径 std::string content_type;// 存储文件的MIME类型 std::string res;// 存储最终的HTTP响应// 判断请求的URL是否为根路径或index.htmlif(hr.url =="/"|| hr.url =="/index.html"){ file_path = path +"/index.html";// 设置默认首页文件路径 content_type ="text/html";// 设置内容类型为HTML}else{ file_path = path + hr.url;// 拼接完整文件路径// 查找文件扩展名的位置 ssize_t pos = file_path.rfind(".");if(pos == std::string::npos){ content_type ="text/html";// 如果没有扩展名,默认为HTML类型}else{// 提取文件扩展名并处理对应的内容类型 std::string suffix = file_path.substr(pos);// 获取文件扩展名 content_type =Task::suffix_handler(suffix);// 根据扩展名获取对应的MIME类型}}// 读取文件内容 std::string body =read_file(file_path);// 读取请求的文件内容 std::string headler_line;// HTTP响应状态行 std::string header;// HTTP响应头// 判断文件是否存在(即body是否为空)if(body.empty()){// 文件不存在,返回404错误 headler_line ="HTTP/1.0 404 Not Found\r\n";// 设置404状态行// 构建404错误响应头 header +="Connection: close\r\n"; std::string content =read_file(path +"/404.html");// 读取404错误页面 header +="Content-Length: "+ std::to_string(content.size())+"\r\n"; header +="Content-Type: text/html\r\n"; header +="\r\n";// 组装完整的404响应 res = headler_line + header + content;}else{// 文件存在,构建200 OK响应 headler_line ="HTTP/1.0 200 OK\r\n";// 构建响应头 header +="Content-Length: "+ std::to_string(body.size())+"\r\n"; header +="Connection: close\r\n"; header +="Content-Type: "+ content_type +"\r\n"; header +="\r\n";// 组装完整的成功响应 res = headler_line + header + body;}return res;}

Threadpool.h:

#pragmaonce#include<pthread.h>#include<semaphore.h>#include<string>#include<vector>#include<sys/types.h>#include"Task.hpp"#definemax_size10classthreadpool{public:static threadpool&getinstance(){static threadpool instance;return instance;}/** * 启动函数,用于创建多个线程来处理任务 * 该函数会创建Max_size个线程,每个线程都执行handlertask函数 */voidstart(){// 循环创建Max_size个线程for(int i =0; i < Max_size; i++){// 声明一个线程标识符tid pthread_t tid;// 创建一个新线程,并将this作为参数传递给handlertask函数pthread_create(&tid,NULL, handlertask,this);}}/** * 从队列中弹出一个任务 * return Task 返回队列中的任务 */ Task pop(){sem_wait(&element);// 等待有元素的信号,即队列不为空pthread_mutex_lock(&mutex);// 加锁,确保线程安全 Task data = q[c_index];// 获取当前索引处的任务 c_index =(c_index +1)% Max_task_size;// 更新消费者索引,实现循环队列pthread_mutex_unlock(&mutex);sem_post(&space);// 释放空间信号,表示队列中有可用空间return data;// 返回获取的任务}// 向任务队列中添加一个任务的函数voidpush(const Task& T){sem_wait(&space);// 等待空间信号量,表示队列中还有可用空间 q[p_index]= T;// 将任务T存入队列的当前位置 p_index =(p_index +1)% Max_task_size;// 更新写入位置索引,实现循环队列sem_post(&element);// 发送元素信号量,表示队列中新增了一个元素}~threadpool(){pthread_mutex_destroy(&mutex);sem_destroy(&element);sem_destroy(&space);}threadpool(const threadpool&)=delete; threadpool&operator=(const threadpool&)=delete;private:// 线程池的构造函数,用于初始化线程池// 参数:// max_num: 线程池中最大线程数,默认值为max_size// max_task_size: 线程池中最大任务数,默认值为max_sizethreadpool(int max_num = max_size,int max_task_size = max_size)// 初始化最大线程数:Max_size(max_num)// 初始化消费者索引,用于任务队列的循环队列,c_index(0)// 初始化生产者索引,用于任务队列的循环队列,p_index(0)// 初始化最大任务数,Max_task_size(max_task_size){// 调整任务队列大小为最大任务数 q.resize(Max_task_size);// 初始化互斥锁,用于保护共享资源的访问pthread_mutex_init(&mutex,NULL);// 初始化信号量element,用于表示队列中的任务数量,初始为0sem_init(&element,0,0);// 初始化信号量space,用于表示队列中的可用空间数,初始为Max_task_sizesem_init(&space,0, Max_task_size);}/** * 静态线程处理函数,作为线程池中工作线程的执行体 * args 传入的线程池对象指针,用于线程获取任务 * return NULL 线程函数返回值 */staticvoid*handlertask(void* args)// 静态线程处理函数,作为线程池中工作线程的执行体{ threadpool* tp =(threadpool*)args;// 将传入的参数转换为线程池对象指针while(1)// 无限循环,持续等待并执行任务{ Task task = tp->pop();// 从线程池中获取一个任务 task.run();// 执行获取到的任务}returnNULL;// 理论上不会执行到这里} std::vector<Task> q;int Max_size; pthread_mutex_t mutex;int Max_task_size;int c_index;int p_index; sem_t element; sem_t space;};

protocol.hpp:

#pragmaonce#include"log.hpp"#include<iostream>#include<vector>#include<string>#include<sstream>#include<unordered_map>extern log lg;classHttp_Request{public:/** * 反序列化HTTP请求头 * head 包含HTTP请求头的原始字符串 * return bool 反序列化成功返回true,失败返回false */boolDeserialization(std::string& head){// 初始化起始位置 size_t start=0;// 存储解析出的每一行HTTP头 std::vector<std::string>_header;// 循环解析每一行HTTP头while(true){ std::string line;// 查找当前行的结束位置(\r\n) size_t end=head.find("\r\n",start);// 如果找不到结束符,说明格式错误if(end==std::string::npos){returnfalse;}// 提取当前行的内容 line=head.substr(start,end-start);// 如果遇到空行,说明头部结束if(line.empty()){break;}// 更新起始位置到下一行的开始 start=end+2;// 将解析出的行存入_header _header.push_back(line);}// 检查至少有一行数据(请求行)if(_header.size()<1){returnfalse;}// 解析每一行头部字段for(size_t i=1;i<_header.size();i++){ std::string line=_header[i];// 查找键值分隔符(:) ssize_t pos=line.find(":");// 如果找不到分隔符,说明格式错误if(pos==std::string::npos){returnfalse;}// 提取键 std::string key=line.substr(0,pos);// 值的起始位置(跳过分隔符后的空格) size_t val_start=pos+1;while(val_start<line.size()&& std::isspace(line[val_start])){ val_start++;}// 提取值 std::string value=line.substr(val_start);// 将键值对存入headers映射 headers[key]=value;}// 解析请求行(第一行) std::string first_line=_header[0]; std::stringstream ss(first_line);// 提取方法、URL和HTTP版本 ss>>method>>url>>http_version;returntrue;}/** * 调试打印函数,用于输出HTTP请求的详细信息 * 该函数会打印HTTP方法、URL、HTTP版本、请求头和请求体内容 */voiddebugprint(){// 打印一个空行,用于分隔不同部分的内容 std::cout<<std::endl;// 打印HTTP方法、URL和HTTP版本 std::cout<<method<<" "<<url<<" "<<http_version<<std::endl;// 遍历并打印所有的请求头信息for(auto it=headers.begin();it!=headers.end();it++){// 打印每个请求头的键值对,格式为"键: 值" std::cout<<it->first<<": "<<it->second<<std::endl;}// 打印空行,分隔请求头和请求体 std::cout<<std::endl;// 打印请求体内容 std::cout<<text<<std::endl;// 打印两个空行,作为调试输出的结束分隔符 std::cout<<std::endl; std::cout<<std::endl;}public: std::unordered_map<std::string,std::string> headers; std::string text; std::string method; std::string url; std::string http_version;};

Socket.hpp:

#pragmaonce#include<arpa/inet.h>#include<netinet/in.h>#include<unistd.h>#include<string>#include<cstring>#include<cstdlib>#include"log.hpp"extern log lg;enum{ Socket_Error =1, Bind_Error, Listen_Error, Accept_Error, Connect_Error, Usage_Error,};classsock{public:sock():socketfd(-1){}~sock(){if(socketfd >=0){::close(socketfd);}}/** * 创建一个TCP套接字 * * 该函数用于创建一个IPv4的TCP套接字。如果创建失败,会记录错误日志并退出程序。 */voidsocket(){// 调用系统socket函数创建套接字// AF_INET表示使用IPv4地址族// SOCK_STREAM表示使用TCP协议// 0表示使用默认协议 socketfd =::socket(AF_INET, SOCK_STREAM,0);// 检查套接字是否创建成功if(socketfd <0){// 记录致命错误日志 lg.logmessage(Fatal,"socket error");// 设置套接字描述符为-1,表示无效 socketfd =-1;// 退出程序,错误码为Socket_Errorexit(Socket_Error);}// 记录成功日志 lg.logmessage(info,"socket successfully");}/** * 绑定IP地址和端口号到套接字 * ip 要绑定的IP地址字符串 * port 要绑定的端口号 */voidbind(std::string ip,uint16_t port){// 检查套接字是否有效if(socketfd <0){ lg.logmessage(Fatal,"socket not created");// 记录致命错误:套接字未创建exit(Socket_Error);// 以套接字错误码退出程序}// 创建并初始化服务器地址结构体structsockaddr_in server;memset(&server,0,sizeof(server));// 将地址结构体清零// 设置地址族为IPv4 server.sin_family = AF_INET;// 将端口号从主机字节序转换为网络字节序 server.sin_port =htons(port);// 处理IP地址if(ip =="0.0.0.0"){// 如果是0.0.0.0,表示绑定所有可用的网络接口 server.sin_addr.s_addr = INADDR_ANY;}elseif(inet_pton(AF_INET, ip.c_str(),&server.sin_addr)<=0){// 尝试将点分十进制IP地址转换为网络字节序 lg.logmessage(Fatal,"inet_pton fail");// 记录致命错误:IP地址转换失败::close(socketfd);// 关闭套接字 socketfd =-1;// 将套接字描述符设为无效值exit(Bind_Error);// 以绑定错误码退出程序}// 获取服务器地址结构体长度 socklen_t serverlen =sizeof(server);// 尝试绑定套接字到指定地址和端口int n =::bind(socketfd,(structsockaddr*)&server, serverlen);if(n <0){ lg.logmessage(Fatal,"bind error");// 记录致命错误:绑定失败::close(socketfd);// 关闭套接字 socketfd =-1;// 将套接字描述符设为无效值exit(Bind_Error);// 以绑定错误码退出程序}// 记录绑定成功的消息 lg.logmessage(info,"bind successfully");}/** * 监听函数,用于开始监听客户端连接请求 * 该函数首先检查socket是否已创建,然后调用listen函数进入监听状态 * 如果出现错误,会记录错误日志并进行相应处理 */voidlisten(){// 检查socket是否已创建,如果socketfd小于0表示socket未创建if(socketfd <0){// 记录致命错误日志:socket未创建 lg.logmessage(Fatal,"socket not created");// 以socket错误码退出程序exit(Socket_Error);}// 调用系统listen函数开始监听,设置最大连接数为5int n =::listen(socketfd,5);// 如果listen函数返回值小于0,表示监听失败if(n <0){// 记录致命错误日志:监听错误 lg.logmessage(Fatal,"listen error");// 关闭socket文件描述符::close(socketfd);// 将socketfd重置为-1,表示socket未创建 socketfd =-1;// 以监听错误码退出程序exit(Listen_Error);}// 记录信息日志:监听成功 lg.logmessage(info,"listen successfully");}/** * 接受客户端连接请求 * * client 指向sockaddr_in结构体的指针,用于存储客户端地址信息 * clientlen 指向socklen_t的指针,表示client结构体的大小 * return int 成功返回客户端socket描述符,失败返回-1 * * 该函数实现以下功能: * 1. 检查监听socket是否有效 * 2. 调用系统accept函数接受连接 * 3. 记录连接结果日志 * 4. 返回新的客户端socket描述符 * * 注意事项: * - 调用前需要确保socket已创建并处于监听状态 * - 需要正确初始化client和clientlen参数 * - 返回的客户端socket需要由调用者负责关闭 */intaccept(structsockaddr_in* client, socklen_t* clientlen){// 检查监听socket是否有效if(socketfd <0){ lg.logmessage(Fatal,"socket not created");exit(Socket_Error);// socket未创建,严重错误,退出程序}// 调用系统accept函数接受客户端连接int client_fd =::accept(socketfd,(structsockaddr*)client, clientlen);if(client_fd <0){ lg.logmessage(Fatal,"accept error");return-1;// accept失败,返回-1}// 记录成功接受连接的日志 lg.logmessage(info,"accept successfully");return client_fd;// 返回客户端socket描述符}/** * 连接到服务器的函数 * server 指向服务器地址结构的指针 * serverlen 服务器地址结构的长度 */voidconnect(structsockaddr_in* server, socklen_t serverlen){// 检查socket是否已创建if(socketfd <0){// 记录socket未创建的致命错误日志 lg.logmessage(Fatal,"socket not created");// 退出程序,错误码为Socket_Errorexit(Socket_Error);}// 尝试连接到服务器int n =::connect(socketfd,(structsockaddr*)server, serverlen);// 检查连接是否成功if(n <0){// 记录连接失败的致命错误日志 lg.logmessage(Fatal,"connect error");// 关闭socket::close(socketfd);// 将socket描述符重置为-1,表示无效 socketfd =-1;// 退出程序,错误码为Connect_Errorexit(Connect_Error);}// 记录连接成功的信息日志 lg.logmessage(info,"connect successfully");}/** * 关闭套接字函数 * 该函数用于关闭当前对象的套接字,如果套接字有效(即socketfd >= 0),则关闭套接字并将socketfd重置为-1 */voidclose(){// 检查套接字描述符是否有效(大于等于0)if(socketfd >=0){// 调用系统的close函数关闭套接字::close(socketfd);// 将套接字描述符设置为-1,表示套接字已关闭 socketfd =-1;}}sock(const sock&)=delete; sock&operator=(const sock&)=delete;private:int socketfd;};

log.hpp:

#pragmaonce#include<iostream>#include<string>#include<time.h>#include<stdarg.h>#include<fcntl.h>#defineSIZE1024#definescreen0#defineFile1#defineClassFile2enum{ info, debug, warning, Fatal,};classlog{private: std::string memssage;int method;public:log(int _method = File):method(_method){}voidlogmessage(int leval,char* format,...){char* _leval;switch(leval){case info: _leval ="info";break;case debug: _leval ="debug";break;case warning: _leval ="warning";break;case Fatal: _leval ="Fatal";break;}char timebuffer[SIZE]; time_t t =time(NULL);structtm* localTime =localtime(&t);snprintf(timebuffer, SIZE,"[%d-%d-%d-%d:%d]", localTime->tm_year +1900, localTime->tm_mon +1, localTime->tm_mday, localTime->tm_hour, localTime->tm_min);char rightbuffer[SIZE]; va_list arg;va_start(arg, format);vsnprintf(rightbuffer, SIZE, format, arg);char finalbuffer[2* SIZE];int len=snprintf(finalbuffer,sizeof(finalbuffer),"[%s]%s:%s\n", _leval, timebuffer, rightbuffer);int fd;switch(method){case screen: std::cout << finalbuffer;break;case File: fd =open("log.txt", O_WRONLY | O_CREAT | O_APPEND,0666);if(fd >=0){write(fd, finalbuffer, len);close(fd);}break;case ClassFile:switch(leval){case info: fd =open("log/info.txt", O_WRONLY | O_CREAT | O_TRUNC,0666);write(fd, finalbuffer,sizeof(finalbuffer));break;case debug: fd =open("log/debug.txt", O_WRONLY | O_CREAT | O_TRUNC,0666);write(fd, finalbuffer,sizeof(finalbuffer));break;case warning: fd =open("log/Warning.txt", O_WRONLY | O_CREAT | O_TRUNC,0666);write(fd, finalbuffer,sizeof(finalbuffer));break;case Fatal: fd =open("log/Fat.txt", O_WRONLY | O_CREAT | O_TRUNC,0666);break;}if(fd >0){write(fd, finalbuffer,sizeof(finalbuffer));close(fd);}}}}; log lg;

运行截图:

在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述

结语

那么这就是本篇文章的全部内容,带你全面认识以及掌握https,并且实现web计算器服务器,至此我们掌握了应用层协议的有关内容,那么下一期博客我会更新TCP/IP协议,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!

在这里插入图片描述

Read more

将现有 REST API 转换为 MCP Server工具 -higress

将现有 REST API 转换为 MCP Server工具 -higress

Higress 是一款云原生 API 网关,集成了流量网关、微服务网关、安全网关和 AI 网关的功能。 它基于 Istio 和 Envoy 开发,支持使用 Go/Rust/JS 等语言编写 Wasm 插件。 提供了数十个通用插件和开箱即用的控制台。 Higress AI 网关支持多种 AI 服务提供商,如 OpenAI、DeepSeek、通义千问等,并具备令牌限流、消费者鉴权、WAF 防护、语义缓存等功能。 MCP Server 插件配置 higress 功能说明 * mcp-server 插件基于 Model Context Protocol (MCP),专为 AI 助手设计,

By Ne0inhk
MCP 工具速成:npx vs. uvx 全流程安装指南

MCP 工具速成:npx vs. uvx 全流程安装指南

在现代 AI 开发中,Model Context Protocol(MCP)允许通过外部进程扩展模型能力,而 npx(Node.js 生态)和 uvx(Python 生态)则是两种即装即用的客户端工具,帮助你快速下载并运行 MCP 服务器或工具包,无需全局安装。本文将从原理和对比入手,提供面向 Windows、macOS、Linux 的详细安装、验证及使用示例,确保你能在本地或 CI/CD 流程中无缝集成 MCP 服务器。 1. 工具简介 1.1 npx(Node.js/npm) npx 是 npm CLI(≥v5.2.0)

By Ne0inhk
解锁Dify与MySQL的深度融合:MCP魔法开启数据新旅程

解锁Dify与MySQL的深度融合:MCP魔法开启数据新旅程

文章目录 * 解锁Dify与MySQL的深度融合:MCP魔法开启数据新旅程 * 引言:技术融合的奇妙开篇 * 认识主角:Dify、MCP 与 MySQL * (一)Dify:大语言模型应用开发利器 * (二)MCP:连接的桥梁 * (三)MySQL:经典数据库 * 准备工作:搭建融合舞台 * (一)环境搭建 * (二)安装与配置 Dify * (三)安装与配置 MySQL * 关键步骤:Dify 与 MySQL 的牵手过程 * (一)安装必要插件 * (二)配置 MCP SSE * (三)创建 Dify 工作流 * (四)配置 Agent 策略 * (五)搭建MCP

By Ne0inhk
MCP客户端与服务端初使用——让deepseek调用查询天气的mcp来查询天气

MCP客户端与服务端初使用——让deepseek调用查询天气的mcp来查询天气

本系列主要通过调用天气的mcp server查询天气这个例子来学习什么是mcp,以及怎么设计mcp。话不多说,我们开始吧。主要参考的是B站的老哥做的一个教程,我把链接放到这里,大家如果有什么不懂的也可以去看一下。 https://www.bilibili.com/video/BV1NLXCYTEbj?spm_id_from=333.788.videopod.episodes&vd_source=32148098d54c83926572ec0bab6a3b1d https://blog.ZEEKLOG.net/fufan_LLM/article/details/146377471 最终的效果:让deepseek-v3使用天气查询的工具来查询指定地方的天气情况 技术介绍 MCP,即Model Context Protocol(模型上下文协议),是由Claude的母公司Anthropic在2024年底推出的一项创新技术协议。在它刚问世时,并未引起太多关注,反响较为平淡。然而,随着今年智能体Agent领域的迅猛发展,MCP逐渐进入大众视野并受到广泛关注。今年2月,

By Ne0inhk