一个基于Quic协议的Http转Udp代理工具
背景
某地存在A和B两个网络,A中搭建了上级平台,用户平时正常办公使用该平台。B搭建了下级平台,接入设备。
两个网络的边界端口映射有一个唯一的限制:两个网络之间端口映射只能映射Udp的端口
现在B网中的设备想要接入A网,B网中的设备接入基本上都是http请求,所以整体的对接只需要对接http请求。
探索
针对该问题,进行相关的预研探索。目前已有的条件就是udp通道的边界,那么实现该目标的办法就是利用该udp通道去传输http请求,也就是说要开发http-udp,udp-http的代理工具。
-
TCP和UDP
- 处在TCP/IP模型的传输层中,传输控制协议TCP和用户数据报协议UDP
- TCP可靠,UDP不可靠
-
Http
- 处在TCP/IP模型的应用层中,基于tcp协议的应用
上述的概念比较清晰,其中也暴露了一个非常关键的点,TCP可靠,UDP不可靠。我们都知道tcp的三次握手和四次挥手,tcp是面向连接的协议,通过严格的确认连接来保证整个连接的正常通信。而udp面向非连接,不需要维护连接状态,只需要交付报文即可。
经过一些了解后,如果要开发一个http和udp互相转换的代理工具,主要需要解决以下的问题:
- Udp不可靠,存在丢包的情况
- Udp有报文大小限制,1480bytes
- Udp不保证数据顺序
对于一个网络方面的小白来说,想要独自开发上述代理工具至少需要半年起步。
既然从0开发需要耗时太长,那么就需要找目前已经存在的代理工具,经过搜索发现相关内容少之又少。
在一次偶然的搜索中发现http3.0是基于Quic协议开发,而Quic协议又是基于udp的,那么似乎找到了一丝问题解决的可能性。
HTTP/3 & Quic
HTTP/3 is the third major version of the Hypertext Transfer Protocol used to exchange information on the World Wide Web, complementing the widely-deployed HTTP/1.1 and HTTP/2. Unlike previous versions which relied on the well-established TCP (published in 1974),[2] HTTP/3 uses QUIC, a multiplexed transport protocol built on UDP.[3] On 6 June 2022, IETF published HTTP/3 as a Proposed Standard in RFC 9114.[4]
通过维基百科我们可以了解到HTTP3使用一种基于UDP多路复用的QUIC协议,同时它也实现了Http/1.1和Http/2。
通过协议栈我们可以发现,QUIC = HTTP/2 + TLS + UDP。
Quic协议是由google设计的,全称Quick UDP Internet Connections,由于目前互联网中大量的存量设备都是使用的Http协议,所以并没有继续在tcp协议上进行大的改动升级Http,而是使用了全新的Udp来完成Http。
HTTP/3的开源实现
HTTP/3的开源实现并不多,主要C++,C#,Go,Rust语言上,其中目前使用最为广泛的应该是Cloudflare的quiche(https://github.com/cloudflare/quiche),
其实在看到很多quick的实际应用,都是针对某个单体网站去进行http3的应用。
初次尝试 quiche-nginx
看了一些项目,初步的一个想法我在两个网络部署支持http3的nginx,然后通过nginx转发请求达到代理的效果。
quiche的nginx版本需要在nginx1.16.1版本上打quiche http3的patch,然后重新编译nginx,此次尝试耗时很久,实际结果却不尽如人意,整个编译链需要的很多库都需要联网下载,内网离线安装费时费力。编译完成后开启http3配置也接收不到请求。此次尝试以失败告终。
同时也在思考,就算该种方法成功,后期交付和维护也是很大的问题,将会浪费很多时间。
再次尝试 kwik
由于本人是Java开发,考虑到整个部门也是Java居多,如果用Java实现,后期维护比较方便。而且以组件的形式开发,交付和复用也比较方便。基于这样的考虑,进行了第二次的尝试,Http3的Java开源实现kwik(https://github.com/ptrd/kwik)。
这次的尝试就比较顺畅了,由于已经使用udp测试工具测试过udp通道的正常,而且kwik中已经实现好了client端和server端,所以只需要在B网络部署serevr端的jar包,A网络用client端的jar包去调用即可。
kwik的server端内置了一个/getVersion的接口,在A网络用client去调用B网络server端的/getVersion请求,成功进行了返回。
随后又在server写死了一个平台侧的请求,继续调用,也成功进行了返回。在完成测试后,下面开始准备魔改kwik,让其满足我们的业务需求。
Kwik
kwik-client
kwik只实现了quick协议,对于如何使用需要自行定义。
那这里我们作为一个http的代理工具,client端要进行的定制也是非常清晰明了,起一个Http的服务器接收请求,针对每个请求,http协议转quic协议,使用kwik client去调用即可。
有了整体的实现逻辑,那么代码实现起来也比较简单,这里看一些关键的代码:
|
|
至此,完成了client侧的定制。
kwik-server
那么对于server端,我们的定制内容也是比较清晰,kwik-client端发送来的请求,quic协议转http协议,然后调用平台的接口,返回结果接口。
下面看一些核心的代码:
|
|
整体源码流程
术语
首先定义一些术语,方便大家观看
- Http3Connection: Http3 Client端的连接类
- Http3Client: Http3 Client,负责发送请求
- QuicStream: Quic协议一次请求的流信息的定义,持有inputStream和outputStream,负责输入输出的写入。
- ServerConnection: http3 Server端的连接类
- Sender、Receiver: 请求发送和接收的实际操作类
定制部分比较少,流程也比较清晰,主要还是要看一下kwik是如何实现QUIC协议的,这里从源码出发,由Client至Server,进行一步步的解析。
Client端
请求的发送其实在JDK11内置的HttpClient上进行扩展的。
kwik定义了Http3Client,继承自java.net.http.HttpClient,我们上面的实现中,client端请求发出就是用的该类,Http3Client重写了send方法。
|
|
第一行getConnection实际上就是创建http3Connection, 由于QUIC是实现Http3的一种协议,所以,这里Http3Connection实际只持有和管理QuicClientConnection,以及一些请求控制参数。
那么创建Http3Connection实际上就是创建QuicClientConnection,QuicClientConnection持有了一个sender和receiver,sender负责发送请求,receiver负责接收请求返回信息。
|
|
这里就比较清晰了,先是创建了个UDP的socket,然后sender使用这个socket往new InetSocketAddress(serverAddress, port)这个地址发送数据, receiver从socket中接收数据, sender和receiver都是各起了一个线程。
Http3Connection创建好后,开始connect,connect中主要分了两个动作,QuicClientConnection的建立和client端请求参数的配置,这里咱们主要关注quicConnection的实现,
|
|
第一行确认版本,第二行确认协议,还是聚焦在第三行QuicClientConnection的connect中:
|
|
QuicClientConnection的connect就是用刚刚启动刚刚定义的sender和receiver线程,那这里比较关键的是startHandshake, 之前我们也说了UDP是不可靠的,那么QUIC是如何实现可靠的呢?就是依靠的和TCP一样的机制,握手。
在刚刚QuicClientConnection创建的时候,还创建了ClientMessageSender的实现类、:
|
|
那看到这我们应该很熟悉了,在startHandshake中,先发送clientHello,同样对应的在server端收到serverHello后,会发送serverHello。
需要注意的是,这里的握手并不是实现了TCP的三次握手,四次挥手。HTTP3用了最新的TLS1.3, TLS1.3中定义了0-RTT(一次握手),1-RTT(两次握手)等等,有兴趣的可以了解一下。
具体的握手细节可以从抓包上清楚的看到:
-
Step1: client发送了ClientHello,并且携带了各种key
-
Step2: server发送了Retry, 因为server端持有的connectionId和client发送的DCID(destination connection id)相等了,所以要求client重新handshake
-
Step3: client重新发送Initial packet, 可以看到,这里用了server的Retry packet的DCID
-
Step4: server发送ServerHello,然后包括了HandShake,TLS的各种信息
-
Step5: client进行HandShake,TLS的各种信息
-
Step6: 开始传输message
在整个过程中,只有ClientHello和ServerHello是明文的,其余都是加密的。
握手成功后,就开始真正的send了,主要在下面代码的第二行和第三行中,第一行根据当前的connection创建一个stream出来,QuicStream包含了StreamInputStream和StreamOutputStream,自定义了输入和输出流。
同时其中也包含了除了请求header和body之外的协议信息,创建QuicStream相当于创建了个模板,后续只需要填充header和body。
|
|
当sendRequest(request, httpStream);时,将request的header和body都写到QuicStream的StreamOutputStream中去,而该QuicStream也push到RequestQueue中。
存入requestQueue后,通知最初定义的sender, sender将请求输出到socket中去。
receiveResponse(request, responseBodyHandler, httpStream);拉取请求返回结果,当server处理完请求后,会将返回结果发送到socket中,最初定义的receiver一直在拉取请求结果信息。
最终将结果写入到httpStream的inputStream中,receiveResponse将返回的信息返回到http request中去。
上述只是最简单的实现描述,真实的代码非常复杂,TLS的处理、以及不同过程的数据处理,以及数据拆分等等等等。
Server端
Server端首先用配置好的port新建一个socket,这个port就是上面client端的socket发送的port。同样的新建一个Receiver去接收client端发送的请求。
|
|
定义了一个receivedPacketsQueue去存放接收的消息,然后将接收到的消息进行处理。
|
|
|
|
数据的处理主要分为两种类型,一种是Long header packet,long header主要是初始化和handshake的时候使用的。而short header是除了long header以外普通的packet。
第一次接收数据时并处理时,创建一个ServerConnection,在ServerConnection中,只有一个sender,和Client的做法类似,有一个ServerMessageSender去发送ServerHello和handshake。
Long header packet的数据处理完后,也就完成了和client端的handshake。后面就处理short header的数据,处理后的packet解析成http1的请求,http1的请求经过定制后,得到http1的response。
response写入QuicStream中,和client端的send一样,由ServerConnection中的sender去发送返回结果。
如何解决的UDP的问题
开头我们说了UDP通讯的三个问题:
- Udp不可靠,存在丢包的情况
- Udp有报文大小限制,1480bytes
- Udp不保证数据顺序
Quic的解决方案如下:
- 如果发送的数据server端没有响应,就会一直重发,直到服务端响应
- 发送数据前拆包
- 在1的基础上,每个数据包都有一个offset,最后根据offset组装数据