# redapricot 审计报告 审计日期:2026-08-15 审计方式:5 个并行探索子代理深读全部源码(~8200 行)与测试,交叉核对两端实现与 PROTOCOL.md;随后对全部高风险发现逐一人工复核(含 velocity 模块与 Velocity 官方 源码 `dev/4.0.0` 的 `PlayerDataForwarding.java` 逐字节比对)。 --- ## 0. 总体评价 **设计质量很高,且文档与实现高度一致。** 亮点包括:单事件循环 hub 让并发模型归零; 「持锁不 dial」的池设计;resume 三偏移量(Sent/Accepted/Delivered)的区分在两端实现 和注释中都正确且互相印证;流控窗口同时充当重传缓冲区上限(「保留区无需自带上限」 这一论证成立,依赖链完整);off-switch(`streamResume:false`、`statsIntervalMs:0`) 确实只留一个分支;写超时、心跳、会话建立 deadline 等「liveness 显式化」哲学落实到位。 **主要风险集中在三处**:客户端 resume 状态的并发管理(存在静默数据损坏路径)、 `Close()` 与关闭语义(库用场景泄漏)、测试覆盖(6 个协议行为零测试、Java 侧除纯 函数外零单测、e2e 注册就绪用固定 sleep)。 --- ## 1. 协议实现审计 ### 1.1 线级一致性:逐字节核对,全部一致 ✓ 握手布局、Rekey 帧(`magic‖randLen‖rand‖ts‖flags‖window`,REKEY=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.0` 的 `PlayerDataForwarding.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.19–1.19.2 且客户端有 key 时使用(redapricot 无 key 故正确回落 v1)。 ### 1.2 轻微偏差(不影响互通,但建议修) | # | 问题 | 位置 | 说明 | |---|------|------|------| | P1 | **pattern 注册前被归一化,违反 §5 "verbatim" 约定** | `client.go:49-52`、`config.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_RESERVED`、`REGISTER_OK/ERR` 未定义,违反 CLAUDE.md「常量镜像」不变式 | | P6 | RST 原因码半实现 | `worker.go:430-432`;`WorkerConn.java:55` | 客户端 `sendRst` 从不带原因字节(`RST_DIAL_FAILED/FLOW_CONTROL/RESUME_ABANDONED` 是死常量);hub 收到 RST 也不解析原因字节。协议标称"both directions"的对称性未实现 | | P7 | 规范文档错误 | `PROTOCOL.md:214` | §5 表 SessionReady 标 "(none)",与 §4(flags+window+grace)矛盾;§7.5 未提 `RST(ALREADY_BOUND)` 分支 | --- ## 2. 设计审计 **做得对且值得保留的设计**(均已核实,非泛泛而谈): 1. **池不在持锁时 dial**——`Allocate` 与 `maybeGrowLocked` 都把 dial 放到释放锁之后 /后台 goroutine,注释与代码一致;空池时单 dialer + `cond.Wait` + `dialGen` 代际 账本正确处理失败信号。 2. **流经 hub 路由而非闭包捕获 conn**——`Hub.onPlayerData` 按 `st.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 queue,vclock 不因 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.go`、`client/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.parked` 为 **false** → 再起**第二个** `resumeLoop`(`resume.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` 映射为 `errResumeRaced` → `resumeLoop` **无条件退出**(`resume.go:104-108`),不 teardown 不重试。此后该流 `parked=true` 永远挂着、destination socket 永久泄漏,直到进程退出。 **修复**:`errResumeRaced` 改为可重试(C1 修复后不再有真并发 loop,ALREADY_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); - `dialSession`(`client.go:100-210`)只用 `HandshakeTimeout` 硬 deadline,不读 ctx。 `Close()` 与在途重连/拨号并发时,新连接建立后无人关闭——`serveControl` 永久阻塞在 `ReadFrame` 上,goroutine + socket 泄漏。 **修复**:`atomic.Bool closing`;`park` 时已 closing 直接 teardown;`dialSession` 用 `net.Dialer.DialContext` + `context.AfterFunc` 在 ctx 取消时关连接。 ### 🟠 C4. 手建 Config `PingIntervalMs<=0` → `time.NewTicker(0)` panic(客户端) `New()`(`client.go:38-68`)只钳 window、解析带宽,**不验证 PingIntervalMs**(`LoadConfig` 才钳到 ≥1000,`config.go:292-298`)。注释明确说 Config 可被直接构造(测试用),此时 `time.NewTicker(0)` 直接崩进程(`client.go:342`、`worker.go:247`)。 **修复**:`New()` 里 clamp 或显式报错。 ### 🟡 服务端:无同等严重的逻辑漏洞 逐一复核了 `Hub.removeSession/expireOrphans/replayAwaiting`(orphan 语义、deadline 切换、`rearm` 的 timer 与 `cancelTimer`)、`WorkerConn.handleResume`(含 replay 不 重复计窗、`st.credited=0`、CID 重铸)、`EnforceParkedCaps`(`parkedBytes` 先减后加再 经 `removeStream` 统一扣减——自洽但绕)、`EncryptedFrames.pump`(明文长度前缀/加密 负载/rekey 切换在帧边界无歧义)。**未发现逻辑错误**。健壮性/一致性缺口: - **hub 不校验客户端发送窗口**(`WorkerConn.handleData` 只信任客户端)。协议 §7.3 明确 允许对超窗 RST,但 hub 不查——持 PSK 的恶意/损坏客户端可让 hub 端每流缓冲区无界 增长。建议按协议补校验。 - **`ProtoWriter.u8/u16` 静默截断**(`ProtoWriter.java:11,16-19`):越界值截断为 "合法外观"的错误字节,无日志;与 reader 侧严格校验不对称。 - **停机无优雅 drain**:`Main.java:40` 的 shutdown hook 不等待 `vertx.close()` 完成 (JVM halt 掐断异步关闭);无协议层下线通知;挂起玩家/流被硬切。 - **Vert.x 5 日志路由存疑**(中高置信):`Main.java:16` 设的是 Vert.x 4 的 `vertx.logger-delegate-factory-class-name`,Vert.x 5 核心日志已走 SLF4J,且 classpath 上无 slf4j-api/log4j-slf4j2-impl。 --- ## 4. 错误处理缺陷 | # | 问题 | 位置 | 后果 | |---|------|------|------| | E1 | **`registerAll` 半失败静默** | `client.go:254-263` | Register 写失败只 log 并 return,`connectControl` 仍成功、`Start` 仍返回成功,但 hub 上一个 pattern 都没注册——玩家全被拒,且日志误导 | | E2 | **RegisterAck status=1 不处理** | `client.go:314-318` | 非法 regex 只打日志,mapping 保留,之后永远收不到 ControlRequest | | E3 | **WND/FIN/RST 写失败全吞** | `worker.go:429-435`、`worker.go:373` | WND 丢失 → hub 停发该流 → 玩家卡死,全程无日志 | | E4 | **解析错误大量 `_ =`** | `client.go:325-327`、`worker.go:364-368` | 坏帧静默吞掉(hub 可信,可接受) | | E5 | **`New()` 带宽解析失败静默关限速** | `client.go:58-59` | 运维以为限速生效,实际已关闭 | | E6 | **`shaperStall` 归因污染** | `worker.go:637-648` | `Acquire`→`emit`(含最长 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 校验、`EncryptedFrames`、`WorkerConn` 流控/resume 零单测。 根因之一:`testHub()` 传 `vertx=null`(`CryptoCodecTest.java:79-83`),`Hub.rearm` 用 `vertx.setTimer` 会 NPE——**测试缝本身封死了 timer 路径**。 - **跨语言 pinning 只有 SHA3-224 一处**;ChaCha20 字节一致性无 Java 侧单测、无 RFC 8439 KAT,`deriveKey` 无绝对向量,全靠 e2e 兜底。 - **e2e 注册就绪用固定 200ms sleep**(`e2e_test.go:47`)——全 e2e 最大的 flaky 源。 `Start` 只保证 Register 帧**写出**,不等待 RegisterAck。 - **flaky 前五**:`TestShaperSharesFairlyBetweenStreams`(35% 容差)、 `TestBlackholedPathRecovers`(45s 轮询)、`TestResumePreservesByteStream`/ `TestResumeWithConcurrentStreams`、`TestControlOutageHangsArrivingPlayer`。 - **velocity 无真实 Paper golden vector**——测试只证明自洽(本次审计已确认字节布局与 官方源码一致,建议补 golden 测试防回归)。 --- ## 7. 重构建议(按性价比排序) **P0 — 正确性(先做)** 1. **修 C1 双 resumeLoop 竞态**:`completeResume` 失败保持 `parked=true`; `deliverResume` 校验 loop 归属。补 ALREADY_BOUND 竞争路径的 e2e。 2. **修 C2 半开悬挂**:`errResumeRaced` 改为在 grace 剩余内重试。 3. **修 C3/C4**:`Close()` 加 shutdown 标志、`dialSession` 尊重 ctx、`New()` 校验 `PingIntervalMs`。 **P1 — 健壮性与可观测性** 4. 客户端 `registerAll` 失败要传导;RegisterAck status=1 至少移除/标记该 mapping。 5. hub 补客户端发送窗口校验(§7.3 允许的 RST);`ProtoWriter` 加 `u8/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) 悬挂 | ✅ 已修(`resumeLoop` 对 `errResumeRaced` 改为可重试,由 grace 截止时间兜底) | | C3 | Close()/dialSession ctx | ✅ 已修(`Client.closing` 标志 + 内部 ctx/cancel;`dialSession(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=18`、`RegisterOk=0x00`、`RegisterErrPattern=0x01`,RegisterAck 处理用命名常量) | | P7 | PROTOCOL.md §5 SessionReady 表 | ✅ 已修(补充 `Flags/RecvWindow/ResumeGraceMs` 字段;§2 补充 Intent 18 status line 格式) | 验证:`go test ./client/... -count=1`、`go test -race ./client/...`、`gradle -p server test`、 `go test ./e2e/... -count=1`(含 resume/blackhole 套件)全部通过;Intent 18 与严格小写 PSK 已用真实客户端 codec 对真实 hub 探针验证。