故意把 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 包处理分支里:
- 先看
getenv("LWS_QUIC_FORCE_RETRY")有没有设置; - 解析客户端 Initial 里是否带 token(看 varint 长度字段);
- 不带 token:构造一个 1200 字节的 Retry 包(长头、版本回显、随机 8 字节 retry SCID),用
lws_quic_create_retry_token()生成绑定客户端 IP 的 token,再附 16 字节认证 tag(lws_quic_create_retry_tag()),回给客户端并把这条 Initial 丢弃; - 带 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),仅供参考