QUIC協議分析-基於quic-go


quic協議分析

QUIC是由谷歌設計的一種基於UDP的傳輸層網絡協議,並且已經成為IETF草案。HTTP/3就是基於QUIC協議的。QUIC只是一個協議,可以通過多種方法來實現,目前常見的實現有Google的quiche,微軟的msquic,mozilla的neqo,以及基於go語言的quic-go等。

由於go語言的簡潔性以及編譯的便捷性,本文將選用quic-go進行quic協議的分析,該庫是完全基於go語言實現,可以用於構建客戶端或服務端。

源碼編譯與測試

下載

  1. 從https://golang.org/dl/下載golang編譯器,要求go版本為1.14+。
  2. 使用git clone https://github.com/lucas-clemente/quic-go.git下載庫

編譯

服務端

cd example
go build main.go

之后使用./main -qlog -v -tcp運行即可。

必須帶上-tcp參數是因為瀏覽器第一次訪問時仍然是要通過TCP進行的,如果不帶瀏覽器將無法訪問。

客戶端

先修改example/client/main.go,在60行之后加上qconf.Versions = []protocol.VersionNumber{protocol.VersionDraft29},選擇quic版本為draft-29。

cd example/client
go build main.go

之后使用./main -v -insecure -keylog ssl.log https://quic.rocks:4433/即可訪問支持quic協議的網站。

服務端測試

瀏覽器訪問

在firefox中打開about:config,搜索HTTP3,將值設為True以打開HTTP3的實驗特性。

打開https://localhost:6121/demo/tile網頁,通過調試工具查看請求,當第一次請求該網頁時,會通過TCP協議進行:

而在響應頭中會帶上Alt-Svc,以告訴瀏覽器該服務器支持HTTP3協議:

之后刷新頁面,瀏覽器就會以HTTP3協議來訪問:

抓包

使用wireshark對loopback進行抓包,過濾器設置為udp.port==6121,此時wireshark只顯示為UDP協議,並未解析為quic,需要右鍵Decode As解析為quic。

可以看到,第一個包的類型為Initial,進行了0-RTT的初始化。

問題解決

當訪問時,服務器可能會報錯Client offered version draft-29, sending Version Negotiation,這是因為當使用-tcp選項后,將使用默認設置,而在默認設置中未開啟draft-29版本的支持,因此需要修改源碼,將internal/protocol/version.go:30中的var SupportedVersions = []VersionNumber{VersionTLS}修改為var SupportedVersions = []VersionNumber{VersionTLS, VersionDraft29}即可。

客戶端測試

使用./main -v -insecure -keylog key.log https://quic.rocks:4433/訪問測試網站,可以看見最后成功輸出了網頁的內容 “You have successfully loaded quic.rocks using QUIC!”,使用的協議為HTTP/3,並且錯誤代碼為0x100,即未發生錯誤。

抓包

在wireshark中的首選項-protocol-tls-(pre)-master-secret log filename設置為上面輸出的key.log文件,用來對quic的payload進行解密,之后可以看到客戶端的完整的請求過程,包括1-RTT的握手,HTTP3數據發送,斷開連接等:

協議分析

數據包

quic的數據包是通過UDP數據報進行傳輸的,一個數據報中可以包含一個或多個quic數據包。quic數據包編號被分為三個空間:

  • Initial:所有初始包
  • Handshake:所有握手包
  • Application data:所有 0-RTT 和 1-RTT 加密的數據包

從上圖的抓包中可以看見三種類型的包:Initial,Handshake以及Protected payload即Application data。

首部

quic首部分為兩種:Long header 和 Short Header,通過第一個有效字節的最高位來區分。首部當中有部分字段是於版本有關的,本文將以quic-29為基礎進行分析。

Long header的定義如下:

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),
}

Long Header Packets的類型包括四種:Initial,0-RTT,Handshake,Retry。

Short Header的定義如下:

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 (..),
}

在版本協商以及1-RTT密鑰傳輸完成后,quic就會使用Short Header Packet來傳輸數據。

連接遷移 Connection Migration

quic通過在首部攜帶Connection ID來保證在底層協議(UPD、IP等)尋址發生變化時也能夠將數據包分發到正確的端點上。在TCP協議中,是通過四元組(源 IP,源端口,目的 IP,目的端口)來標識連接的,而當網絡發生切換時,IP就會發生變化,使得連接需要重新建立,浪費大量時間;而quic通過Connection ID來對連接進行標識,只要ID不變,這條連接就可以保持,這就給quic協議帶來了連接遷移的特性。

握手

quic加密握手提供以下屬性:

  • 認證密鑰交換,其中
    • 服務端總是經過身份驗證
    • 客戶端可以選擇性進行身份驗證
    • 每個連接都會產生不同並且不相關的密鑰
    • 密鑰材料(keying material)可用於 0-RTT 和 1-RTT 數據包的保護
  • 兩個端點(both endpoints)傳輸參數的認證值,以及服務端傳輸參數的保密保護
  • 應用協議的認證協商(TLS 使用 ALPN)

1-rtt的握手流程如下所示:

Client                                                  Server

Initial[0]: CRYPTO[CH] ->

                                 Initial[0]: CRYPTO[SH] ACK[0]
                       Handshake[0]: CRYPTO[EE, CERT, CV, FIN]
                                 <- 1-RTT[0]: STREAM[1, "..."]

Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[0]: STREAM[0, "..."], ACK[0] ->

                                          Handshake[1]: ACK[0]
         <- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[0]

0-rtt的握手流程如下所示:

Client                                                  Server

Initial[0]: CRYPTO[CH]
0-RTT[0]: STREAM[0, "..."] ->

                                 Initial[0]: CRYPTO[SH] ACK[0]
                                  Handshake[0] CRYPTO[EE, FIN]
                          <- 1-RTT[0]: STREAM[1, "..."] ACK[0]

Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[1]: STREAM[0, "..."] ACK[0] ->

                                          Handshake[1]: ACK[0]
         <- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[1]

源碼分析

在example的client代碼中,通過http3.RoundTripper建立了一個中間件,之后將roundTripper傳遞給http.Client建立了一個http客戶端,並以此來發起http請求。

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)

http3.RoundTripper實現了net.RoundTripper接口,使http客戶端將發起請求的過程交由該中間件來處理。該接口定義如下,只有一個函數RoundTrip接受一個http請求,返回http響應。

type RoundTripper interface { 
       RoundTrip(*Request) (*Response, error)
}

http3.RoundTripper的實現中,將請求又交給了RoundTripOpt函數來處理。該函數中首先判斷請求是否合法,如果不合法就關閉請求,合法就會通過cl, err := r.getClient(hostname, opt.OnlyCachedConn)來獲取quic客戶端。

而在getClient函數中,通過hash表來獲取quic client,如果不存在就會通過newClient函數建立新client。

當獲取到client之后,就會通過client.RoundTrip函數發起請求。

而在client.RoundTrip中,在發起請求之前,會調用authorityAddr來確保源地址不是偽造的。當第一次發送請求時會調用dial函數進行握手,如果使用0rtt請求,就立即發送請求,否在當握手完成后通過doRequest發出請求。

QUIC請求流程分析

時序圖

整個過程的時序圖如下所示,忽略了部分ACK幀:

可以看出在1-RTT時,就開始了數據的傳輸,在2RTT時數據傳輸完成並准備關閉連接。這也就是QUIC協議快於TCP協議的一個主要原因。

數據包的發送

握手的函數調用棧為dial -> dialAddr -> DialAddrEarly -> DialAddrEarlyContext -> dialAddrContext -> dialContext -> newClient -> client.dial -> newClientSession -> session.run -> RunHandshake -> conn.Handshake -> clientHandshake。最終在Conn.clientHandshake函數中完成了握手的設置,之后通過clientHandshakeState.handshark函數完成了發送等工作。

在newClient函數中,通過generateConnectionIDgenerateConnectionIDForInitialsrcConnIDdestConnID進行了生成。

在handshark函數中,調用establishKeys函數,完成了密鑰的生成,之后調用sendFinished函數,將Client Hello幀寫入到TLS Record層,完成握手包的發送。

數據包的接收

session.run中的runloop中,通過select對接收通道進行監聽,當收到數據包時,就會調用handlePacketImpl -> handleSinglePacket -> handleUnpackedPacket函數進行處理。

handleUnpackedPacket函數中,如果是第一個包,就會讀取其SrcConnectionID,將其設置為該連接的destination connection ID;之后對包中的幀依次進行讀取,並使用parseFrame函數進行判斷,並調用對應函數進行解析,最后調用handleFrame函數中調用相關函數進行處理。

在握手過程中,接收的第一個Initial包為合並包(coalesced packet),其第一個幀為ACK幀,通過parseAckFrame進行解析,使用handleAckFrame函數進行處理;第二個幀為Crypto幀,消息為Server Hello,通過parseCryptoFrame函數解析,handleCryptoFrame函數進行處理,該函數會通過session.cryptoStreamManager對密鑰信息進行處理。之后第二個Handshake包中只有一個Crypto幀,消息類型為Encrypted Extensions。第三個quic包中包含了一個Stream幀,stream id為3,這個幀會通過handleStreamFrameImpl進行處理,在該函數中會將數據pushframeQueue隊列中去,之后通過signalRead函數來通知數據包的到達。該幀的內容為HTTP3的SETTINGS幀。

連接建立及HTTP3數據傳輸

在第二個RTT中,client先通過Initial包發送ACK幀對收到的包進行確認,之后再通過Handshake包發送了CRYPTO幀和ACK幀,此CRYPTO幀的消息為Handshark protocol: Finished。最后再分別發送了Stream id為0和2的HTTP3 HEADERS幀和SETTINGS幀。

Stream id為0的HEADERS包即為http請求,該包使用了QPACK方法進行壓縮,該方法與http2的HPACK類似,而根據QPACK的定義,id為2和3的stream分別為encoder stream和decoder stream,即上文中提及的兩個SETTINGS幀。

之后client接收到了Handshark包,其中包含一個ACK幀。此時,1-RTT的握手過程已經結束,因此接下來收到的包的類型就變為了Short header packet,收到的第一個包的類型為HANDSHARK_DONE,說明握手完成。

最后,服務端返回了一個HTTP3的DATA幀,該幀中即包含了請求的響應數據,如下圖,可以看到數據的對應文本即為html文檔。

在收到數據后,客戶端就發送了一個CONNECTION_CLOSE的幀關閉連接,Error code為0x100說明正常關閉,未發生錯誤。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM