QUIC 協議分析 - 基於 quic-go

1|**0**quic 協議分析

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

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

1|1 源碼編譯與測試

1|0 下載

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

1|0 編譯

1|0 服務端

cd example
go build main.go

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

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

1|0 客戶端

先修改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 協議的網站。

1|0 服務端測試

1|0 瀏覽器訪問

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

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

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

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

1|0 抓包

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

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

1|0 問題解決

當訪問時,服務器可能會報錯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}即可。

1|0 客戶端測試

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

1|0 抓包

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

1|2 協議分析

1|0 數據包

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

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

1|0 首部

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 來傳輸數據。

1|0 連接遷移 Connection Migration

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

1|0 握手

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

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]

1|3 源碼分析

在 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發出請求。

1|**0**QUIC 請求流程分析

1|0 時序圖

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

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

1|0 數據包的發送

握手的函數調用棧爲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 層,完成握手包的發送。

1|0 數據包的接收

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

1|0 連接建立及 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 說明正常關閉,未發生錯誤。

本文作者:星見遙
本文鏈接:https://www.cnblogs.com/weijunji/p/quic-study.html
關於博主:評論和私信會在第一時間回覆。或者直接私信我。
版權聲明:本博客所有文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!
聲援博主:如果您覺得文章對您有幫助,可以點擊文章右下角**【推薦】**一下。您的鼓勵是博主的最大動力!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://www.cnblogs.com/weijunji/p/quic-study.html