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 下載
- 從 https://golang.org/dl / 下載 golang 編譯器,要求 go 版本爲 1.14+。
- 使用
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:所有握手包
- Application data:所有 0-RTT 和 1-RTT 加密的數據包
從上圖的抓包中可以看見三種類型的包: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 加密握手提供以下屬性:
- 認證密鑰交換,其中
- 服務端總是經過身份驗證
- 客戶端可以選擇性進行身份驗證
- 每個連接都會產生不同並且不相關的密鑰
- 密鑰材料(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]
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 函數中,通過generateConnectionID
和generateConnectionIDForInitial
對srcConnID
和destConnID
進行了生成。
在 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
進行處理,在該函數中會將數據push
到frameQueue
隊列中去,之後通過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