一个基于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互相转换的代理工具,主要需要解决以下的问题:

  1. Udp不可靠,存在丢包的情况
  2. Udp有报文大小限制,1480bytes
  3. 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

通过协议栈我们可以发现,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),

http3_implement

其实在看到很多quick的实际应用,都是针对某个单体网站去进行http3的应用。

初次尝试 quiche-nginx

看了一些项目,初步的一个想法我在两个网络部署支持http3的nginx,然后通过nginx转发请求达到代理的效果。

http3_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去调用即可。

kwik-client

有了整体的实现逻辑,那么代码实现起来也比较简单,这里看一些关键的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  //1. 创建一个HttpServer实例,并绑定到指定的端口号
  HttpServer httpServer = HttpServer.create(new InetSocketAddress(listenPort), 0);
  httpServer.createContext("/", customHttpHandler);
  httpServer.start();
        
  // 2. 创建自定义的HttpHandler 完成http协议到quic协议的转换
  QuicClientConnection.Builder builder = QuicClientConnection.newBuilder();
  // 这里的serverIp 和 serverPort为kwik-server的ip和port
  builder.uri(new URI("//" + serverIp + ":" + serverPort));
  QuicClientConnection quicConnection = builder.build();
  HttpClient httpClient = createHttpClient(quicConnection, false);
  HttpRequest.Builder httpBuilder = HttpRequest.newBuilder();
  HttpRequest httpRequest = httpBuilder.GET().build();
  
  // 3. 调用kwik-server, 返回的请求统一用String去接, 针对某些特殊请求,图片等 server端使用转base64返回, 这里拿到String方便解base64
  HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
  
  // 4. ... 省略一些处理
  
  // 5. 写回返回流
  OutputStream outputStream = exchange.getResponseBody();
  outputStream.write(responseBytes);
  outputStream.close();

至此,完成了client侧的定制。

kwik-server

那么对于server端,我们的定制内容也是比较清晰,kwik-client端发送来的请求,quic协议转http协议,然后调用平台的接口,返回结果接口。

kwik-server

下面看一些核心的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
  // 1. 开启一个quic connections,在listenPort上的udp socket,监听请求
  ServerConnector serverConnector = new ServerConnector(listenPort, new FileInputStream(certificateFile), new FileInputStream(certificateKeyFile), supportedVersions, true, log);
  registerHttp3(serverConnector);
  serverConnector.start();
  
  // 2. 根据路径区分不同类型的请求,并进行定制处理
  if (request.path().startsWith(ServerType.FILE.getPath())) {
      // 文件服务器 可以摆渡/home下的文件
      homeFileServer.handleRequest(request, response);
  } else if (request.path().startsWith(ServerType.IMAGE.getPath())) {
      httpImageServer.handleRequest(request, response);
  } else if (request.path().startsWith(ServerType.APP_FILE.getPath())) {
      fileServer.handleRequest(request, response);
  }else {
      requestHandler.handleRequest(request, response);
  }
  
  // 3. 实现不同的http请求server,进行不同的处理,
  // HomeFileServer: 文件摆渡
  // HttpImage: 图片请求
  // FileServer: APP文件下载请求
  // RequestHandler: 默认HTTP请求处理

整体源码流程

术语

首先定义一些术语,方便大家观看

  1. Http3Connection: Http3 Client端的连接类
  2. Http3Client: Http3 Client,负责发送请求
  3. QuicStream: Quic协议一次请求的流信息的定义,持有inputStream和outputStream,负责输入输出的写入。
  4. ServerConnection: http3 Server端的连接类
  5. Sender、Receiver: 请求发送和接收的实际操作类

定制部分比较少,流程也比较清晰,主要还是要看一下kwik是如何实现QUIC协议的,这里从源码出发,由Client至Server,进行一步步的解析。

Client端

请求的发送其实在JDK11内置的HttpClient上进行扩展的。

kwik定义了Http3Client,继承自java.net.http.HttpClient,我们上面的实现中,client端请求发出就是用的该类,Http3Client重写了send方法。

1
2
3
4
5
6
    @Override
    public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException {
        http3Connection = http3ConnectionFactory.getConnection(request);
        http3Connection.connect((int) connectTimeout().orElse(DEFAULT_CONNECT_TIMEOUT).toMillis());
        return http3Connection.send(request, responseBodyHandler);
    }

第一行getConnection实际上就是创建http3Connection, 由于QUIC是实现Http3的一种协议,所以,这里Http3Connection实际只持有和管理QuicClientConnection,以及一些请求控制参数。

那么创建Http3Connection实际上就是创建QuicClientConnection,QuicClientConnection持有了一个sender和receiver,sender负责发送请求,receiver负责接收请求返回信息。

1
2
3
4
        socket = new DatagramSocket();
        sender = new SenderImpl(quicVersion, getMaxPacketSize(), socket, new InetSocketAddress(serverAddress, port),
                        this, initialRtt, log);
        receiver = new Receiver(socket, log, this::abortConnection);

这里就比较清晰了,先是创建了个UDP的socket,然后sender使用这个socket往new InetSocketAddress(serverAddress, port)这个地址发送数据, receiver从socket中接收数据, sender和receiver都是各起了一个线程。

Http3Connection创建好后,开始connect,connect中主要分了两个动作,QuicClientConnection的建立和client端请求参数的配置,这里咱们主要关注quicConnection的实现,

1
2
3
4
5
    if (!quicConnection.isConnected()) {
        Version quicVersion = determinePreferredQuicVersion();
        String applicationProtocol = quicVersion.equals(Version.QUIC_version_1) ? "h3" : determineH3Version(quicVersion);
        quicConnection.connect(connectTimeoutInMillis, applicationProtocol, null, Collections.emptyList());
    }

第一行确认版本,第二行确认协议,还是聚焦在第三行QuicClientConnection的connect中:

1
2
3
        receiver.start();
        sender.start(connectionSecrets);
        startHandshake(applicationProtocol, !earlyData.isEmpty());

QuicClientConnection的connect就是用刚刚启动刚刚定义的sender和receiver线程,那这里比较关键的是startHandshake, 之前我们也说了UDP是不可靠的,那么QUIC是如何实现可靠的呢?就是依靠的和TCP一样的机制,握手。

在刚刚QuicClientConnection创建的时候,还创建了ClientMessageSender的实现类、:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public interface ClientMessageSender {

    void send(ClientHello clientHello) throws IOException;

    void send(FinishedMessage finishedMessage) throws IOException;

    void send(CertificateMessage certificateMessage) throws IOException;

    void send(CertificateVerifyMessage certificateVerifyMessage);
}

那看到这我们应该很熟悉了,在startHandshake中,先发送clientHello,同样对应的在server端收到serverHello后,会发送serverHello。

需要注意的是,这里的握手并不是实现了TCP的三次握手,四次挥手。HTTP3用了最新的TLS1.3, TLS1.3中定义了0-RTT(一次握手),1-RTT(两次握手)等等,有兴趣的可以了解一下。

具体的握手细节可以从抓包上清楚的看到:

quic-wireshark

  • 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。

1
2
3
4
5
6
    public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException {
        QuicStream httpStream = quicConnection.createStream(true);
        sendRequest(request, httpStream);
        Http3Response<T> http3Response = receiveResponse(request, responseBodyHandler, httpStream);
        return http3Response;
    }

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端发送的请求。

1
2
        new DatagramSocket(port)
        receiver = new Receiver(serverSocket, log, exception -> System.exit(9));

定义了一个receivedPacketsQueue去存放接收的消息,然后将接收到的消息进行处理。

1
2
3
4
5
        receiverThread = new Thread(() -> run(), "receiver");
        receiverThread.setDaemon(true);
        receivedPacketsQueue = new LinkedBlockingQueue<>();
        RawPacket rawPacket = receiver.get((int) Duration.ofDays(10 * 365).toSeconds());
        process(rawPacket);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    protected void process(RawPacket rawPacket) {
        ByteBuffer data = rawPacket.getData();
        int flags = data.get();
        data.rewind();
        if ((flags & 0b1100_0000) == 0b1100_0000) {
            processLongHeaderPacket(new InetSocketAddress(rawPacket.getAddress(), rawPacket.getPort()), data);
        } else if ((flags & 0b1100_0000) == 0b0100_0000) {
            processShortHeaderPacket(new InetSocketAddress(rawPacket.getAddress(), rawPacket.getPort()), data);
        } else {
            log.warn(String.format("Invalid Quic packet (flags: %02x) is discarded", flags));
        }
    }

数据的处理主要分为两种类型,一种是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通讯的三个问题:

  1. Udp不可靠,存在丢包的情况
  2. Udp有报文大小限制,1480bytes
  3. Udp不保证数据顺序

Quic的解决方案如下:

  1. 如果发送的数据server端没有响应,就会一直重发,直到服务端响应
  2. 发送数据前拆包
  3. 在1的基础上,每个数据包都有一个offset,最后根据offset组装数据