ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

故意把 QUIC 数据包弄乱、弄丢、混进别人的包,看它还能不能完整收到

故意把 QUIC 数据包弄乱、弄丢、混进别人的包,看它还能不能完整收到

故意把 QUIC 数据包弄乱、弄丢、混进别人的包,看它还能不能完整收到

【免费下载链接】VidBeeDownload video and audio from YouTube , TikTok , Twitter , Instagram , Facebook , Twitch , Bilibili , and 1000+ sites—or import local media. Create searchable transcripts on your computer, then summarize, translate, or ask questions with your preferred AI provider.项目地址: https://gitcode.com/GitHub_Trending/vi/VidBee

libwebsockets 的minimal-quic-client-server示例,用一个不到 800 行的单文件就能在本地验证一件事:QUIC 面对乱序、丢包、握手干扰时到底靠不靠谱。它不用你额外装任何 QUIC 协议库——lws 的 QUIC 传输栈是原生实现的——你只需编译运行,就能亲眼看着 128KB 数据在“故意搞破坏”的网络里被完整、按序地送达对端。

先搭好这个自测小场景:一个进程里同时跑服务端和客户端

QUIC 跑在 UDP 上,而 UDP 本身既不保证顺序、也不保证送达。这个示例最聪明的地方,是它不假设网络会“老实”,而是主动制造各种“不老实”的情形,再看 QUIC 能不能兜住。

它默认要干完的三件事

运行./lws-minimal-quic-client-server,进程内默认同时做三件事:

  • 在 UDP 端口上起一个 QUIC 服务端;
  • 在同一个进程里再拉起一个 QUIC 客户端,去连这个服务端;
  • 两边各自打开一条流(stream),互发TOTAL_DATA = 128 * 1024(128KB)数据。

所谓“通过”,就一条:每一方都完整、按序地收到了对端发的全部 128KB。测试成功时会打印Test completed successfully! Both sides received full data...,再等 1.5 秒(让最后的 ACK 落地)后以返回码 0 退出。

编译运行前,环境得先满足什么

示例的 CMakeLists.txt 用require_lws_config()卡了三道闸:LWS_ROLE_QUIC、LWS_WITH_CLIENT、LWS_WITH_SERVER都得是 1,缺一就编不过。

有个容易忽略的绑定关系:LWS_WITH_HTTP3默认是开的,而它一旦开启就会自动把LWS_WITH_QUIC一起点亮,所以你通常不用单独去找 QUIC 的开关。再往下一层,QUIC 依赖 TLS 1.3,示例直接用了内嵌在源码里的自签名测试证书(test_cert/test_key),客户端连本地时再用LCCSCF_ALLOW_SELFSIGNED | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK放行这张自签证书。

满足这些后,cmake . && make编译、运行即可。整个示例靠一组命令行开关来“拨”出不同的破坏场景,先列在这,方便你对照着读下文:

开关默认值拨动它会发生什么
-p <port>7681服务端监听端口,也是客户端要连的端口
--server <addr>127.0.0.1客户端连接的服务端地址
-s—只跑服务端:不起客户端,服务端也不主动发数据
-u <url>—客户端改连这个 URL 指向的远端服务端
--relay <port>—客户端不直连,改走本进程内该端口的 UDP 中继
--stray—配合--relay,往客户端灌“别人连接”的包
--write-size <n>1024每次lws_write()发多少字节,取值 1~1024
--cubic—用 CUBIC 拥塞控制器替换默认的 NewReno

这些参数在源码里有严格校验:-p和--relay的端口都必须在 0~65535 内;--write-size超出 1~1024 会直接报--write-size must be 1 to 1024;单独用--stray不带--relay会报--stray needs --relay;-u传入的 URL 则交给lws_parse_uri()拆出协议、地址、端口和路径。

靠什么判断数据真的没乱序、没丢?每个字节都是偏移量的函数

网络一旦乱序,“我收到了 128KB”这句话就没意义了——得能逐字节证明“一个不少、一个不错、顺序也对”。示例的设计很直白:发送的每个字节的值,都是它在流中偏移量(stream offset)的函数。接收方拿着偏移量一算,就知道这个位置该是哪个值。

发送端怎么填数据

发送发生在LWS_CALLBACK_CLIENT_WRITEABLE(客户端侧)和LWS_CALLBACK_SERVER_WRITEABLE(服务端侧)两个回调里。它按write-size把缓冲区分片填好,再调用lws_write(wsi, &buf[LWS_PRE], to_send, LWS_WRITE_BINARY)。注意buf[LWS_PRE]这个偏移——lws 协议栈写入时会用到缓冲区前预留的LWS_PRE字节,所以数据不是从buf[0]开始写,这是用 lws 做底层写入时的固定规矩。

--write-size在这里很关键:值越小,每个 UDP 数据包里装的小 STREAM 帧就越多、越碎,重组难度随之上升;默认 1024 则是一个包对应一个较大的 STREAM 帧。

接收端怎么逐字节核对

接收端在LWS_CALLBACK_QT_CLIENT_RECEIVE(QUIC 传输层客户端侧收到负载)和LWS_CALLBACK_QT_SERVER_RECEIVE(服务端侧)两个回调里做三件事:

  • 用pattern_ok()核对本次到达的数据,是否与流偏移处的预期字节一致,不一致立刻判失败退出;
  • 累加收到的字节数,并维护一个 djb2 风格的哈希(simple_hash,初始值 5381)做整体校验;
  • 任一侧收满TOTAL_DATA就置位client_done/server_done,两边都齐活时由check_test_completion()打印成功日志,并调度 1.5 秒后退出。

任何乱序、丢失或重复,都会在偏移量对应的位置附近立刻露馅——这正是这套校验的价值。主循环还挂了双保险:60 秒绝对超时、15 秒无任何接收的空闲超时,任一触发即判失败退出。

制造乱序:让数据在中继里被倒着发出去

直连时数据基本按顺序到,测不出 QUIC 的重排能力。--relay <port>就是来解决这个问题的:客户端不再直连服务端,而是先接到本进程内一个 UDP 中继,由它把数据报“倒着”递给对端。

中继按批缓存、凑够再倒着发

中继的行为可以记成三句话:每个方向按批缓存,每批最多 4 个,发送时从队尾倒着发。

#define RELAY_BATCH 4 #define RELAY_HOLD_US (2 * LWS_US_PER_MS) #define RELAY_DGRAM_MAX 2048
  • 发往服务端、发往客户端各有一个struct relay_dir,内部是长度 4 的环形批次队列和对应目标地址;
  • 方向怎么判?数据报的来源若和“发往服务端”方向的已知目的地址一致,就归到“发往客户端”那侧,否则当作来自客户端,源码用lws_sa46_compare_ads()做这个比对;
  • 凑满 4 个立即发;不足 4 个,就从第一个数据报到达起等RELAY_HOLD_US(2ms)再发——这样孤零零的单个包(比如客户端第一个 Initial)只会被延迟,不会被永远扣着;
  • 某个方向的队列满了之后,新到的包直接丢弃,QUIC 会像对待普通丢包一样靠重传补回来;
  • 发送时从队尾到队头逆序relay_send()逐个发出。

连握手阶段的 CRYPTO 数据都会被乱序送到,对端必须先存住先到的数据,等前面的“空洞”填满后才能交给上层。

这里有个硬约束:用了--relay时,--server必须是数字形式的 IP(比如 127.0.0.1),因为中继要把地址解析成 sockaddr 才能转发,源码用lws_sa46_parse_numeric_address()解析,失败会报--relay needs a numeric --server address。

中继本身是一个独立的 vhost,协议名quic-relay,靠lws_create_adopt_udp(rvh, ads, relay_port, LWS_CAUDP_BIND, "quic-relay", ...)绑定 UDP 端口,并用lws_sorted_usec_list排那个 2ms 的定时器——事件驱动模型和 lws 主循环完全一致。

一个典型用法:

./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --write-size 100

--write-size 100让每个包装下许多小 STREAM 帧,把乱序重组的难度拉满,所以 ctest 的 reorder 用例固定用它。

制造干扰:往握手里混进“别人连接”的数据包

真实网络里,“别的连接的包”漂到你这儿是常态。--stray(必须配--relay)让中继在转发给客户端的第一批、以及此后每第 8 批(RELAY_STRAY_EVERY 8)之前,额外塞进一个属于另一条 QUIC 连接的长头包(long header packet)。

这个杂散包由relay_send_stray()现场构造:用lws_get_random()生成 57 字节随机内容当连接 ID、包号和负载;首字节在0xf1(QUIC v2)和0xe1(QUIC v1)之间交替,拼出 Handshake 类型的长头;再写随机 8 字节 DCID、8 字节 SCID 和 2 字节 varint 长度字段。负载是没有任何一方持有密钥的密文。

这类测试的精髓在时序上:

  • 第一个杂散包先于服务端的第一个 Initial 到达,正好落在握手进行中;
  • 其余杂散包在握手完成后持续到达;
  • 客户端必须把这些包全部丢弃,而且它们不能改变自身连接的任何状态。

这背后是两条 RFC 的硬要求:RFC 9000 7.2 规定服务端的连接 ID 只能取自其第一个通过认证的 Initial 包;RFC 9368 则要求协议版本只能由通过认证的包决定。只要客户端被这些“陌生包”带偏了,传输就完不成。

./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --stray

制造重放风险:逼服务端在握手先发一个 Retry

QUIC 的 Retry(RFC 9000 8.1.2)用来在握手早期验证客户端地址是否真实:服务端对每个不带 token 的 Initial回一个 Retry 包,客户端必须带着 Retry 里给的 token 重发 Initial,服务端才继续握手。

示例用环境变量LWS_QUIC_FORCE_RETRY无条件触发这条路径:

LWS_QUIC_FORCE_RETRY=1 ./lws-minimal-quic-client-server

服务端的处理逻辑在 lib/sansio/quic/ops-quic.c 的 Initial 包处理分支里:

  1. 先看getenv("LWS_QUIC_FORCE_RETRY")有没有设置;
  2. 解析客户端 Initial 里是否带 token(看 varint 长度字段);
  3. 不带 token:构造一个 1200 字节的 Retry 包(长头、版本回显、随机 8 字节 retry SCID),用lws_quic_create_retry_token()生成绑定客户端 IP 的 token,再附 16 字节认证 tag(lws_quic_create_retry_tag()),回给客户端并把这条 Initial 丢弃;
  4. 带 token:调lws_quic_validate_retry_token()校验 token 与客户端 IP,无效就丢 Initial,有效才走lws_quic_halfopen_admit()进入建连。

客户端这边还有半步:它会校验服务端的 transport parameters 有没有复用 Retry 交换里的连接 ID(RFC 9000 7.3),专门防降级攻击。ctest 的 retry 用例正是靠注入这个环境变量来覆盖该路径。

换个“脑子”管发送:从 NewReno 切到 CUBIC

QUIC 要稳定跑,得有人管“在途字节”(bytes in flight)和发送节奏。lws 把拥塞控制做成可插拔的:struct lws_cc_ops定义了一组钩子,包括init、on_sent、on_ack、on_loss、on_discard、on_persistent_congestion、can_send和get_pacing_delay(接口见 include/libwebsockets/lws-quic.h)。库内置了两个实现——lws_cc_ops_newreno(默认)和lws_cc_ops_cubic。

可插拔拥塞控制器长什么样

切换 CUBIC 在示例里就一行,而且同一个上下文里的服务端和客户端都会跟着换:

if (lws_cmdline_option(argc, argv, switches[LWS_SW_CUBIC].sw)) info.quic_cc_ops = &lws_cc_ops_cubic;

也就是说,在--relay已经造出乱序和丢包的前提下,双方都会用 CUBIC 来记账和恢复。两个控制器都遵循 RFC 9002 的骨架,差别在细节:

  • 初始窗口:cwnd默认 10 个 MTU(MTU 默认 1280,可用 vhost 的quic_mtu覆盖,context 的quic_initial_cwnd也能改);ssthresh起步是无穷大((size_t)-1);
  • 慢启动:只要cwnd < ssthresh,每个 ACK 就线性加bytes_acked;
  • 拥塞避免:NewReno 每个 ACK 加mtu * bytes_acked / cwnd;CUBIC 按 RFC 9438 用立方函数算目标窗口,还对应用受限(app-limited)时段做了 epoch 平移,并实现了 TCP 友好性(Reno 近似)和 fast convergence;
  • 丢包响应:NewReno 把ssthresh砍半(cwnd / 2,下限 2 个 MTU);CUBIC 用cwnd * 0.7设ssthresh并更新w_max;
  • 持续拥塞:两者都把cwnd塌缩到 2 个 MTU 的最小值;
  • PTO 重排队:on_discard只修 in-flight 计数,不动cwnd和ssthresh(RFC 9002 6.2.4),免得每次 PTO 都把可用窗口永久缩小;
  • 发送节流:两者都是“信用式” pacing——pacing_credit按cwnd / srtt的速率随时间补,单次突发上限封顶 64 个包(64 * mtu),get_pacing_delay()返回还得再等多少微秒。

想换自己的算法(比如 BBR)?实现struct lws_cc_ops,在init()里把私有状态挂到nwsi->quic.qn->cc_state,再把实例赋给info.quic_cc_ops就行。

./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --write-size 100 --cubic

把这些“灾难”固化成回归测试:ctest 用例与 h3spec

前面手动拨的每个开关,在示例的 CMakeLists.txt 里都被固化成了一个 ctest 用例,这正好是理解“每个参数组合到底在测什么”的权威清单:

ctest 用例启动命令覆盖点
lws-minimal-quic-client-server--server <addr> -p <port>基础双向 128KB 传输
...-reorder`

【免费下载链接】VidBeeDownload video and audio from YouTube , TikTok , Twitter , Instagram , Facebook , Twitch , Bilibili , and 1000+ sites—or import local media. Create searchable transcripts on your computer, then summarize, translate, or ask questions with your preferred AI provider.项目地址: https://gitcode.com/GitHub_Trending/vi/VidBee

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进