QUIC 发生了什么
从 HTTP/1.1(1999 年发布)到 HTTP/2 发布(2015 年发布)之间的发展差距很大,随着 2019 年 HTTP/3 的发布,HTTP/3 即将成为互联网上超文本传输协议的下一代协议,HTTP/3 是Google QUIC协议的演变。它与传统的 HTTP 有很大的不同。QUIC 是一种新的可靠传输协议,可以被视为一种下一代TCP。
QUIC 连接通过 UDP 端口和 IP 地址建立的,一旦建立,该连接就通过其“连接 ID”相关联(每个连接都拥有一组连接标识符或连接 ID,每个连接标识符都可用于识别连接)。
QUIC 提供 0-RTT 和 1-RTT 连接设置,所以建立新连接时,会比传统的 TCP + TLS 的组合要快许多。
HTTP 的演变
超文本传输协议 (HTTP) 是运行在 TCP/IP 之上的应用层协议, HTTP 有 4 个稳定版本——HTTP/0.9、HTTP/1.0、HTTP/1.1 和 HTTP/2。在 2021年七月,市面上已经有 三分之二的浏览器支持了 HTTP/3 。
同年5月底,IETF:QUIC Version 1 (RFC 9000) 作为标准化版本现已发布,QUIC部署将从使用临时草案版本转向新创建的Version 1。
QUIC 从哪来
IETF 并不是从头开始研究 QUIC。2012 年,谷歌设计了自己的 QUIC 版本, 随后 Chrome 浏览器及其Google的大部分服务相继支持QUIC,包括 Youtube 和 Google 搜索。
IETF QUIC 与 早期的 gQUIC 相比也已经有了比较大的变化,这里不妨列举几个。
- 加密协商:谷歌 QUIC 是定制加密握手,而 IETF 是采用了 TLS1.3
- 分离:IETF QUIC 的层级是分明的,其上可以承载的应用层协议不止 HTTP 协议
- 头部压缩:gQUIC 沿用了HTTP2的 HPack,而IETF QUIC 使用的是QPACK。
QUIC 的版本
目前有15种实验型QUIC。【RFC9000 确定后,所有的QUIC版本会趋于统一】。但是这些实验性QUIC互操性比较差,因为各家的QUIC的实现标准不统一,对具体的一些功能实现存在diff。
数据截取于 2021.07
使用Python和asyncio的QUIC实现
- Language: Python
- Version: draft-29 through version 1(RCF 9000)
- Handshake: TLS 1.3
- Protocol IDs:
0xff00001d
,0xff00001e
,0xff00001f
,0xff000020
,0x1
Chromium的QUIC实现(chrome85.0.4171.0及更高版本支持draft-29)
- Languages: C, C++
- Versions: Q043, Q046, Q050, T050, T051, draft-27, draft-29.
- Handshakes: QUIC Crypto, TLS
- Protocol IDs:
Q043
,Q046
,Q050
,T050
,T051
,0xff00001b
,0xff00001d
- ALPNs: h3-Q043, h3-Q046, h3-Q050, h3-T050, h3-T051, h3-27, h3-29
- Google将实现命名为“quiche”,但它与Cloudflare的Rust实现完全无关。
LiteSpeed QUIC和HTTP/3库。适用于Linux、FreeBSD、MacOS、Android和Windows。
- Language: C
- Version: v1, Draft-34, Draft-29, Draft-27, Q043, Q046, and Q050.
- Roles: Client, Server, Library
- Handshake: QUIC Crypto, RFC 8446
- Protocol IDs:
0x00000001
,0xFF000022
,0xFF00001D
,0xFF00001B
当前互联网上的QUIC流量,draft-29 和 Version 1 版本 的量级在逐渐上升。国内的一些大厂也在使用QUIC来作为当前主要的流量接入,比如微信的视频号就使用 gQUIC 43 、快手也宣布了线上的千万级QUIC计划。
相信今年的 RFC9000 推出 QUICv1 版本后,QUIC的互操性能得到更广泛的运用。当前各大版本实现的QUIC互操性能得到进一步的提升。
QUIC 实验
当前的QUIC 版本很多,支持的比较完整的是 Chromium 和 lsquic 。
下面将以 lsquic 为例,实际剖析一下QUIC。
目前支持的QUIC版本有v1、Internet draft-27 和 draft-29, 包括老一些的Google QUIC 版本 Q043
、Q046
和 Q050
。
相关文档: https://lsquic.readthedocs.io/en/latest/
要构建LSQUIC,需要CMake、zlib和BoringSSL。
编译安装
- Building BoringSSL
|
|
- Building LSQUIC Library
|
|
- demo
quic 的 demo 在 $lsquic_pwd/bin 目录下, 我们实验用就用 http_client
吧。
实验
先看看 http_client
支持的几个命令行参数.
|
|
QUIC 发生了什么
lsquic 提供了测试网站 www.litespeedtech.com
,我们用下面的命令行工具访问这个网站, 并使用Wireshark 抓包和 qlog 去解析QUIC,探索一些QUIC究竟发生了什么。
Wireshark (3.4.2)已经支持了解析 IETF QUIC,加载秘钥的方法参考 Wireshark Tutorial: Decrypting HTTPS Traffic
|
|
当前目录下会生成一个 sslkeylog F9CE5E3D598B8D80.keys
,wireshark 导入,注意Wireshark 要装最新版本(当前是 3.4.2)。
注意: Wireshark 本身无法解密 GQUIC 数据包,即使已配置NSS Keylogging。
更多QUIC debug or 可视化工具参考: https://github.com/quicwg/base-drafts/wiki/Tools
抓包内容如下:
这里再介绍一下HTTP3的分层结构。
一个 HTTP3 包会按照以下方式分层。实时上 QUIC 已经完全可以理解为一个传输层协议了,QUIC的实现也是和HTTP协议分离的,正如 RFC 9000 所描述的。“QUIC 是一种面向连接的协议,它在客户端和服务器之间创建有状态的交互。”
quic-go 的 echo 示例 也在其示例代码中提供了QUIC层的demo `session.OpenStreamSync(context.Background())`
|
|
Packet Header
Packet Header实现了可靠的连接。当UDP报文丢失后,通过Packet Header中的Packet Number实现报文重传。连接也是通过其中的Connection ID字段定义的
这里会有两种 Header 类型,Long Header 和 Short Header。Long Header 用于初始化交换 直到 可以1RTT可以被激动和版本协商完成。Short Header 用于承载数据。
- Long Header
|
|
建立连接时,连接是由服务器通过Source Connection ID字段分配的,这样,后续传输时,双方只需要固定住Destination Connection ID,就可以在客户端IP地址、端口变化后,绕过UDP四元组(与TCP四元组相同),实现连接迁移功能。如下图,后续的DCID和SCID都会统一为 752566b9c5e2b77a
- Short Header
|
|
Packet Number是每个报文独一无二的序号,基于它可以实现丢失报文的精准重发。
参考: martinthomson/quic_header.md
QUIC Frame Header
在Packet Header之上的QUIC Frame Header, QUIC中的流为应用程序提供了轻量级的有序字节流抽象。流可以是单向的或双向的,而且Stream之间可以实现真正的并发。
所有的帧被包含在单独的QUIC Packet 中,且没有帧可以跨越QUIC Packet 边界。一个Packet报文中可以存放多个QUIC Frame,当然所有Frame的长度之和不能大于PMTUD(Path Maximum Transmission Unit Discovery,这是大于1200字节的值), 一个QUIC Frame 也不允许跨Packet。
每一个QUIC Frame 都有明确的帧类型。
参考: 帧类型和格式
上述文档比较老,很多数据有变动,下面是结合 RFC9000 的版本
当前定义的普通帧类型如下:
|
|
第三列 SPEC 规定了该帧的一些特殊用法 N : 该类帧不会被ACK C : 出于拥塞控制目的,该类型帧不会被计入飞行中的字节数 P : 该类型帧的数据包可用于在连接迁移期间探测新的网络路径 F : 该类型帧用于流控
- PADDING 帧:PADDING帧使用0x00字节填充一个包。当遇到该帧时,包的剩余部分需要被填充字节。 该帧包含0x00字节并扩展至QUIC包的末端。
- PING 帧:PING帧用来验证对端是否仍然存活。PING帧不包含载荷。 PING帧的接收方只需要应答(ACK)包含该帧的包。 PING帧应该被用于当一条流被打开时,保持连接存活。 默认是30s
- ACK 帧: 发送ACK帧以通知对端已经接收了哪些分组,以及接收方仍然认为丢失了哪些分组(可能需要重新发送丢失分组的内容)。
- 如果帧类型为0x02:普通的ACK应答作用
- 如果帧类型为0x03:则ACK帧还包含在此点之前在连接上接收到的具有相关ECN标记的QUIC数据包的累积计数。如果发送的数据包启用了ECN,则应使用ECN部分中的信息来管理拥塞状态
- RST_STREAM 帧:RST_STREAM帧允许异常终止一条流。当这个帧是流的创建者发出的,表示创建者希望取消这条流。 当接收端发送这个帧,表示有错误或者当前接收端不希望接收这个流,因此这个流应该被关闭。
- STOP_SENDING 帧:请求对等方停止流上的传输.
- CRYPTO 帧:用于传输加密握手消息,加密帧在功能上与STREAM帧相同,但是不受流控。加密帧上传输 TLS 握手细信息。
- NEW_TOKEN 帧:为客户端提供一个令牌,以便用于之后的链接0-RTT。
- STREAM 帧 : STREAM帧同时被用于隐式地创建流和在流上发送数据。STREAM帧中的类型字段的格式为0B00001xxx(或0x08到0x0f的一组值),帧类型的三个低阶位确定帧中存在的字段。流可以是单向的,也可以是双向的,对于连接上的所有流都是唯一的。流低2位可标识是客户端还是服务端发起的单向流或双向流。
|
|
- MAX_DATA 帧:用于流控,用于通知对端整个连接上可发送的最大数据量。
- MAX_STREAM_DATA 帧:用于流控,用于通知对端流上可发送的最大数据量。
- MAX_STREAMS 帧:通知对等端允许打开的给定类型的流的最大数。
- 0x12 的MAX_STREAM适用于双向流 - 0x13 的MAX_STREAM适用于单向流
- DATA_BLOCKED 帧:BLOCKED帧用于向远端指明本端点已经准备好发送数据了(且有数据要发送), 但是当前被流量控制阻塞了。这是一个纯粹的信息帧,它对于调试极其有用。 BLOCKED帧的接收者应该简单的丢弃它(可能在打印了一条有帮助的log消息之后)
- STREAM_DATA_BLOCKED 帧:当发送方希望发送数据但由于流级流控制而无法发送数据时,发送方应发送 STREAM_DATA_BLOCKED。
- STREAMS_BLOCKED 帧:当发送方希望打开流,但由于其对等方设置的最大流限制而无法打开流时,发送方应发送STREAMS_BLOCKED, - 0x16 的STREAMS_BLOCKED适用于双向流 - 0x17 的STREAMS_BLOCKED适用于单向流
- NEW_CONNECTION_ID 帧:发送一个新的 NEW_CONNECTION_ID 帧, 为其对端提供可用于在迁移连接时中断可链接性的替代连接ID
- RETIRE_CONNECTION_ID 帧: 一端发送 RETIRE_CONNECTION_ID 表示不再使用对端发送的CONNECTION ID ,包含握手时确定的 连接id,后续可以发送新的 CONNECTION ID 再。
- PATH_CHALLENGE 帧:来检查对等方的可达性,并在连接迁移期间进行路径验证
- PATH_RESPONSE 帧: PATH_CHALLENGE 的响应帧
- CONNECTION_CLOSE 帧:CONNECTION_CLOSE帧用来通知连接将被关闭。如果流仍然有数据在发送,那么在连接关闭时, 这些流将被隐式关闭。
- HANDSHAKE_DONE 帧:向客户端发送握手确认信号。
HTTP3 Frame Header
QUIC 的 STREAM 帧是实际承载流量的帧。自然也是 HTTP3 协议的承载。
Stream Frame头部的3个字段,完成了多路复用、有序字节流以及报文段层面的二进制分隔功能
- Stream ID 标识了一个有序字节流。当HTTP Body非常大,需要跨越多个Packet时,只要在每个Stream Frame中含有同样的Stream ID,就可以传输任意长度的消息。多个并发传输的HTTP消息,通过不同的Stream ID加以区别
- 消息序列化后的“有序”特性,是通过Offset字段完成的,它类似于TCP协议中的Sequence序号,用于实现Stream内多个Frame间的累计确认功能;
- Length指明了Frame数据的长度。
HTTP3 的Frame 继承了 HTTP2 的Frame 设计。
|
|
QUIC Stream Frame定义了有序字节流,且多个Stream间的传输没有时序性要求,这样,HTTP消息基于QUIC Stream就实现了真正的多路复用,队头阻塞问题自然就被解决掉了。
HTTP Header头部的编码方式,它需要面对另一种队头阻塞问题
与HTTP2中的HPACK编码方式相似,HTTP3中的QPACK也采用了静态表、动态表及Huffman编码:
在HTTP2中,共有61个静态表项,而在QPACK中,则上升为98个静态表项, 比如 qpack 的golang 实现中的 staticTableEntries
所示。可以从这里找到 HTTP3 的完整静态表
相较于HTTP3的静态表,和HTTP2协议其实并没有特别大的变化。但动态表编解码方式差距很大。
所谓动态表,就是将未包含在静态表中的Header项,在其首次出现时加入动态表,这样后续传输时仅用1个数字表示,大大提升了编码效率。因此,动态表是天然具备时序性的,如果首次出现的请求出现了丢包,后续请求解码HPACK头部时,一定会被阻塞!
事实上,QPACK将动态表的编码、解码独立在单向Stream中传输,仅当单向Stream中的动态表编码成功后,接收端才能解码双向Stream上HTTP消息里的动态表索引。
因此,当Stream ID是0、4、8、12时,这就是客户端发起的双向Stream(HTTP3不支持服务器发起双向Stream),它用于传输HTTP请求与响应。单向Stream有很多用途,所以它在数据前又多出一个Stream Type字段:
Stream Type有以下取值: - 0x00:控制Stream,传递各类Stream控制消息; - 0x01:服务器推送消息; - 0x02:用于编码QPACK动态表,比如面对不属于静态表的HTTP请求头部,客户端可以通过这个Stream发送动态表编码; - 0x03:用于通知编码端QPACK动态表的更新结果。
由于HTTP3的STREAM之间是乱序传输的,因此,若先发送的编码Stream后到达,双向Stream中的QPACK头部就无法解码,此时传输HTTP消息的双向Stream就会进入Block阻塞状态(两端可以通过控制帧定义阻塞Stream的处理方式)。
QPack 在编解码原理上和 HPack 没有本质区别。从 QPACK 的代码中可以看出,其内部完全是套了一层 HAPCK 的实现
|
|
QUIC Feature
0 RTT
使用 0-RTT 取决于客户端和服务器使用从先前连接协商的协议参数。为了启用 0-RTT, 客户端将服务器传输参数与它在连接上收到的 session tickets 一起存储, 另外还存储应用程序或加密握手所需的信息。
和握手相关的报头都是长报头(Long Packet
) 。分别是 Initial (0x00)
、0-RTT (0x01)
、Handshake (0x02)
、Retry (0x03)
。
CRYPTO
帧可以在不同的数据包编号空间(packet number spaces)中发送。CRYPTO 帧使用偏移量(offsets)来确保加密握手数据的有序传递的在每个包编号空间(packet number spaces)都是从零开始。
握手流程示例
- QUIC 在握手前会先进行地址验证(Address Validation),确保请求包里面的源地址不是伪造的。
- 一旦地址验证交换完成,就可以使用加密握手来获取加密密钥。加密握手通过初始(Initial)和握手(Handshake)包进行传输。
- 下图展示了 1-RTT 握手的示例。每行显示一个 QUIC 包(packet),首先显示包类型(type)和包编号(number),然后是帧(frames)。例如,第一个包是 Initial 类型,包编号为 0,并且包含一个携带 ClientHello(缩写:CH) 的 CRYPTO 帧。
- 多个 QUIC 数据包(即便是不同的类型)也可以合并成一个单独的 UDP 数据报(datagram)。因此,下图所示的 1-RTT 握手可以由 4 个UDP数据报(datagrams)组成。如果受协议固有的限制(如拥塞控制(congestion control)和反放大(anti-amplification))也可以使用更多的数据报。
- 1 - RTT
|
|
- 0 - RTT
|
|
如下是取自于 qvis的 1-RTT 握手请求。
地址验证(Address Validation)
地址验证主要是用于确保端点(endpoint)不能被用于流量放大攻击(traffic amplification attack)。攻击者如果伪造数据包的源地址为受害者的地址,发送大量的数据包给服务端,如果服务端没有进行地址验证,直接响应大量数据包给源地址(受害者),就会被攻击者利用、进行流量放大攻击。
QUIC 针对放大攻击的主要防御措施是验证端点(endpoint)是否能够在其声明的传输地址接收数据包。地址验证在连接建立(connection establishment)期间和 连接迁移(connection migration)期间进行。
- 连接建立时,为了验证客户端的地址是否是攻击者伪造的,服务端会生成一个令牌(token)并通过重试包(Retry packet)响应给客户端。客户端需要在后续的初始包(Initial packet)带上这个令牌,以便服务端进行地址验证。
- 服务端可以在当前连接中通过 NEW_TOKEN 帧预先发布令牌,以便客户端在后续的新连接使用,这是 QUIC 实现 0-RTT 很重要的一个功能。
- 当我们的网络路径变化时(比如从蜂窝网络切换到 WIFI),QUIC 提供了连接迁移(connection migration)的功能来避免连接中断。QUIC 通过路径验证(Path Validation)验证网络新地址的可达性(reachability),防止在连接迁移中的地址是攻击者伪造的。
使用重试数据包(Retry Packets)验证地址
在接收到客户端的初始数据包(Initial packet)后,服务端可以通过发送包含令牌(token)的重试数据包(Retry packet)来请求地址验证。客户端在接收到这个重试数据包(Retry packet)的令牌(token)之后,必须在该连接中后续所有初始数据包(Initial packet)中附上该令牌(token)。
|
|
后续的连接使用 NEW_TOKEN 帧的令牌(token)
服务端可以在一次连接中向客户端提供地址验证令牌(token),该令牌可用于后续的连接。这对于 0-RTT 尤其重要,后续的新连接可以直接使用该令牌进行地址验证,而无需额外的 1-RTT。
服务端使用 NEW_TOKEN 帧向客户端提供地址验证令牌,该令牌可用于验证后续的连接。在后续的连接中,客户端在初始数据包(Initial packets)中包含该令牌,以提供地址验证。
重试数据包(Retry packet)中提供的令牌只能立即使用,不能用于后续连接的地址验证。而 NEW_TOKEN 帧生成的令牌可以在一个时间范围内使用,这个令牌应该有一个过期时间,可以是显式的过期时间,也可以是可用于动态计算过期时间的时间戳(timestamp)。服务端可以存储过期时间,也可以在令牌中以加密的形式包含它。
参考: 跟坚哥学QUIC系列:地址验证(Address Validation)
链接迁移
QUIC 使用连接ID(而不是 ip + port)来确保数据包的路由一致性。如果用户的 IP 发生变化时,比如从移动蜂窝 4G 网络切换到 WiFi,IP 地址会改变。而使用一个唯一的连接ID 可以确保用户的 IP 变化时业务请求依然能够被继续处理,不用重新建连,可以继续使用当前连接ID 路由数据包,因此 QUIC 可以通过这个特性支持连接迁移
QUIC 的数据包(packets)长报头(long header)包含两个连接ID:目标连接ID(Destination Connection ID)由数据包的接收者选择并用于提供一致的路由,源连接ID(Source Connection ID)用于对端(peer)响应时使用的目标连接ID(Destination Connection ID)。
在握手过程中,带有长报头的包用于建立两端(both endpoints)使用的连接ID。在处理第一个初始数据包(Initial packet)之后,每个端点使用其接收到的源连接ID(Source Connection ID)字段的值设置为后续数据包中的目标连接ID(Destination Connection ID)字段。
当客户端发送了一个初始包(Initial packet),而该客户端之前没有从服务端接收过初始数据包(Initial packet)或重试包(Retry packet),则客户端将用一个不可预测的值(长度至少为8字节)填充到目标连接ID字段。在从服务端接收到数据包之前,客户端必须对该连接中的所有数据包使用相同的目标连接ID值。
当第一次从服务端接收到初始(Initial)或重试(Retry)数据包时,客户端使用服务端提供的源连接ID作为后续数据包(包括任何 0-RTT 数据包)的目标连接ID。这意味着在建立连接的过程中,客户端可能需要两次更改它的目标连接ID字段:一次用于响应重试(Retry),一次用于响应来自服务端的初始数据包(Initial)。一旦客户端从服务端接收到有效的初始数据包,客户端必须丢弃它后续接收到的具有不同源连接ID的数据包。
服务端必须根据第一个接收到的初始数据包(Initial packet)的源连接ID,设置为用于发送数据包的目标连接ID。后续只有当接收到 NEW_CONNECTION_ID 帧时,才允许对目标连接ID进行更改。如果后续初始数据包包含不同的源连接ID,则必须将其丢弃。这样可以避免由于无状态(stateless)处理具有不同源连接ID的多个初始数据包而导致的不可预测结果。
参考文章: