简单http安全proxy服务
http代理概念
HTTP 代理存在两种形式,分别简单介绍如下:
- 第一种是 RFC 7230 - HTTP/1.1: Message Syntax and Routing(即修订后的 RFC 2616,HTTP/1.1 协议的第一部分)描述的普通代理。这种代理扮演的是「中间人」角色,对于连接到它的客户端来说,它是服务端;对于要连接的服务端来说,它是客户端。它就负责在两端之间来回传送 HTTP 报文, 负责http相关报文的解析。
- 第二种是 Tunneling TCP based protocols through Web proxy servers(通过 Web 代理服务器用隧道方式传输基于 TCP 的协议)描述的隧道代理。它通过 HTTP 协议正文部分(Body)完成通讯,以 HTTP 的方式实现任意基于 TCP 的应用层协议代理。这种代理使用 HTTP 的 CONNECT 方法建立连接,当原始服务器返回连接成功之后,浏览器与最终服务器之间的消息通信不再采用http协议来封装数据,而是直接传递字节流,代理服务器的作用就是进行数据转发。
通过代理访问 A 网站,对于 A 来说,它会把代理当做客户端,完全察觉不到真正客户端的存在,这实现了隐藏客户端 IP 的目的。代理也可以修改 HTTP 请求头部,通过 X-Forwarded-IP
这样的自定义头部告诉服务端真正的客户端 IP。但服务器无法验证这个自定义头部真的是由代理添加,还是客户端修改了请求头。
给浏览器显式的指定代理,需要手动修改浏览器或操作系统相关设置,或者指定 PAC(Proxy Auto-Configuration,自动配置代理)文件自动设置,还有些浏览器支持 WPAD(Web Proxy Autodiscovery Protocol,Web 代理自动发现协议)。显式指定浏览器代理这种方式一般称之为正向代理,浏览器启用正向代理后,会对 HTTP 请求报文做一些修改,来规避老旧代理服务器的一些问题。
还有一种情况是访问 A 网站时,实际上访问的是代理,代理收到请求报文后,再向真正提供服务的服务器发起请求,并将响应转发给浏览器。这种情况一般被称之为反向代理,它可以用来隐藏服务器 IP 及端口。一般使用反向代理后,需要通过修改 DNS 让域名解析到代理服务器 IP,这时浏览器无法察觉到真正服务器的存在,当然也就不需要修改配置了。反向代理是 Web 系统最为常见的一种部署方式,一般使用nginx来当反向代理以处理负载均衡问题。
HTTP 协议解析
http 报文可以分为两种:请求报文和应答报文,对于报文结构不再赘述,网上有很多资料,这里就简单贴一下图。
http 请求的格式如下:
![34466a97bf3fbc81291238e61626f01d.png](https://i-blog.csdnimg.cn/blog_migrate/24228fe9e4313694997e8f1807f117b0.jpeg)
http 响应的格式如下:
![8be74804a95dac81fababc415c556af8.png](https://i-blog.csdnimg.cn/blog_migrate/d5fc2c8717797902d2691acfd0c938d8.jpeg)
这里提一下自己解析协议时候的一些关键点:
- 同一个packet内,可能存在重复的首部字段名,所有字段值是可以用逗号分割的都可以转换成多行描述的形式, 所以存储的时候不能用简单的
unordered_map
, 而要用unordered_multimap
; - 报文内的Content-Length字段给出了entity body的长度, 但是有些报文可能压根没有这个字段,采取的可能是不定的,例如
Transfer-Encoding:chunked
,此时采取的是游程编码,客户端需要不停的读取数据流,直到读取到rnrn
,此时才代表一个packet的结束。 - connect方法返回ok之后,后续流量不再是http协议,代理服务器也不要试图去解析。
连接管理
最早的http协议里面,一个请求就要开一个tcp链接,等待请求结果回来之后,关闭这个连接。由于tcp链接的三次握手和四次握手的问题,导致延迟和消耗都非常大,所以http1.1
版本中,加入了keep-alive选项。如果开启了keep-alive
,则客户端收到请求之后,不再关闭链接,后续对于同站点的相关请求可以复用此链接。
对于显示的代理服务器来说,客户端发过来的请求头并没有Connection: keep-alive
, 取而代之的是'Proxy-Connection: keep-alive',代理服务器在收到这个字段之后,才能对最终服务器的链接开启keep-alive
,否则收到返回之后直接断开与原始服务器的链接。至于为什么不复用Connection
而采用Proxy-Connection
,参考这个链接。
流量加密
上面所说的代理服务器从原理上来说比较简单,配合一些脚本语言可能一两行就可以跑起来一个localhost的代理服务器,但是一旦代理服务器跨越了我国公网,则几乎100%的概率是连接超时。因为代理所发出来的http包是很容易识别的,所以基于这种方式通过vps搭建简单代理来走向世界是不行的,为此我们需要对http请求的tcp流量进行加密,即模拟一个轻量版(漏洞百出版)的https
。
加密又分为两种:对称加密和非对称加密。对称加密里的加密和解密key是一样的,而非对称加密则分为公钥和私钥之分,两个密钥的内容不同,公钥公开给其他使用当前加密服务的人员使用,私钥则自己存储,一份数据经过公钥加密后可以通过私钥解密,同样的经过私钥加密之后可以通过公钥解密。这种公钥分享系统也叫做PKI。
由于非对称加密的复杂度一般远远大于对称加密的复杂度, 所以实际使用时一般是首先通过公钥加密系统来握手,同时商定对称加密的key, 之后的处理都走对称加密流程。这样既确保了对称密钥的私密性,又加大了数据处理的速度。
这里我们使用openssl来进行流量的加密传输,非对称加密系统采取RSA, 对称加密系统则采用AES,同时对称加密的分组模式提供ECB、CBC、CFB、OFB四种选项,具体选项的含义就不要去追究了,直接调用相关api就好。
工作流程
由于我们现在加入了加密这个流程,简单的一个代理服务器已经无法满足我们当前的需求,所以当前程序设计成了两个部分:本地代理客户端和远程代理服务端。本地代理客户端充当原来的简单代理服务端,但是他接收到浏览器请求之后并不直接代理访问,而是转发到远程代理服务端,中继代理请求。其实本地的代理就是一个中继器,负责请求转发和流量加密。
现在的整个流程可以分为四个部分:
- 代理客户端与代理服务端握手
- 代理客户端中继浏览器请求到代理服务端
- 代理服务端收到请求之后执行http访问,将结果返回代理客户端
- 代理客户端将代理服务端返回结果通知到浏览器
代理客户端与代理服务端握手
这里的握手过程就是协商密钥的过程, 流程可以分为如下几步:
- 代理客户端 读取服务端RSA公钥,选择一个合适的对称加密算法和生成对应的加密密钥,通过RSA公钥加密之后,发送到加密服务端,此时客户端记录的链接状态是negotiating,
- 服务端验证数据有效性、密钥合法性、账户合法性,如果验证结果错误,直接断开连接。否则当前客户端准备同时接收浏览器和代理服务端的数据, 代理服务端开始等待代理客户端转发过来的请求。
代理客户端与代理服务端数据交换
在握手成功之后,代理客户端线程处于双工的状态,同时发送和接受服务端和浏览器的数据。由于http pipeline
基本不怎么受支持,所以其实也不算双工。这里使用了四个函数接口:
async_read_from_ua
从浏览器这里读取数据,每次读取到数据之后都直接进行加密,然后调用async_wirte_to_server
将这些数据发送出去async_write_to_proxy_server
则不停的读取当前的发送缓冲,并发送到server端, 当所有都发送完成之后,调用async_read_from_ua
等待浏览器数据async_read_from_proxy_server
不断的接受proxy_server
断端发送过来的数据并解密,然后立即调用async_write_to_ua
发送回浏览器async_write_to_ua
则是不断的调用自身将发送缓冲清空,然后调用async_read_from_proxy_server
继续等待proxy_server的数据
这里的代码实现时将这四个操作都用一个strand
串联起来了,主要是因为openssl
内的decrypt
和encrypt
函数对于多线程的支持不好,官方网站也说对于openssl
的多线程不要抱太大希望。所以对于一个线程内的所有操作都用同一个strand
。同时asio
那边对于connection
的处理也是推荐使用strand去管理,否则会出现各种稀奇古怪的bug。
握手成功后,代理服务端线程则只能处于单工状态,接受代理客户端请求,解析之后向原始服务器请求,原始服务器请求回来之后,解析返回的数据,然后发送回代理客户端。这里的逻辑就比客户端的纯连接转发功能复杂多了:
async_read_from_proxy_client
从client读取数据,on_proxy_client_data_read
解密从代理客户端获取的数据:- 如果当前状态是
tunnel
(就是connect
请求成功之后的状态) 则直接调用async_write_to_origin_server
发送数据; - 否则解析请求头,获取目标服务器的地址:
- 如果当前已经在与目标服务器的
keep-alive
状态,则调用on_origin_server_connected
处理后续数据的发送, - 否则调用
async_connect_to_origin_server
去连接目标服务器;
- 如果当前已经在与目标服务器的
async_connect_to_origin_server
,遍历所有目标服务器的endpoint
去链接,成功之后调用on_origin_server_connected
处理之前的数据;on_origin_server_connected
,- 如果之前的请求方法是
connect
,则向代理客户端发送链接成功的响应,同时将当前链接的状态设置为tunnel
, - 否则,处理连接之前收取的数据,当获取到一个完整的请求头或者任意数量的内容之后,调用
async_wtite_to_origin_server
; async_write_to_origin_server
不断的调用底层的发送接口去发送数据,当发送完成之后,调用on_origin_server_data_send
on_origin_server_data_send
- 如果当前在
tunnel
状态,则直接调用async_read_from_proxy_client
等待数据 - 如果当前刚好发送完一个完整的packet,则调用
async_read_from_origin_server
等待目标服务器的应答 - 当前正在读取
packet
之中,继续调用async_read_from_proxy_client
等待数据 async_read_from_origin_server
从目标服务器等待响应数据on_origin_server_data_read
处理目标服务器的数据返回- 如果当前状态是
tunnel
,则加密数据之后直接调用async_write_to_proxy_client
发送加密后数据 - 如果当前已读数据不足以构造
packet
头部,则继续调用async_read_from_origin_server
等待数据 - 把已读数据加密,调用
async_write_to_proxy_client
发送加密后数据。 async_write_to_proxy_client
调用底层的发送接口把数据发送到客户端,发送完成之后调用on_proxy_client_data_send
;on_proxy_client_data_send
:- 如果当前状态在
tunnel
,直接调用async_read_from_origin_server
- 如果当前发送的数据并不能组成一个
packet
,则调用async_read_from_origin_server
等待剩余数据 - 如果完整发送了一个
packet
,则根据是否接受keep-alive选项来决定是否关闭链接,如果不关闭,则调用async_read_from_proxy_client
等待下一个packet
请求。
- 如果当前状态在
更多改进
实现这些接口之后,一个极简且可用的加密代理服务搭建完成。使用者可以将本机启动client,远程vps启动server,同时将浏览器设置为以client作为代理服务器,就可以解决部分网络不好的问题了。但是,当前服务也只是能用而已,目前还有很多问题,有很多改进的点:
- 每次浏览器发起一个请求的时候,client都会新建一个connection去连接服务器,以处理这个请求。虽然我们目前支持了keep-alive,但是正常使用过程中,多开几个页面,同时连接数经常在30多个以上。可以考虑client和server维持一个连接池,每个请求的session绑定到其中一个连接上,这样就进一步减少了ping-pong延迟。最佳情况是可以达到ftp的链接速率。
- 国内网络环境还是比较恶劣的,在外租用的vps经常时不时网络抽风,可以尝试把多个server组成一个集群,动态调整各个server的连接优先级,甚至可以做一个serverlist的服务发现
- client和server之间的连接流量模式还是过于明显,可以考虑在连接池的基础上,client-server之间加入稳定的随机流量,同时把每个packet做一下padding到1024的整数倍。
相关链接
- 本文最早参考的项目azure http proxy 一个基于boost.asio和c++11的加密代理
- 我自己对于此项目的修改版azure_http_proxy 基于asio和c++17
- 关于http代理的更详细的说明https://imququ.com/post/web-proxy.html