QUIC协议分析-基于quic-go
<h1 id="quic协议分析">quic协议分析</h1><p>QUIC是由谷歌设计的一种基于UDP的传输层网络协议,并且已经成为IETF草案。HTTP/3就是基于QUIC协议的。QUIC只是一个协议,可以通过多种方法来实现,目前常见的实现有Google的quiche,微软的msquic,mozilla的neqo,以及基于go语言的quic-go等。</p>
<p>由于go语言的简洁性以及编译的便捷性,本文将选用quic-go进行quic协议的分析,该库是完全基于go语言实现,可以用于构建客户端或服务端。</p>
<h2 id="源码编译与测试">源码编译与测试</h2>
<h3 id="下载">下载</h3>
<ol>
<li>从https://golang.org/dl/下载golang编译器,要求go版本为1.14+。</li>
<li>使用<code>git clone https://github.com/lucas-clemente/quic-go.git</code>下载库</li>
</ol>
<h3 id="编译">编译</h3>
<h4 id="服务端">服务端</h4>
<pre><code class="language-bash">cd example
go build main.go
</code></pre>
<p>之后使用<code>./main -qlog -v -tcp</code>运行即可。</p>
<p>必须带上<code>-tcp</code>参数是因为浏览器第一次访问时仍然是要通过TCP进行的,如果不带浏览器将无法访问。</p>
<h4 id="客户端">客户端</h4>
<p>先修改<code>example/client/main.go</code>,在60行之后加上<code>qconf.Versions = []protocol.VersionNumber{protocol.VersionDraft29}</code>,选择quic版本为draft-29。</p>
<pre><code class="language-bash">cd example/client
go build main.go
</code></pre>
<p>之后使用<code>./main -v -insecure -keylog ssl.log https://quic.rocks:4433/</code>即可访问支持quic协议的网站。</p>
<h3 id="服务端测试">服务端测试</h3>
<h4 id="浏览器访问">浏览器访问</h4>
<p>在firefox中打开<code>about:config</code>,搜索HTTP3,将值设为True以打开HTTP3的实验特性。</p>
<p>打开https://localhost:6121/demo/tile网页,通过调试工具查看请求,当第一次请求该网页时,会通过TCP协议进行:</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082538070-126779131.png" alt="" loading="lazy"></p>
<p>而在响应头中会带上Alt-Svc,以告诉浏览器该服务器支持HTTP3协议:</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082555384-640960938.png" alt="" loading="lazy"></p>
<p>之后刷新页面,浏览器就会以HTTP3协议来访问:</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082611193-1604094624.png" alt="" loading="lazy"></p>
<h4 id="抓包">抓包</h4>
<p>使用wireshark对loopback进行抓包,过滤器设置为<code>udp.port==6121</code>,此时wireshark只显示为UDP协议,并未解析为quic,需要右键Decode As解析为quic。</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082624431-1733541567.png" alt="" loading="lazy"></p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082638198-1225358590.png" alt="" loading="lazy"></p>
<p>可以看到,第一个包的类型为Initial,进行了0-RTT的初始化。</p>
<h4 id="问题解决">问题解决</h4>
<p>当访问时,服务器可能会报错<code>Client offered version draft-29, sending Version Negotiation</code>,这是因为当使用<code>-tcp</code>选项后,将使用默认设置,而在默认设置中未开启draft-29版本的支持,因此需要修改源码,将<code>internal/protocol/version.go:30</code>中的<code>var SupportedVersions = []VersionNumber{VersionTLS}</code>修改为<code>var SupportedVersions = []VersionNumber{VersionTLS, VersionDraft29}</code>即可。</p>
<h3 id="客户端测试">客户端测试</h3>
<p>使用<code>./main -v -insecure -keylog key.log https://quic.rocks:4433/</code>访问测试网站,可以看见最后成功输出了网页的内容 “You have successfully loaded quic.rocks using QUIC!”,使用的协议为HTTP/3,并且错误代码为0x100,即未发生错误。</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121082657671-1708764803.png" alt="" loading="lazy"></p>
<h4 id="抓包-1">抓包</h4>
<p>在wireshark中的首选项-protocol-tls-(pre)-master-secret log filename设置为上面输出的key.log文件,用来对quic的payload进行解密,之后可以看到客户端的完整的请求过程,包括1-RTT的握手,HTTP3数据发送,断开连接等:</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121115216243-856724438.jpg" alt="" loading="lazy"></p>
<h2 id="协议分析">协议分析</h2>
<h3 id="数据包">数据包</h3>
<p>quic的数据包是通过UDP数据报进行传输的,一个数据报中可以包含一个或多个quic数据包。quic数据包编号被分为三个空间:</p>
<ul>
<li>Initial:所有初始包</li>
<li>Handshake:所有握手包</li>
<li>Application data:所有 0-RTT 和 1-RTT 加密的数据包</li>
</ul>
<p>从上图的抓包中可以看见三种类型的包:Initial,Handshake以及Protected payload即Application data。</p>
<h4 id="首部">首部</h4>
<p>quic首部分为两种:Long header 和 Short Header,通过第一个有效字节的最高位来区分。首部当中有部分字段是于版本有关的,本文将以quic-29为基础进行分析。</p>
<p>Long header的定义如下:</p>
<pre><code>Long Header Packet {
Header Form (1) = 1,
Fixed Bit (1) = 1,
Long Packet Type (2),
Type-Specific Bits (4),
Version (32),
Destination Connection ID Length (8),
Destination Connection ID (0..160),
Source Connection ID Length (8),
Source Connection ID (0..160),
}
</code></pre>
<p>Long Header Packets的类型包括四种:Initial,0-RTT,Handshake,Retry。</p>
<p>Short Header的定义如下:</p>
<pre><code>Short Header Packet {
Header Form (1) = 0,
Fixed Bit (1) = 1,
Spin Bit (1),
Reserved Bits (2),
Key Phase (1),
Packet Number Length (2),
Destination Connection ID (0..160),
Packet Number (8..32),
Packet Payload (..),
}
</code></pre>
<p>在版本协商以及1-RTT密钥传输完成后,quic就会使用Short Header Packet来传输数据。</p>
<h3 id="连接迁移-connection-migration">连接迁移 Connection Migration</h3>
<p>quic通过在首部携带Connection ID来保证在底层协议(UPD、IP等)寻址发生变化时也能够将数据包分发到正确的端点上。在TCP协议中,是通过四元组(源 IP,源端口,目的 IP,目的端口)来标识连接的,而当网络发生切换时,IP就会发生变化,使得连接需要重新建立,浪费大量时间;而quic通过Connection ID来对连接进行标识,只要ID不变,这条连接就可以保持,这就给quic协议带来了连接迁移的特性。</p>
<h3 id="握手">握手</h3>
<p>quic加密握手提供以下属性:</p>
<ul>
<li>认证密钥交换,其中
<ul>
<li>服务端总是经过身份验证</li>
<li>客户端可以选择性进行身份验证</li>
<li>每个连接都会产生不同并且不相关的密钥</li>
<li>密钥材料(keying material)可用于 0-RTT 和 1-RTT 数据包的保护</li>
</ul>
</li>
<li>两个端点(both endpoints)传输参数的认证值,以及服务端传输参数的保密保护</li>
<li>应用协议的认证协商(TLS 使用 ALPN)</li>
</ul>
<p>1-rtt的握手流程如下所示:</p>
<pre><code>Client Server
Initial: CRYPTO ->
Initial: CRYPTO ACK
Handshake: CRYPTO
<- 1-RTT: STREAM
Initial: ACK
Handshake: CRYPTO, ACK
1-RTT: STREAM, ACK ->
Handshake: ACK
<- 1-RTT: HANDSHAKE_DONE, STREAM, ACK
</code></pre>
<p>0-rtt的握手流程如下所示:</p>
<pre><code>Client Server
Initial: CRYPTO
0-RTT: STREAM ->
Initial: CRYPTO ACK
Handshake CRYPTO
<- 1-RTT: STREAM ACK
Initial: ACK
Handshake: CRYPTO, ACK
1-RTT: STREAM ACK ->
Handshake: ACK
<- 1-RTT: HANDSHAKE_DONE, STREAM, ACK
</code></pre>
<h2 id="源码分析">源码分析</h2>
<p>在example的client代码中,通过<code>http3.RoundTripper</code>建立了一个中间件,之后将<code>roundTripper</code>传递给<code>http.Client</code>建立了一个http客户端,并以此来发起http请求。</p>
<pre><code class="language-go">roundTripper := &http3.RoundTripper{
TLSClientConfig: &tls.Config{
RootCAs: pool,
InsecureSkipVerify: *insecure,
KeyLogWriter: keyLog,
},
QuicConfig: &qconf,
}
defer roundTripper.Close()
hclient := &http.Client{
Transport: roundTripper,
}
rsp, err := hclient.Get(addr)
</code></pre>
<p><code>http3.RoundTripper</code>实现了<code>net.RoundTripper</code>接口,使http客户端将发起请求的过程交由该中间件来处理。该接口定义如下,只有一个函数<code>RoundTrip</code>接受一个http请求,返回http响应。</p>
<pre><code class="language-go">type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
</code></pre>
<p>在<code>http3.RoundTripper</code>的实现中,将请求又交给了<code>RoundTripOpt</code>函数来处理。该函数中首先判断请求是否合法,如果不合法就关闭请求,合法就会通过<code>cl, err := r.getClient(hostname, opt.OnlyCachedConn)</code>来获取quic客户端。</p>
<p>而在<code>getClient</code>函数中,通过hash表来获取quic client,如果不存在就会通过<code>newClient</code>函数建立新client。</p>
<p>当获取到client之后,就会通过<code>client.RoundTrip</code>函数发起请求。</p>
<p>而在<code>client.RoundTrip</code>中,在发起请求之前,会调用<code>authorityAddr</code>来确保源地址不是伪造的。当第一次发送请求时会调用<code>dial</code>函数进行握手,如果使用0rtt请求,就立即发送请求,否在当握手完成后通过<code>doRequest</code>发出请求。</p>
<h3 id="quic请求流程分析">QUIC请求流程分析</h3>
<h4 id="时序图">时序图</h4>
<p>整个过程的时序图如下所示,忽略了部分ACK帧:</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121135453344-1539146569.png" alt="" loading="lazy"></p>
<p>可以看出在1-RTT时,就开始了数据的传输,在2RTT时数据传输完成并准备关闭连接。这也就是QUIC协议快于TCP协议的一个主要原因。</p>
<h4 id="数据包的发送">数据包的发送</h4>
<p>握手的函数调用栈为<code>dial</code> -> <code>dialAddr</code> -> <code>DialAddrEarly</code> -> <code>DialAddrEarlyContext</code> -> <code>dialAddrContext</code> -> <code>dialContext</code> -> <code>newClient</code> -> <code>client.dial</code> -> <code>newClientSession</code> -> <code>session.run</code> -> <code>RunHandshake</code> -> <code>conn.Handshake</code> -> <code>clientHandshake</code>。最终在<code>Conn.clientHandshake</code>函数中完成了握手的设置,之后通过<code>clientHandshakeState.handshark</code>函数完成了发送等工作。</p>
<p>在newClient函数中,通过<code>generateConnectionID</code>和<code>generateConnectionIDForInitial</code>对<code>srcConnID</code>和<code>destConnID</code>进行了生成。</p>
<p>在handshark函数中,调用<code>establishKeys</code>函数,完成了密钥的生成,之后调用<code>sendFinished</code>函数,将Client Hello帧写入到TLS Record层,完成握手包的发送。</p>
<h4 id="数据包的接收">数据包的接收</h4>
<p>在<code>session.run</code>中的<code>runloop</code>中,通过<code>select</code>对接收通道进行监听,当收到数据包时,就会调用<code>handlePacketImpl</code> -> <code>handleSinglePacket</code> -> <code>handleUnpackedPacket</code>函数进行处理。</p>
<p>在<code>handleUnpackedPacket</code>函数中,如果是第一个包,就会读取其SrcConnectionID,将其设置为该连接的destination connection ID;之后对包中的帧依次进行读取,并使用<code>parseFrame</code>函数进行判断,并调用对应函数进行解析,最后调用<code>handleFrame</code>函数中调用相关函数进行处理。</p>
<p>在握手过程中,接收的第一个Initial包为合并包(coalesced packet),其第一个帧为ACK帧,通过<code>parseAckFrame</code>进行解析,使用<code>handleAckFrame</code>函数进行处理;第二个帧为Crypto帧,消息为Server Hello,通过<code>parseCryptoFrame</code>函数解析,<code>handleCryptoFrame</code>函数进行处理,该函数会通过<code>session.cryptoStreamManager</code>对密钥信息进行处理。之后第二个Handshake包中只有一个Crypto帧,消息类型为Encrypted Extensions。第三个quic包中包含了一个Stream帧,stream id为3,这个帧会通过<code>handleStreamFrameImpl</code>进行处理,在该函数中会将数据<code>push</code>到<code>frameQueue</code>队列中去,之后通过<code>signalRead</code>函数来通知数据包的到达。该帧的内容为HTTP3的SETTINGS帧。</p>
<h4 id="连接建立及http3数据传输">连接建立及HTTP3数据传输</h4>
<p>在第二个RTT中,client先通过Initial包发送ACK帧对收到的包进行确认,之后再通过Handshake包发送了CRYPTO帧和ACK帧,此CRYPTO帧的消息为Handshark protocol: Finished。最后再分别发送了Stream id为0和2的HTTP3 HEADERS帧和SETTINGS帧。</p>
<p>Stream id为0的HEADERS包即为http请求,该包使用了QPACK方法进行压缩,该方法与http2的HPACK类似,而根据QPACK的定义,id为2和3的stream分别为encoder stream和decoder stream,即上文中提及的两个SETTINGS帧。</p>
<p>之后client接收到了Handshark包,其中包含一个ACK帧。此时,1-RTT的握手过程已经结束,因此接下来收到的包的类型就变为了Short header packet,收到的第一个包的类型为HANDSHARK_DONE,说明握手完成。</p>
<p>最后,服务端返回了一个HTTP3的DATA帧,该帧中即包含了请求的响应数据,如下图,可以看到数据的对应文本即为html文档。</p>
<p><img src="https://img2020.cnblogs.com/blog/2237217/202101/2237217-20210121132000022-960517082.jpg" alt="" loading="lazy"></p>
<p>在收到数据后,客户端就发送了一个CONNECTION_CLOSE的帧关闭连接,Error code为0x100说明正常关闭,未发生错误。</p><br><br>
来源:https://www.cnblogs.com/weijunji/p/quic-study.html
頁:
[1]