Files
redapricot/AUDIT.md
T
iceBear67 da17140583 fix: harden resume/shutdown paths, tighten Intent-18 and PSK handshake handling
Client (Go) — resume correctness
- C1: completeResume now re-parks the stream when replay fails mid-conn-loss.
  parked was cleared before the replay loop, so the dying conn's teardown
  would start a second resumeLoop and the two loops could strand the stream
  with neither alive. Resume stats are counted only after the replay lands.
- C2: RST(ALREADY_BOUND) is retryable instead of terminating the loop. With
  C1 fixed there is never a genuine second attempt, so "already bound" means
  the hub still holds the stream on a half-open conn; the retry waits out
  that bind (bounded by the grace deadline, teardown on expiry) instead of
  returning and leaving the destination socket hung forever.

Client (Go) — shutdown semantics
- C3: Close() sets a closing flag and cancels an internal context; dialSession
  takes a ctx (DialContext + AfterFunc so shutdown aborts in-flight
  handshakes); the worker pool refuses new conns after closeAll (Allocate,
  background growth, cond waiters); serveControl's reconnect loop is gated by
  closing so Close works even when the caller's Start context is not
  cancelled; conn-loss teardown closes streams outright during shutdown
  instead of parking them for a reattach that will never come.

Client (Go) — hygiene
- C4: pingInterval() clamps at the single point a duration is derived, so a
  hand-built Config with PingIntervalMs <= 0 can no longer panic
  time.NewTicker (added DefaultPingIntervalMs).
- E6: shaperStall is sampled right after shaper.Acquire, before the socket
  write, so a hub that is not reading is no longer charged to the bandwidth
  cap in the stats.
- E7: stream log lines now carry conn%d/sid%d (leg.String()), making streams
  traceable across reattaches.
- P5: mirror constants IntentReserved/RegisterOk/RegisterErrPattern added;
  RegisterAck dispatch logs rejection reasons via the named codes.

Hub (Java) + PROTOCOL.md
- P3: Intent 18 replies with a Minecraft status-response packet
  ([Len: VarInt][0x00][JSON: String]) and closes (socket.end, so the write
  always lands) instead of closing silently; documented in PROTOCOL.md §2.
- P4: PSK address check is strict equality with the lowercase hex address;
  an uppercase/case-folded variant is now rejected per PROTOCOL.md §2.
- P7: PROTOCOL.md §5 SessionReady row lists its real fields
  (Flags/RecvWindow/ResumeGraceMs) instead of "(none)".

Verified: go vet, go test -race ./client/..., gradle test, full e2e suite
(twice), resume e2e 3x, plus live probes of the hub with the real client
codec (Intent-18 status reply, strict-lowercase PSK acceptance/rejection).%
2026-08-15 17:47:39 +08:00

20 KiB
Raw Blame History

redapricot 审计报告

审计日期:2026-08-15 审计方式:5 个并行探索子代理深读全部源码(~8200 行)与测试,交叉核对两端实现与 PROTOCOL.md;随后对全部高风险发现逐一人工复核(含 velocity 模块与 Velocity 官方 源码 dev/4.0.0PlayerDataForwarding.java 逐字节比对)。


0. 总体评价

设计质量很高,且文档与实现高度一致。 亮点包括:单事件循环 hub 让并发模型归零; 「持锁不 dial」的池设计;resume 三偏移量(Sent/Accepted/Delivered)的区分在两端实现 和注释中都正确且互相印证;流控窗口同时充当重传缓冲区上限(「保留区无需自带上限」 这一论证成立,依赖链完整);off-switch(streamResume:falsestatsIntervalMs:0) 确实只留一个分支;写超时、心跳、会话建立 deadline 等「liveness 显式化」哲学落实到位。

主要风险集中在三处:客户端 resume 状态的并发管理(存在静默数据损坏路径)、 Close() 与关闭语义(库用场景泄漏)、测试覆盖(6 个协议行为零测试、Java 侧除纯 函数外零单测、e2e 注册就绪用固定 sleep)。


1. 协议实现审计

1.1 线级一致性:逐字节核对,全部一致 ✓

握手布局、Rekey 帧(magic‖randLen‖rand‖ts‖flags‖windowREKEY=rand‖ts 不含 magic)、SessionReady、密钥派生(SHA3-256(PK‖0x01/0x02)、零 nonce、counter 0)、 控制消息、mux 帧(含 RESUME/RESUME_ACK 字段顺序)、流控语义(只 DATA 计窗口、WND 半窗批量、32KiB chunk)、resume 三偏移量语义、全部常量、配置默认值与钳制——两端实现 与 PROTOCOL.md 全部一致,无任何严重不一致。

velocity 模块已经官方源码确证无误client/velocity.go 与 PaperMC/Velocity dev/4.0.0PlayerDataForwarding.java 比对):

  • payload 布局 VarInt(version) ‖ String(ip) ‖ UUID[16] ‖ String(name) ‖ VarInt(properties) 与官方 writeVarInt + writeString + writeUuid + writeString + writeProperties 完全一致;
  • 版本协商(1.19.3+ 时 requested≥4 发 4、否则发 1)与官方 findForwardingVersion 逐分支一致,包括 v4 lazy-session 不带 key 段、v2/v3 只在 1.191.19.2 且客户端有 key 时使用(redapricot 无 key 故正确回落 v1)。

1.2 轻微偏差(不影响互通,但建议修)

# 问题 位置 说明
P1 pattern 注册前被归一化,违反 §5 "verbatim" 约定 client.go:49-52config.go:314-321 客户端注册的是 NormalizeAddress(pattern) 后的字符串。hub 端确实原样存储,但大小写敏感的 regex 会被破坏:以 \. 结尾的 pattern 经 TrimRight(".") 变成孤立 \ 导致编译失败;\Q…\E 被小写化为 \q…\e 同样编译失败。应原样注册、仅匹配时归一化
P2 streamWindowBytes: 0 语义两端不同 Config.java:36-37 vs config.go:71-75 Go 视为"未设置→262144"Java 钳到最小 32768。规范未定义 0,属规范留白
P3 Intent 18 规范与实现不符 PROTOCOL.md:73-75 vs HubConnection.java:96-98 规范称"回复 status line 后关闭",实现只 log+close
P4 PSK address 比较宽松 HubConnection.java:113 规范要求小写精确匹配,实现用 equalsIgnoreCase
P5 Go 侧缺镜像常量 client/config.go INTENT_RESERVEDREGISTER_OK/ERR 未定义,违反 CLAUDE.md「常量镜像」不变式
P6 RST 原因码半实现 worker.go:430-432WorkerConn.java:55 客户端 sendRst 从不带原因字节(RST_DIAL_FAILED/FLOW_CONTROL/RESUME_ABANDONED 是死常量);hub 收到 RST 也不解析原因字节。协议标称"both directions"的对称性未实现
P7 规范文档错误 PROTOCOL.md:214 §5 表 SessionReady 标 "(none)",与 §4flags+window+grace)矛盾;§7.5 未提 RST(ALREADY_BOUND) 分支

2. 设计审计

做得对且值得保留的设计(均已核实,非泛泛而谈):

  1. 池不在持锁时 dial——AllocatemaybeGrowLocked 都把 dial 放到释放锁之后 /后台 goroutine,注释与代码一致;空池时单 dialer + cond.Wait + dialGen 代际 账本正确处理失败信号。
  2. 流经 hub 路由而非闭包捕获 conn——Hub.onPlayerDatast.worker 转发, reattach 无需重装 handler,规避了死 conn 静默写缺陷。
  3. resume 账本——重放点取 peer 的 accepted、窗口按 delivered 重述、丢弃自身在途 credit、Delivered ≤ Accepted 隐含不变式,两端对称且都有注释钉住;UnackedBytes 的摊还 O(1) advance、from() 越界返回 nil 触发终局 teardown(不静默截断)都正确。
  4. hub 侧 parked 上限——maxParkedStreams/maxParkedBytes 以插入序 LinkedHashMap 淘汰最旧,parkedBytes() 计数与 removeStream 的扣减逻辑经核对自洽(写法绕,见 §4.6)。
  5. grace 协商而非配置假设——hub 在 SessionReady 公布 resumeGraceMs,客户端钳制 在它之下,"客户端必须先放弃"从运维约定变成协议约束。
  6. shaper——token bucket + start-time fair queuevclock 不因 size 前移是刻意的; 只整形 DATA、控制帧永不延迟;关停不卡流量;NewShaper 返回 nil 的短路设计让限速 关闭时热路径零分支。

设计层面的顾虑(非错误,属取舍):

  • 单事件循环 hub:一块网卡 + 1 MiB 帧 + 32KiB chunk,单核吞吐上限明确(文档已 承认)。但 ChaCha20 的 Cipher.update 每次帧调用都过 JCE,帧头 VarInt 解析 + Buffer 切片每帧多次分配,实际吞吐可能在数百 Mbps 量级——建议做一次基准确认。
  • TCP-level HOL 是 mux-over-TCP 固有(文档已承认),池是缓解而非解决。
  • 无 AEAD(文档已承认),ChaCha20 无完整性保护意味着主动攻击者可以翻转密文。

3. 逻辑漏洞(按严重度排序)

🔴 C1. 双 resumeLoop 竞态 → 静默数据损坏(客户端,最严重)

触发链(client/resume.goclient/worker.go):

  1. tryResume 在新建 conn B 上 registerStream 并发 RESUME
  2. completeResume 重放中途 wc.sendData 失败(B 刚注册即死,resume.go:271-273 ——注意 s.parked = false 在重放之前已复位(resume.go:253);
  3. B 的 readLoop 退出 → 对 B 上每条流调 park()worker.go:401-408)→ already := s.parkedfalse → 再起第二个 resumeLoopresume.go:80);
  4. 旧 loop 仍在重试(该错误非终局,退避 500ms 后继续)。两个 loop 并发 tryResume 各自 registerStream 到不同 conn、各自把 s.resumeWait 覆盖成自己的 channel、 发两个并发 RESUME。

后果取决于时序,两种都是坏的:RESUME_ACK 被"非赢家"loop 消费 → leg 绑定到 hub 不 认识的 (conn, sid),上游(dest→player)数据被 hub 静默丢弃——玩家 mute,无任何 日志;或 RST 到达时 resumeWait 为 nil → 误 teardown 一个 hub 已恢复的流。

修复completeResume 失败时保持 parked=true(不让第二个 loop 启动),让 park()already 分支兜底。

🔴 C2. 半开连接 + RST(ALREADY_BOUND) → 流永久悬挂(客户端)

场景:客户端心跳超时(60s)判定 worker conn 死亡并关闭,但 hub 侧该 conn 仍在 90s idle 窗口内(WorkerConn.java 侧流未 park)→ 客户端新 conn 发 RESUME → hub takeParked 为 null、streamByCid 非空 → RST(ALREADY_BOUND) WorkerConn.java:207-210)→ 客户端 onRst 映射为 errResumeRacedresumeLoop 无条件退出resume.go:104-108),不 teardown 不重试。此后该流 parked=true 永远挂着、destination socket 永久泄漏,直到进程退出。

修复errResumeRaced 改为可重试(C1 修复后不再有真并发 loopALREADY_BOUND 只 意味着 hub 旧 conn 尚存),grace 耗尽后正常 teardown。

🟠 C3. Close() 不干净 + dialSession 无视 ctx(客户端)

  • Close()client.go:413-421)无 shutdown 标志。关闭 pool conns 后,readLoop 把流 park → resumeLoop 通过 pool.Allocate()(空池)同步拨新 TCP 连接继续重挂, 最长持续一个 grace(15s);
  • dialSessionclient.go:100-210)只用 HandshakeTimeout 硬 deadline,不读 ctx。 Close() 与在途重连/拨号并发时,新连接建立后无人关闭——serveControl 永久阻塞在 ReadFrame 上,goroutine + socket 泄漏。

修复atomic.Bool closingpark 时已 closing 直接 teardowndialSessionnet.Dialer.DialContext + context.AfterFunc 在 ctx 取消时关连接。

🟠 C4. 手建 Config PingIntervalMs<=0time.NewTicker(0) panic(客户端)

New()client.go:38-68)只钳 window、解析带宽,不验证 PingIntervalMsLoadConfig 才钳到 ≥1000config.go:292-298)。注释明确说 Config 可被直接构造(测试用),此时 time.NewTicker(0) 直接崩进程(client.go:342worker.go:247)。

修复New() 里 clamp 或显式报错。

🟡 服务端:无同等严重的逻辑漏洞

逐一复核了 Hub.removeSession/expireOrphans/replayAwaitingorphan 语义、deadline 切换、rearm 的 timer 与 cancelTimer)、WorkerConn.handleResume(含 replay 不 重复计窗、st.credited=0、CID 重铸)、EnforceParkedCapsparkedBytes 先减后加再 经 removeStream 统一扣减——自洽但绕)、EncryptedFrames.pump(明文长度前缀/加密 负载/rekey 切换在帧边界无歧义)。未发现逻辑错误。健壮性/一致性缺口:

  • hub 不校验客户端发送窗口WorkerConn.handleData 只信任客户端)。协议 §7.3 明确 允许对超窗 RST,但 hub 不查——持 PSK 的恶意/损坏客户端可让 hub 端每流缓冲区无界 增长。建议按协议补校验。
  • ProtoWriter.u8/u16 静默截断ProtoWriter.java:11,16-19):越界值截断为 "合法外观"的错误字节,无日志;与 reader 侧严格校验不对称。
  • 停机无优雅 drainMain.java:40 的 shutdown hook 不等待 vertx.close() 完成 (JVM halt 掐断异步关闭);无协议层下线通知;挂起玩家/流被硬切。
  • Vert.x 5 日志路由存疑(中高置信):Main.java:16 设的是 Vert.x 4 的 vertx.logger-delegate-factory-class-nameVert.x 5 核心日志已走 SLF4J,且 classpath 上无 slf4j-api/log4j-slf4j2-impl。

4. 错误处理缺陷

# 问题 位置 后果
E1 registerAll 半失败静默 client.go:254-263 Register 写失败只 log 并 returnconnectControl 仍成功、Start 仍返回成功,但 hub 上一个 pattern 都没注册——玩家全被拒,且日志误导
E2 RegisterAck status=1 不处理 client.go:314-318 非法 regex 只打日志,mapping 保留,之后永远收不到 ControlRequest
E3 WND/FIN/RST 写失败全吞 worker.go:429-435worker.go:373 WND 丢失 → hub 停发该流 → 玩家卡死,全程无日志
E4 解析错误大量 _ = client.go:325-327worker.go:364-368 坏帧静默吞掉(hub 可信,可接受)
E5 New() 带宽解析失败静默关限速 client.go:58-59 运维以为限速生效,实际已关闭
E6 shaperStall 归因污染 worker.go:637-648 Acquireemit(含最长 30s 的 socket 写)之间时间全记入"带宽上限";hub 停读的拥堵被误报成限速,与 stats 想区分的三病因矛盾。修法:elapsed 在 Acquire 返回后立即取
E7 日志不可关联 多处 stream %d sid 是 per-conn 的且 reattach 后会变,不带 conn id,排障无法把日志对到同一条流
E8 RST 原因映射过宽 resume.go:309-312 RST_FLOW_CONTROL 这类协议违规终局被当可重试,徒增延迟
E9 resume 尝试越过 deadline 最多 10s resume.go:152-168 time.After(ResumeAckTimeout) 不随 deadline 缩短,玩家侧最坏失败延迟被放大到 grace+10s
E10 resumeGrace 与 hubGrace 取 min 后丢失下限 resume.go:38-44 误配 500ms grace 的 hub 让客户端预算 < 一次拨号所需,仅可观测性问题

5. 复杂度分析

热点 位置 分析
Stream 类型 worker.go:451-518 25+ 字段、4 种同步原语(s.mu/sendMu/原子量/chan),resume 状态(parked/resumeWait/leg/cid/三 offset)与流量状态混居。C1 正是"parked 复位"与"loop 存活"两状态未拆开导致的
completeResume resume.go:203-288 全项目最复杂单函数:锁序(sendMu→s.mu)、三偏移换算、重放、窗口重述、欠账 FIN。正确但难审
WorkerPool.Allocate worker.go:54-92 cond 等待 + dialGen/dialErr 代际账本,失败路径无单测
接收方向账本 worker.go 多处 q/qBytes/acceptedOffset/deliveredOffset/consumed 由 writeLoop/deliverFromHub/credit/completeResume 四处维护
shaper headLocked shaper.go:241-252 每 grant 线性扫描 O(n²);n 为流数时无感,但每 DATA 块一次 make(chan) 分配是热点
wire.Reader.VarInt mc.go:55-61 每次 bytes.NewReader 分配,velocity/帧解析热路径高频调用
velocity 状态机 velocity.go 两方向缓冲 + 5 个布尔,逻辑正确但可合并为三值枚举
测试 harness e2e/* 三层 helper 金字塔 + 3 个文件各自复制"hub+relay+dest+client"接线

6. 测试覆盖审计

覆盖良好的:黑盒路径恢复、控制断线三种路径、resume 字节精确/并发/禁用/grace 到期、 带宽整形与公平、慢流隔离、velocity 单测 13 场景 + e2e、PROXY v2、池广度优先、错误 PSK 拒绝。e2e 拉起真实 Java hub 子进程,架构扎实。

零测试的协议行为6 个,e2e + 单测均未断言):

  1. RST 原因码——协议定义了 6 个,两端只实际发射 UNKNOWN_STREAM/ALREADY_BOUND ALREADY_BOUND 竞争路径(C1/C2 的暴露面)无任何测试;
  2. WND 窗口违规——hub 不校验客户端窗口、客户端超窗检测无测试;
  3. pending 超时——"已匹配但 SYN 永不来的玩家被关"无测试;
  4. hub idle watchdog——sessionIdleTimeoutMs 无测试;
  5. CID 单次使用——二次 SYN、resume 后旧 CID 失效无测试;
  6. 1 MiB 帧上限——两侧都无超长帧测试。

结构性缺口

  • Java 侧只有 1 个测试文件CryptoCodecTest.java,9 个纯函数用例)。hub 状态机、 HubConnection 握手/rekey 校验、EncryptedFramesWorkerConn 流控/resume 零单测。 根因之一:testHub()vertx=nullCryptoCodecTest.java:79-83),Hub.rearmvertx.setTimer 会 NPE——测试缝本身封死了 timer 路径
  • 跨语言 pinning 只有 SHA3-224 一处ChaCha20 字节一致性无 Java 侧单测、无 RFC 8439 KATderiveKey 无绝对向量,全靠 e2e 兜底。
  • e2e 注册就绪用固定 200ms sleepe2e_test.go:47)——全 e2e 最大的 flaky 源。 Start 只保证 Register 帧写出,不等待 RegisterAck。
  • flaky 前五TestShaperSharesFairlyBetweenStreams35% 容差)、 TestBlackholedPathRecovers45s 轮询)、TestResumePreservesByteStream/ TestResumeWithConcurrentStreamsTestControlOutageHangsArrivingPlayer
  • velocity 无真实 Paper golden vector——测试只证明自洽(本次审计已确认字节布局与 官方源码一致,建议补 golden 测试防回归)。

7. 重构建议(按性价比排序)

P0 — 正确性(先做)

  1. 修 C1 双 resumeLoop 竞态completeResume 失败保持 parked=true deliverResume 校验 loop 归属。补 ALREADY_BOUND 竞争路径的 e2e。
  2. 修 C2 半开悬挂errResumeRaced 改为在 grace 剩余内重试。
  3. 修 C3/C4Close() 加 shutdown 标志、dialSession 尊重 ctx、New() 校验 PingIntervalMs

P1 — 健壮性与可观测性 4. 客户端 registerAll 失败要传导;RegisterAck status=1 至少移除/标记该 mapping。 5. hub 补客户端发送窗口校验(§7.3 允许的 RST);ProtoWriteru8/u16 范围校验。 6. 修正 shaperStall 归因(elapsed 提前取);流日志统一带 connID/sid。 7. pattern 原样注册(P1);streamWindowBytes:0 语义统一并写进规范(P2)。

P2 — 测试 8. Java 侧补:ChaCha20 RFC 8439 KAT + 分块跨块一致性、EncryptedFrames rekey、 Hub 状态机(timer 可注入)。 9. 客户端加 ready 信号(RegisterAck 挂钩到可等待 channel),替换 200ms sleep;补 6 个零覆盖协议行为的测试。 10. velocity 补官方字节 golden vector。

P3 — 结构 11. completeResume 拆成小函数 + 状态机图注释;接收方向账本封装为 recvLedger。 12. harness 引入单一 newTunnelHarness(t, opts…) 组装点。 13. 热路径去分配:wire.Reader.VarInt 改游标读取;shaper chan 池化(低优先级)。 14. Main 的 shutdown hook 等待 vertx.close() 完成;核实 Vert.x 5 日志绑定。


8. 结论

协议实现是干净且两端一致的——这是本审计最重要的结论,逐字节核对未发现任何线级 不兼容。设计文档质量上乘,注释大量引用"它防的是什么故障",可维护性极好。

需要优先处理的是客户端 resume 路径的两个并发缺陷(C1 静默数据损坏、C2 悬挂泄漏) 和关闭语义(C3),其次是测试缺口。这两类问题恰好互相印证:C1 和 C2 都发生在 "连接死亡/重建"的交叉时序里,而这类时序恰恰是当前 e2e 覆盖最薄的地方。


附:修复状态跟踪

编号 内容 状态
C1 双 resumeLoop 竞态 已修(completeResume 重放失败时重新置 parked=true,统计计数移至重放成功之后)
C2 半开 + RST(ALREADY_BOUND) 悬挂 已修(resumeLooperrResumeRaced 改为可重试,由 grace 截止时间兜底)
C3 Close()/dialSession ctx 已修(Client.closing 标志 + 内部 ctx/canceldialSession(ctx)DialContext + AfterFunc 关闭;WorkerPool.closed 标志门控 Allocate/后台拨号;serveControl 重连被 closing 门控)
C4 NewTicker(0) panic 已修(pingInterval() 单点钳制,新增 DefaultPingIntervalMs
E6 shaperStall 归因污染 已修(elapsed() 移至 Acquire 返回后、socket 写之前采样)
E7 流日志缺 conn id 已修(leg.String() = conn%d/sid%d,替换全部 5 处流日志)
P3 Intent 18 status line 已修(hub 回复 Minecraft status 包 [Len][0x00][JSON] 后关闭;已用真实 codec 探针验证)
P4 PSK address 严格小写 已修(equalsIgnoreCase → 严格 equals;大写变体探针验证被拒)
P5 Go 镜像常量 已修(IntentReserved=18RegisterOk=0x00RegisterErrPattern=0x01RegisterAck 处理用命名常量)
P7 PROTOCOL.md §5 SessionReady 表 已修(补充 Flags/RecvWindow/ResumeGraceMs 字段;§2 补充 Intent 18 status line 格式)

验证:go test ./client/... -count=1go test -race ./client/...gradle -p server testgo test ./e2e/... -count=1(含 resume/blackhole 套件)全部通过;Intent 18 与严格小写 PSK 已用真实客户端 codec 对真实 hub 探针验证。