From d27a0819c1f8fe5bd53354c258773361975401e8 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 13 Sep 2026 23:01:07 +0800 Subject: [PATCH] chore: pin MiniRTC UDP punch P0 lab and record acceptance gate --- deps/submodules/minirtc | 2 +- docs/p2p-punching-implementation-plan.md | 567 +++++++++++++++++++++++ 2 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 docs/p2p-punching-implementation-plan.md diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index f169a696..7f0e6662 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit f169a696cb4e6cb59037ec02e85532e30d3c1262 +Subproject commit 7f0e666216891c9b79bd4a8f75b63741fe1fabf2 diff --git a/docs/p2p-punching-implementation-plan.md b/docs/p2p-punching-implementation-plan.md new file mode 100644 index 00000000..345936c3 --- /dev/null +++ b/docs/p2p-punching-implementation-plan.md @@ -0,0 +1,567 @@ +# CrossDesk 双端 UDP 打洞增强实现方案 + +状态:设计基线保留;已实现 P0 单端点后端与实验工具,macOS 本机机制验证通过,P0 双平台/双端抓包总验收尚未完成。P1–P5 尚未启动。日期:2026-09-13。 + +源码基线:CrossDesk `73e3bf709d7c054df7bd75cfafceda0721c6cda1`;MiniRTC 子模块 `f169a696cb4e6cb59037ec02e85532e30d3c1262`;libnice `0.1.24` 加仓库已有补丁。 + +本文规定第一版的范围、接口职责、协议、资源预算、开发顺序和验收条件。新增名称、配置、接口和测试目标均为拟实现内容;现有代码位置另外标明。本文不代表功能已经实现或成功率已得到验证。 + +## 1. 目标与取证依据 + +目标是在普通 ICE 检查未建立直连、TURN 已经可以传输的情况下,通过“固定端点一侧扩大目的端口探索+另一侧使用多个本地 UDP 端点”寻找新的可达组合,再接入已有 ICE 检查及中继转直连流程。 + +2026-09-13 两次连接提供了以下依据: + +| 项目 | CrossDesk 16:06 会话 | UU 21:30 会话 | +| --- | --- | --- | +| Mac 的直接探测 | 同一个本地端口,向 11 个公网端口发出 324 个 Binding Request,含重传 | 同一个本地端口,向 231 个离散目的端口发送短探测包 | +| Windows 的直接探测 | 主 ICE 端口向 Mac 公网候选持续检查,双方未捕获到直接入站 | 两个常规端点检查失败后,49 个不同本地源端口轮流向 Mac 固定公网端点发短包 | +| 成功组合 | 没有观察到 | Windows 第 46 个短包端点与 Mac 建立双向通信,随后 STUN 成功并持续传输 | +| 成功端点是否继续传输 | 不适用 | Windows :52257,经 NAT 为 :11968,后续沿用该端点 | + +Mac 先前发往 :11968 的短包没有出现在 Windows 捕获中;成功开始于 Windows 自己发出的短包到达 Mac。无需把两个固定载荷短包解释成请求与回显,才能说明双方端口探索的作用。 + +证据边界: + +- 231、49 是成功附近实际执行的数量,不是已知的 UU 配置上限。 +- 抓包证明多个源端口参与,没有证明所有历史 socket 句柄同时存活,也没有恢复 UU 的端口生成算法。 +- NAT 内部建表和过滤过程仍是推断;本方案不宣称复刻 UU 私有实现。 +- 两次应用运行时间、socket 和服务端不同,不能从这两个样本计算成功率提升幅度。 +- 详细原始取证位于本次任务的本地分析产物;实现与验收必须能够独立于 `/tmp` 文件运行,不把抓包或会话密钥提交到仓库。 + +## 2. 第一版范围和关键决策 + +### 2.1 纳入范围 + +- 原生 MiniRTC ↔ 原生 MiniRTC;首先验证 Windows x64 与 macOS arm64,Linux 用于编译和可控网络测试。 +- IPv4 UDP,单个 ICE component,选择一个明确的物理接口和原始 host socket。 +- 非强制中继、非 reliable ICE;已有选中路径包含 relay,且 DTLS 握手和远端指纹验证已经成功。 +- 本轮一侧具有同一 socket 对多个 STUN 目标的稳定公网映射观测,另一侧具有目的相关映射观测。 +- 每个 ICE generation 至多执行一轮增强;失败继续使用原连接。 + +### 2.2 暂不纳入 + +- 浏览器/libdatachannel 对端、iOS 启用、IPv6 探索、两侧都目的相关或两侧信息不足时的通用算法。 +- TURN 尚未建立时的首次连接加速、`TurnDisabled` 下的增强协商、未启用 SRTP/DTLS 的会话。 +- UPnP/PCP/NAT-PMP 的新实现、网关配置修改、跨多个接口同时建立端口池。 +- 根据 UU 单个样本自动学习端口分布,或把端口枚举成全部普通 ICE 候选。 +- 新增直连失效后无缝回切 TURN 的完整恢复算法。第一版遵循当前恢复行为,验证失败恢复;不得把“仍保留 TURN allocation”描述为已经具备自动回切能力。 + +### 2.3 架构决策 + +1. **先验证 socket 接入,再实现大范围探测。** 新端点在中继 READY 后必须能参与 ICE 并成为媒体端点,这是 P0 的前置验收项。 +2. **SDP 只协商能力,运行时协调复用已选中路径。** 第一版通过现有 relay 数据路径传递带认证的内部控制包,不新增 WSS 消息类型,也不要求修改 CrossDesk Server。 +3. **新 socket 由 libnice 管理。** 不在 GUI、业务数据通道或独立线程中建立脱离 ICE 生命周期的临时 socket 池。 +4. **探索和 ICE 检查分开调度。** 数百个目的端口属于有限的探索集合;只有少量命中端点进入普通检查表。 +5. **保留普通 ICE 与已有 30 秒升级窗口。** 增强操作不能重置该窗口、重启 DTLS,或延迟正常的中继可用状态。 +6. **默认关闭。** 编译扩展存在、运行配置开启、双方声明支持、网络和连接状态满足条件时才启动。 + +这会使第一版主要改善“已通过中继连接,但希望随后升为 P2P”的场景。将来需要在没有中继时运行同类算法,应另行设计可验证的信令协调通道。 + +## 3. 现有代码与拟增加模块 + +| 位置 | 当前职责 / 后续变更 | +| --- | --- | +| [ice_utils.h](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/ice_utils.h) | 已有精确匹配 SDP 能力行与 ufrag 解析;新增独立 v1 能力,不改变旧能力含义 | +| [nat_traversal.h](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/nat_traversal.h) | 已有映射观测和附近端口预测;提取带 socket/generation 的快照,新增角色选择和有界采样逻辑 | +| [ice_agent.h/.cpp](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/ice_agent.cpp) | 持有 NiceAgent、GMainContext 和 DTLS;接入协调器、验证后的 exporter、内部控制包分流和停止流程 | +| [ice_transport.cpp](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/transport/ice_transport.cpp) | 已有选路日志、trickle 和媒体入口;防止内部端点误发普通候选,新增结果统计,保持媒体状态连续 | +| [MiniRTC xmake.lua](/Users/dijunkun/crossdesk/deps/submodules/minirtc/xmake.lua) | 注册可选的纯逻辑测试和 ICE 集成测试目标,接入编译开关 | +| [libnice 配方](/Users/dijunkun/crossdesk/deps/submodules/minirtc/thirdparty/libnice/xmake.lua) | 注册新补丁、哈希及包身份版本,增加扩展符号检查,确保双端重新构建依赖 | +| [relay upgrade 补丁](/Users/dijunkun/crossdesk/deps/submodules/minirtc/thirdparty/libnice/patches/relay_upgrade_0.1.24.patch) | 已有保留直连检查、定期重试及再次提名;扩展接入点须遵守已有截止时间和优先级条件 | + +拟新增到 MiniRTC 的模块,路径以子模块根目录为基准: + +```text +src/ice/punch_config.h 配置、硬上限、实验模式 +src/ice/punch_protocol.h/.cpp 编解码、认证、去重、generation 校验 +src/ice/punch_scheduler.h/.cpp 端口采样、预算、时序;可注入时钟和随机源 +src/ice/punch_coordinator.h/.cpp 每个 IceAgent 的增强状态机 +src/ice/punch_budget.h/.cpp 进程级 socket/发包配额 +thirdparty/libnice/patches/punch_pool_0.1.24.patch +tests/ice/punch_* 纯逻辑和集成测试 +tests/ice/lab/ 可控 NAT 场景与抓包核验脚本 +``` + +MiniRTC 和 CrossDesk 为不同 Git 仓库。后续先在子模块形成可验证的提交,再更新主仓库的子模块指针;不得只修改本机 Xmake 下载缓存中的 libnice 源码。 + +## 4. 能力、身份和角色协商 + +### 4.1 能力开关 + +拟新增精确 SDP 行: + +```text +a=x-minirtc-udp-punch:1 +``` + +只有本地构建包含对应 libnice 扩展、运行配置开启、原生 UDP 路径允许时才通告。双方同时支持该能力和现有 relay-upgrade 能力才启用。旧的 `a=x-minirtc-p2p-enhancement:1` 继续表示原有增强能力,不隐式包含端口池协议。 + +SDP 的能力行不是命令;资源数量由本地配置和认证后的协商共同限制。未知版本或异常重复能力声明只停用此增强,不使普通 ICE 失败。 + +现有 `IceTransport::SetRemoteSdp` 会提取媒体段,并显式补回旧增强能力行;新增能力也必须经过该路径保留。需要测试能力位于 session、视频或其他媒体段,以及 fingerprint 提取前后的情况,避免完整 SDP 中支持、传入 IceAgent 后却丢失。 + +### 4.2 连接和轮次身份 + +- 使用有序的 offer/answer ufrag 对标识 ICE generation;记录本地单调递增 generation 序号,防止异步旧回调误用新连接。 +- 线上 `generation_id` 为长度前缀编码的 offer/answer ufrag 对的 SHA-256 前 16 字节;实际认证密钥另由本次已验证的 DTLS 会话导出。 +- 原始 offer 发送方担任协调方,分配随机 128-bit `round_id`。协调角色不等于动态 ICE controlling 角色。 +- 所有控制包、探测包、候选提升请求和回调必须携带 generation/round 范围。重连、ICE restart、接口改变时旧范围作废。 + +### 4.3 网络角色 + +命名:`anchor` 表示复用固定 socket 的一侧;`pool` 表示使用额外本地端点的一侧。角色根据观测选择,不与 Windows/macOS、主控/被控绑定。 + +有效映射快照至少包含:同一原始 host socket 的不透明 ID、本地地址、候选 foundation、component、generation、目标 STUN 地址、公网结果、接收的单调时间、观测序号。 + +- `stable-observed`:当前 generation 的同一 socket 至少对两个不同公网服务器 IP 得到同一公网 IP:port,观测仍在有效期内。 +- `dependent-observed`:同一 socket 对不同目标出现不同映射;不能把多个本地源端口的结果混在一起分类。 +- `unknown`:不足、过期、来源不一致,或公网 IP 本身变化。 + +第一版只在恰好一侧 `stable-observed`、另一侧 `dependent-observed` 时开始。前者为 anchor。其余组合记录跳过原因,保留常规 ICE;实验工具可显式固定角色验证接入,但不得在生产中把固定角色伪装成自动分类。 + +快照默认有效期 15 秒。角色选择只表示实验条件,不把有限的 STUN 观测升级为对 NAT 过滤类型或未来分配行为的保证。anchor 的公网端点必须与被保留的那个真实 socket 对应;不能使用无关联的临时 STUN socket 的结果。 + +## 5. 控制通道与报文契约 + +### 5.1 通道和密钥 + +控制包通过 NiceAgent 当前选中的 relay 路径发送,在 `IceAgent::OnNiceRecv` 中、DTLS/RTP/SRTP 判断之前识别和消费;不送入桌面业务协议,也不占用媒体 SSRC。 + +直接探测包在被借用的 anchor socket 或 libnice 管理的 pool socket 上发送,使用相同的会话范围,但使用独立的密钥域。原始 socket 收包钩子只接收直接路径的探测种类;TURN 解封装后的逻辑来源不能产生“直连命中”事件。 + +密钥方案: + +1. 新增 `dtls_peer_verified` 状态,仅在握手完成且远端 fingerprint 比较成功后设置。当前 `dtls_handshake_done_` 在 fingerprint 判断之前赋值,不能单独作为导出增强密钥的条件。 +2. 在 DTLS 所属上下文内调用 `SSL_export_keying_material`,使用独立标签 `EXPORTER-MiniRTC-UDP-Punch-v1`,context 使用上节有序 ufrag 编码,`use_context=1`。 +3. 导出 128 字节,依次拆为四个 32-byte 密钥:offer→answer 控制、answer→offer 控制、offer→answer 探测、answer→offer 探测。不得复用 SRTP 密钥或把密钥写入 SDP/日志。 +4. 报文采用 HMAC-SHA-256 前 16 字节认证完整头和载荷,常量时间比较。控制内容只含连接元数据,不承载媒体或密钥;它是带认证的内部数据报,不宣称因走 relay 就自动被 DTLS 加密。 + +### 5.2 固定头 + +采用显式序列化,整数为网络字节序,不直接发送 C/C++ struct。 + +| 字段 | 长度 | 约束 | +| --- | --- | --- | +| magic | 4 | `f3 4d 50 31`,避免与常见 STUN、DTLS、RTP 首字节范围混淆 | +| version | 1 | 1 | +| kind | 1 | 见下表,控制与探测种类严格分离 | +| payload_length | 2 | 总数据报必须恰好为 `44 + payload_length + 16` 字节 | +| generation_id | 16 | 与当前连接一致 | +| round_id | 16 | HELLO 阶段为全零;其他消息为已接受轮次 | +| sequence | 4 | 按方向和密钥域递增,重传保持原序号与内容 | +| payload | 可变 | v1 控制采用 UTF-8 JSON;直接探测采用固定二进制载荷 | +| tag | 16 | HMAC 截断值 | + +控制包总长上限 1,200 字节,直接 PROBE/ACK 总长不超过 128 字节。先校验长度、版本、范围、认证,再解析业务字段;不支持分片重组。未知类型、越界数字、重复 JSON 字段、尾随内容均拒绝。 + +控制 JSON 使用受限、版本化的字段集;HMAC 对线上原始字节计算,接收端不重新序列化后验签。v1 禁止未知字段,后续增加字段需升级协议契约或显式定义可选字段。 + +### 5.3 消息和顺序 + +| kind | 作用与必须字段 | +| --- | --- | +| `HELLO (0x01)` | 本地观测快照、候选锚点、运行预算、剩余升级时间;快照只发送分类所需的少量样本 | +| `PREPARE (0x02)` | 协调方发送 round、角色、双方端点、配置摘要和本轮时长;不能授权任意第三方 IP | +| `READY (0x03)` | 接收方已保留原始 socket 或分配所需 pool socket,返回实际预算与 PREPARE 摘要 | +| `START (0x04)` | 协调方确认开始;双方按本地 monotonic clock 运行,不传递绝对系统时钟作为启动依据 | +| `STARTED (0x05)` | 确认 START 已接受;重复 START 不重置计时器 | +| `RESULT (0x06)` | 本端已选中直连,包含结果和地址摘要;该消息本身不能改变对端 selected pair | +| `CANCEL (0x07)` | 超时、状态变化、资源不足或主动停止;对端同样有本地截止时间 | +| `PROBE (0x20)` | 直接包;载荷为网络字节序 uint64 socket_id,随后为 12-byte probe_nonce,共 20 字节 | +| `PROBE_ACK (0x21)` | 直接包;载荷原样返回 PROBE 的 socket_id 和 nonce,由接收它的同一个 socket 发回 | + +协调方通过已有能力与 HELLO 判定条件后发 PREPARE;双方准备完成后执行 START/STARTED。若协商出的实际预算改变,更新 PREPARE 并重新 READY,不在一端静默扩大或缩减配置。 + +控制状态消息按 200/400/800 毫秒退避,最多重发 3 次;使用本轮总截止时间约束。每种消息响应幂等,重复 READY/START 不重复分配 socket,也不重启轮次。乱序消息只能在对应状态允许的范围内缓存或拒绝,不能越级启动。 + +每方向、每密钥域保留 256 项序号重放窗口,允许正常乱序;已处理的控制请求可重新发送缓存响应,不能重新执行副作用。探测 ACK 必须匹配本轮尚有效的 nonce 和本地 socket_id。未认证包不回复。 + +上述直接 socket_id 是本地注册表的不透明标识,不是 OS 文件描述符;不得由远端指定本地 FD。 + +## 6. 探测算法与预算 + +### 6.1 anchor 的行为 + +1. 保留与稳定公网观测对应的实际 host socket,沿用当前物理接口绑定。 +2. 在已确认的对端公网 IP 上,从 1024–65535 无放回抽取目的端口,默认 512 个。先生成完整集合再按随机顺序调度,使用可注入的 CSPRNG;测试使用固定种子。 +3. 现有普通候选和邻近预测继续由原 ICE 调度;新探索集合避免重复消耗这些目标的探测预算。低于 1024 的探测以及非均匀采样留待后续实验,不声称第一版覆盖 UU 的全部端口范围。 +4. 每个新目标默认发一次 PROBE。收到 pool 的认证 PROBE 时,从同一 socket 向该包实际来源端点回 PROBE_ACK;该源 IP 必须仍属于本轮批准的对端公网 IP。 +5. 原始来包地址用于路径发现;不能用包内宣称的地址替代源地址,也不能把 TURN 逻辑地址当成直接源地址。 + +### 6.2 pool 的行为 + +1. 在同一选定物理接口上分配默认 64 个不同 UDP 本地端口;绑定地址和接口遵循原 ICE socket 的平台规则。 +2. 新 socket 不为每个端点申请 TURN allocation、启动独立 DTLS 或开展完整多 STUN 采集,只向 anchor 已协商的公网端点探索。 +3. 以 round-robin 调度 PROBE,最多 3 轮;相邻轮至少间隔 1 秒。全部端点保持可接收至本轮停止或被接入 ICE,不能发完就关闭。 +4. 收到 anchor 的有效 PROBE 或匹配 ACK 后,对该具体 socket 请求提升并启动普通 ICE 检查;收到 PROBE 时先从同一 socket 回复 ACK。 +5. 不根据本地源端口序列推算公网端口。探测过程中没有第三方 STUN 返回也不妨碍命中后的标准 ICE 验证。 + +两侧时间窗需要重叠,但不要求包在同一毫秒发出。允许丢失第一包、控制消息重传和两端系统时钟有偏差。 + +### 6.3 初始配置 + +以下是第一轮实验参数,不是从 UU 反推的最佳值。所有值通过经过校验的不可变 `PunchConfig` 进入每个 IceAgent。 + +| 参数 | 默认实验值 | v1 硬上限 / 规则 | +| --- | --- | --- | +| enabled | false | 必须双方开启 | +| trigger_delay_ms | 1500 | 从首次选中 relay 计时,并等待 DTLS 指纹已验证 | +| mapping_sample_max_age_ms | 15000 | 不跨 generation 或接口变化复用 | +| pool_size | 64 | 每会话最多 128;禁止远端扩容越过本地上限 | +| target_port_count | 512 | 最多 1024,唯一端口数 | +| probe_pps | 100 | 本端全部新增 PROBE/ACK 合计最多 200 包/秒 | +| burst_packets | 8 | token bucket;事件循环滞后时不能瞬间补发整个积压队列 | +| pool_probe_rounds | 3 | v1 最多 3;重发也计入总预算 | +| round_duration_ms | 10000 | 探索阶段时长,最多 15000,并受原 relay upgrade 截止时间限制 | +| ice_finish_reserve_ms | 3000 | 到原升级截止前停止新探索,留给已命中 ICE 检查 | +| probe_packet_budget | 1024 | 本端 PROBE/ACK 累计上限;发送失败也记录尝试计数 | +| control_packet_budget | 48 | 本端全部控制消息与重传总量 | +| promoted_endpoint_limit | 4 | 最多 8;只有命中端点进入普通 ICE 检查 | +| rounds_per_generation | 1 | 重复能力/控制消息不创建新轮次 | +| process_active_round_limit | 2 | 超额会话跳过增强,正常媒体不排队 | +| process_extra_socket_limit | 256 | 进程内所有 IceAgent 共享配额 | +| process_probe_pps | 200 | 进程级公平调度,优先及时 ACK,仍受总预算约束 | + +定义两个截止时间:`T_probe = min(本轮开始 + 探索时长, 原升级截止 - ICE 保留时间)`;`T_finish = min(T_probe + ICE 保留时间, 原升级截止)`。到 T_probe 停止新探索、释放未命中端点,已提升且正在检查的端点进入 DRAINING,继续标准 ICE 至 T_finish。到 T_finish 才取消仍未完成的增强检查;已选中的端点继续归正常 ICE 管理。协商、分配、重传均不延长原有 30 秒窗口。双方各自检查本地剩余时间;不足时拒绝开始。 + +控制包与新增 PROBE/ACK 单独计数;提升后的普通 STUN 检查遵循 libnice 原节拍,但需要记录它们带来的额外事务和重传。媒体包不计入探测预算。 + +首次实现用内部配置和明确命名的实验环境变量加载配置,仅读取一次,不增加公开 `Params` 布局或 GUI 开关;例如 `MINIRTC_UDP_PUNCH=off|wide-only|pool-wide`。无效值禁用增强并打印原因。端口池大小等调参入口统一由同一校验函数处理。 + +`wide-only` 保留同样的协议、角色和锚点选择,但 pool 侧只用一个端点;`pool-wide` 使用配置的端点池。基线 `off` 仍保留已有附近端口预测。不能让实验分组顺带改变其他 ICE、TURN 或重试配置。 + +单端点模式也经过新的协调和端点接入流程,因此 A/B 差异不能全部归因于采样范围;B/C 对比用于隔离多个本地端点带来的增量作用。 + +## 7. libnice 接入与命中后的完整闭环 + +### 7.1 后端职责 + +新增能力全部在 NiceAgent 的 GMainContext 中执行。C++ 只持有带 generation 的不透明句柄,不直接持有可悬空的 `NiceSocket*`。 + +拟提供的内部接口契约,确切 C 签名在 P0 验证后冻结: + +| 拟新增接口 | 语义 | +| --- | --- | +| `punch_get_capabilities()` | 扩展版本及可用能力;不能仅因头文件存在就通告支持 | +| `punch_acquire_anchor(base_candidate)` | 借用并保留真实 anchor socket,返回句柄与实际本地地址 | +| `punch_create_pool(base_candidate, count)` | component 拥有新 socket,逐项报告分配成功/失败;只做直接探索 | +| `punch_send(handle, remote, bytes)` | 从指定 socket 发出原始直接 UDP,不走当前 selected relay | +| `punch_packet` 事件 | 句柄、原始外层 src/dst、字节、generation;只在直接 socket 接收路径产生 | +| `punch_promote_and_check(handle, anchor_candidate)` | 原子地把该 pool socket 注册为可检查的本地 host 候选,向已有 anchor 远端候选触发普通 STUN | +| `punch_stop_discovery(round)` | 到探索截止时停止自定义探测,释放未命中端点;保留已提升并在验证的端点 | +| `punch_cancel(round)` | 撤销探索 source、借用引用和未被 ICE 接管的 socket;幂等 | +| `punch_get_upgrade_deadline()` | 返回已有升级截止的 monotonic 时间,避免 C++ 层另起不一致的 30 秒计时 | + +不要用 `nice_agent_set_port_range()` 代替端口池;该接口只约束 host 候选的端口分配范围。不要调用强制 selected-candidate API 绕过 ICE 验证。 + +接入点要覆盖:component 的 socket source 管理、UDP 接收分流、候选与 socket 的关联、候选对入队、READY 后重新唤起检查计时器、候选裁剪和销毁。新增 socket 使用现有 UDP 后端的创建过程,保持 Windows/Apple 物理接口绑定行为。 + +### 7.2 直接接收分流 + +- anchor 原始 socket:在 libnice 将未知源的非 STUN 数据丢弃之前,识别仅在已协商轮次生效的 PROBE/ACK,传给协调器验证。 +- pool 探索 socket:只处理本轮的直接 PROBE/ACK;普通业务数据不向上交付。提升后允许该 socket 进入正常 libnice STUN/媒体处理。 +- 接收钩子必须区分原始 UDP、TURN 服务器来源及 TURN 解封装后来源。嵌套的同 magic 载荷不得走直接命中分支。 +- 在原来的 NiceSocket 事件循环中读取,禁止另一个 recvfrom 线程竞争同一 socket 的 STUN 或媒体包。 +- 回调不能在 libnice 内部持有不可重入锁时同步反向调用后端;必要时保留有界字节副本和句柄,投递到同一 context 的后续任务。 + +### 7.3 标准 ICE 验证路径 + +第一版固定由 pool 端首先对新 socket 启动普通检查,避免将每个猜测端口伪装成已发现的远端候选: + +1. pool 从某个 socket 收到有效 PROBE 或匹配 ACK,确认该端点有直接入站证据。 +2. 后端保持该实际 socket 不关闭,将其提升为正常本地 host 候选,并与已有 anchor 公网候选建立检查对。 +3. pool 从该 socket 发出正常 Binding Request,使用当前 generation 的 ICE 凭据、真实 controlling/controlled 角色和既有 priority 规则。 +4. anchor 在原始 socket 上收到请求,经 libnice 认证后按标准逻辑学习新的 peer-reflexive 远端地址、响应并触发反向检查。 +5. 双向检查形成 valid pair,controlling 一侧通过现有 relay-upgrade 路径执行 nomination/renomination。 +6. 收到真实 selected-pair 变化,双方才各自将状态记为 `DIRECT_SELECTED`。RESULT、短包 ACK 或发现 prflx 均不能代替这个条件。 + +直接 probe 认证和 ICE 认证是两个不同步骤。成功 probe 不标记候选 valid,不伪造 STUN 成功事务,也不提前对外报告“学到 prflx”。 + +必须覆盖 pool 为 controlling 和 pool 为 controlled 两种情况。新增 pair 的优先级由现有候选计算规则产生,并验证满足已有“优先级高于所选 relay 才升级”的条件。 + +### 7.4 候选数量与 end-of-candidates + +探索 socket 在命中之前不参加全量候选配对,不为 64×512 个组合建立检查对象;控制提升数量,逐个接入正常检查。 + +内部增强端点不通过普通 `new_candidate` 信令发送。为这些端点明确添加内部来源标记,并在现有 `new-candidate-full` 回调处处理,防止在已发送 end-of-candidates 后又误发一批普通候选。既有 host/srflx/relay 的正常收集和结束标记不受影响。 + +这属于双方协商的 MiniRTC 扩展;不能假定标准浏览器或未打补丁的 libnice 在 READY 后接受相同的动态行为。 + +### 7.5 媒体连续性和所有权 + +- selected relay 在新的 pair 真正选中前持续传输;不调用 ICE restart,不创建第二套 DTLS/SRTP 会话,不重置 SSRC、编码器和传输控制器。 +- 探索中:component 持有 pool socket;协调器持有不透明句柄。 +- 提升后:所有权转入正常候选/检查生命周期,协调器只保留观察标记。 +- 选中后:候选及 component 保持 socket 存活直到连接关闭;取消探测不能关闭它。 +- 未选中的已提升 socket 若仍被进行中的 STUN/checklist 引用,先在 context 中安全移除相应工作,再释放。不能按一个“关闭整个池”的循环直接 close。 +- 借用的 anchor socket 和原 relay socket 始终由原 ICE 生命周期管理,探测清理只释放额外引用。 + +进程级额外 socket 配额随实际 socket 生命周期持有。提升或选中只转移配额所有者,不能提前返还配额后让进程实际句柄数越过上限。 + +## 8. 状态机、取消和故障行为 + +```text +DISABLED / INELIGIBLE + | + v +WAIT_RELAY_AND_VERIFIED_DTLS + | + v +EXCHANGE_HELLO -> PREPARING -> READY -> PROBING + | | + | v + | ICE_VALIDATING -> DIRECT_SELECTED + | | + v v + DRAINING -> DIRECT_SELECTED + | + v + STOPPED +``` + +`ICE_VALIDATING` 表示至少一个端点已提升;在数量与时间预算内可继续其余探索。单个命中检查失败不能立刻报告整个实验成功或关闭仍在工作的 relay。 + +到 T_probe 或探索预算耗尽时停止新探索;存在待验证端点则进入 DRAINING,否则结束。DRAINING 只继续已经登记的 ICE 工作,不再创建新端点或扩大端口集合;最多持续到 T_finish。 + +任何活动状态均可因下列事件进入 STOPPED: + +- 对端不支持、模式不符、观测不足、预算不足、控制协商超时。 +- 普通 ICE 提前选中了直连;立即停止本增强,区分成功来源为 `ordinary-ice`。 +- 原升级窗口到期、会话断开、ICE generation 改变、被借用 socket/接口失效。 +- 本轮 socket 分配不足、队列过载、认证上下文消失、协议冲突。 + +无效外部包只丢弃和限频计数,不能让任意错误报文取消有效会话。已认证对端主动 CANCEL 可以停止本轮,但不关闭媒体连接。 + +关闭顺序必须是:禁止新调度并标记取消 → 作废 generation/round 任务 → 移除 GSource/回调 → 释放探索资源 → 安全处理未选中提升端点 → 执行现有 NiceAgent 异步关闭 → 清理 exporter 密钥和 DTLS。工作不能阻塞或反向等待自己的 nice_thread。 + +构建默认开关关闭时应没有额外 socket、计时器、控制包或能力通告。运行关闭后不能靠收到对端消息隐式开启。 + +## 9. 日志与测量口径 + +INFO 记录阶段和结论;逐包记录放入可选调试日志。所有记录包含 connection/generation/round 标识,不打印 ICE 密码、exporter 或完整认证 tag。 + +| 事件 | 必须字段 | +| --- | --- | +| eligibility | 模式、双方能力、角色依据、样本 socket/年龄、跳过原因 | +| prepare/start | 协商实际预算、本地截止时间、anchor 本地/公网端点、批准的对端 IP | +| probe-tx/rx | 本地 socket_id、实际地址、原始外层源/目的、nonce、direct/control/relay 标记 | +| promote | 命中的具体 socket、触发原因、候选 foundation、候选对标识 | +| ICE check | TID、pair、角色、状态、外层地址、TURN peer(若有) | +| selected | 完整本地/远端候选类型和地址、成功来源、是否沿用命中 socket | +| stop | 原因、持续时间、唯一目标数、socket 峰值、发送尝试/成功/失败/重传、清理结果 | + +成功率至少分别报告: + +- 全部连接尝试中的直连建立比例。 +- 满足增强条件并实际启动的连接中的增强成功比例。 +- 普通 ICE 自行成功、增强成功、继续 relay、连接失败的数量。 + +分母包含失败/超时,不把“跳过增强”混入增强失败,也不能只汇总成功样本。记录 P50/P95 建立耗时、额外包数、峰值句柄、CPU 和切换期间的媒体间隙。每条结论都能追到 session ID、二进制版本和抓包。 + +## 10. 分阶段实施与验收 + +以下顺序是依赖关系。P0 未通过时先解决后端接入问题,不能通过增加扫描范围掩盖“已命中却无法接入 ICE”。 + +### P0:验证 READY 后新增 socket 与媒体切换 + +交付:最小 libnice 扩展及双端集成测试,在受控网络中创建一个额外 pool socket,获得直接入站后提升、普通 ICE 验证、选中并传输。 + +验收: + +- 从真实 selected relay、READY 和已验证 DTLS 状态开始,端点在此后创建。 +- Windows/macOS 均验证创建、物理接口绑定、接收、提升及结束清理;两个 ICE 角色都覆盖。 +- 捕获证明命中的本地端口和后续 STUN/媒体端口一致。 +- 验证候选不会因 READY、检查上限、同 foundation/base 或 end-of-candidates 流程被错误抑制。 +- 选路变化不重启 DTLS;未命中和主动取消保留原 relay。 +- 冻结后端接口、所有权表和必要补丁范围,记录实际代码接入点。 + +P0 工具只能使用实验提供的明确对端地址,不含广范围探测。 + +### P1:协议、密钥与配置 + +交付:能力行、验证后 exporter、报文编解码、重放窗口、HELLO/PREPARE/READY/START 协商和默认关闭配置。 + +验收:新旧版本互连正常;关闭时无新增行为;无效 tag、旧 generation、重复 START、丢失 READY、时钟偏差不导致越级启动或重复分配。控制包不会进入 DTLS/SRTP/业务包解析。 + +### P2:角色、端口采样和预算 + +交付:映射快照、角色判断、无放回端口采样、token bucket、pool 轮发策略、进程级配额。 + +验收:假时钟可以复现相同发送计划;延迟唤醒不超突发上限;观测过期/多 socket 混淆被拒绝;同进程多连接不突破共享限制;短 ACK 不绕过总预算。 + +### P3:组合探测与提升 + +交付:anchor、pool、直接报文接收钩子、命中后普通 ICE 检查、selected-pair 归因。 + +验收:完整实现第 7 节闭环;只提升命中端点;来自 TURN 内层的相同短包不产生直接命中;成功、超时和普通 ICE 抢先成功均正确清理。 + +### P4:生命周期、兼容与构建 + +交付:断开/重连/接口变化处理,libnice 配方和包身份更新,新符号检测,Windows/macOS/Linux 编译验证。 + +验收:连续创建/停止至少 100 轮无残留端口池或 source,关闭竞态检查通过;旧依赖缓存不能错误通告新能力;iOS/WebRTC 路径未启用此模式且正常构建。 + +### P5:真实网络对照与启用决定 + +交付:A/B/C 配置、自动汇总脚本、双端抓包复核和实验结论。 + +- A:现有策略,增强关闭。 +- B:相同新协议与固定角色,一侧单端点,另一侧扩大目的端口。 +- C:与 B 相同,但使用端口池。 + +按交错顺序重复 A/B/C,第一轮每组至少 30 次;保持网络、版本、STUN/TURN 和其余配置相同,记录每次启动间隔和真实端点。观察到一次成功不等于方案验收完成。 + +先验收机制和无回归,再评价收益:每个增强成功样本必须能证明直接命中 → 标准 STUN → nomination → 直接媒体;默认关闭路径无行为变化;资源全部在上限内。报告成功比例的不确定性,不从小样本承诺固定提升幅度。 + +在真实网络证明净收益且兼容/退出检查通过之前,保持默认关闭;通过后再单独形成默认启用及参数调整变更。 + +## 11. 测试设计 + +### 11.1 纯逻辑与协议测试 + +新增拟定目标 `minirtc_punch_unit_test`,覆盖: + +- 跨 generation、跨 socket 的样本隔离和角色对称性。 +- 端口采样范围、唯一性、可复现性、耗尽与数量边界。 +- 速率/包数/时长/进程配额,事件循环暂停后的行为。 +- 报文截断、长度溢出、错误域密钥、错 tag、旧轮次、nonce/socket 不匹配、乱序与重放。 +- READY/START/CANCEL 重复、断开在任意状态发生、部分分配失败。 +- 默认关闭、新旧能力组合、强制 relay、reliable、无 DTLS 验证的跳过条件。 + +### 11.2 libnice 集成测试 + +新增拟定目标 `minirtc_punch_integration_test`。当前 MiniRTC 没有已注册的可直接运行的 ICE 测试目标,旧 `tests/peerconnection` 示例不是可用基线。实现时必须注册目标并写出准确的构建/执行命令。 + +集成测试验证真实 NiceAgent、真实 socket、STUN 认证、triggered check、选路、媒体收发及销毁。模拟器内的单元测试不能替代 P0。 + +### 11.3 可控网络场景 + +搭建受控 UDP/NAT 测试拓扑,或具有真实 socket 和明确翻译规则的测试代理。不要默认 Linux 普通 MASQUERADE 就等于本次目的相关映射;必须先通过多目标 STUN 验证场景行为。 + +必测: + +1. LAN/普通可直连网络:普通 ICE 成功,不启动或及时停止增强。 +2. 一侧稳定映射且有端口相关过滤,另一侧目的相关映射:未知新映射参与匹配。 +3. 丢弃 anchor 最早的探测,让 pool 后来的包首先到达:复现 UU 的关键可达顺序。 +4. 没有目标端口交集:到期后保持 relay,释放探索资源。 +5. 模拟 UDP 丢失、重复、乱序,控制包延迟,以及两端墙上时钟相差至少 500 ms。 +6. 只有 TURN 内层收到同内容数据:不得记录为直接命中。 +7. 两侧都目的相关、观测不足、IPv6-only:v1 跳过,原功能继续。 +8. pool 任一端点命中后断开、提名中取消、接口变化、旧任务到达新 generation。 +9. 进程中超过两个连接请求增强:资源限制与普通媒体均正常。 + +### 11.4 真实会话验收 + +双方记录二进制/提交版本、实际配置、网卡、抓包起止与内核丢包计数;按 nonce、STUN TID、外层地址和成功后的 socket 对齐。NAT 可能重写端口,不能仅按发端源端口搜索对端抓包。 + +“直连成功”的严格条件:候选对两端均不含 relay,标准检查和提名完成,双端网卡观察到相匹配的外层 UDP,并持续承载应用数据。日志中的 prflx 或一个短包 ACK 不算成功。 + +## 12. 已识别的实现风险与解决位置 + +| 风险 | 解决阶段 | +| --- | --- | +| READY 后新增 socket 无法进入检查或被裁剪 | P0 验证并冻结必要 libnice 改动 | +| 为全部端口组合创建 ICE 对象导致检查表膨胀 | P2/P3 将探索与少量提升分离 | +| 临时 socket 成功后被清理,媒体仍发旧端口 | P0/P3 明确所有权转移并核对原始端口 | +| 把 TURN 解封装逻辑来源当成直接来包 | P3 在原始 socket 层分类,P5 双端抓包验收 | +| 现有 DTLS done 标志先于指纹验证 | P1 增加明确的 peer-verified 状态与导出门槛 | +| 自定义控制包落入 SRTP/业务解析 | P1/P3 按版本和种类分流,协议测试与媒体回归 | +| libnice/GLib 回调锁内重入、退出时悬空引用 | P0/P4 同 context 调度、句柄注册表与幂等停止 | +| 两端升级窗口不同或旧消息延长新轮次 | P1/P2 本地 monotonic 截止、generation/round 隔离 | +| 头文件与旧 libnice 静态库不匹配 | P4 包身份、补丁哈希、编译/链接及运行能力检测 | +| 把一次成功当作普遍成功率提升 | P5 分组重复实验,保留全部失败和跳过样本 | + +## 13. 后续执行入口 + +后续实现从 **P0** 开始,先读取本方案和当时仓库状态,确认源码基线是否变化。每完成一个阶段,在对应实现提交说明中记录:改动范围、验收证据、未解决问题和本方案是否需要修订。 + +如果 P0 证明某个后端接口或 selected-pair 路径不可按本文实现,应先根据实际 libnice 代码修订这一处设计并给出替代路径,再继续后续阶段;不要把未完成的直接验证改成强制选路或延长超时。 + +本方案当前的执行目标是一个默认关闭、可复现实验、完整管理 socket 生命周期的原型。通过 P5 后,才讨论默认启用、扩大网络适用范围,以及首次连接阶段的增强。 + +## 14. 技术依据 + +- [RFC 8445:ICE 的认证、peer-reflexive 候选、触发检查和提名](https://www.rfc-editor.org/rfc/rfc8445.html):命中后的验证复用 ICE;本文 READY 后探索为双方协商的额外能力。 +- [libnice NiceAgent 文档](https://libnice.freedesktop.org/libnice/NiceAgent.html):候选收集、端口范围、接收回调和检查参数;新增 pool API 不属于现有公共 API。 +- [OpenSSL 3.3:SSL_export_keying_material](https://docs.openssl.org/3.3/man3/SSL_export_keying_material/):独立标签及 context 导出会话密钥;本方案新增 peer-verified 前置条件。 +- [NAT traversal:多 socket 与目的端口探索](https://tailscale.com/blog/how-nat-traversal-works#the-benefits-of-birthdays):两个集合相交的通用思路,用于解释实验方向,不作为 UU 私有实现或成功率证明。 + + +## 15. 本轮 P0 实施记录(2026-09-13) + +MiniRTC 实施提交:`7f0e666216891c9b79bd4a8f75b63741fe1fabf2`。 +本机最终检查为 22/22 通过,包括 en1 实际 IPv4 与内核接口绑定读回;这不是跨 NAT +成功率。详情见 [P0 测试记录](../deps/submodules/minirtc/tests/ice/lab/p0-validation-2026-09-13.md) +与 [构建、双机执行和抓包步骤](../deps/submodules/minirtc/tests/ice/lab/README.md)。 + +本节是执行记录,不改变 P0→P5 的依赖门槛。**本轮没有完成可启用的“双端 +UDP 打洞增强”产品功能。** P0 的本机机制已可运行;Windows、双端物理网卡抓包 +和实际 CrossDesk 媒体尚待验收,因此不启动 P1–P5,不通告生产 v1 能力,也不进行 +512 目的端口/64 本地端点的广范围探索。 + +交付位于 MiniRTC 子模块,不能仅依赖开发机缓存: + +- `thirdparty/libnice/patches/punch_pool_0.1.24.patch`:在前三个现有补丁后应用, + 新增 `nice/punch.h` 与单端点实验后端,补丁 SHA、包身份版本与符号链接检查已进配方。 +- `tests/ice/punch_integration_test.cpp`:真实 NiceAgent、TURN、DTLS、SRTP 与 UDP + 转发门控,pool 的两个 ICE 角色均可执行;在代码中检查内核实际 selected socket。 +- `tests/ice/punch_peer_test.cpp`:两个独立进程/两台 LAN 主机的 P0 实验入口, + 私有共享目录提供测试信令,便于 Windows/macOS 后续配合;不是生产控制协议。 +- `tests/ice/lab/run_p0.py`:临时 coturn、STUN 就绪检查、场景矩阵、二进制 SHA、 + 结构化结果与清理;`tests/ice/lab/README.md` 给出准确构建、双机操作和抓包清单。 +- `src/ice/ice_agent.*`:明确 `dtls_peer_verified_`,统一两个握手完成入口的指纹 + 验证;导出 SRTP 前要求验证成功;有界保留早于本端 READY 的 DTLS flight。 + +### 15.1 经源码和实验确认的调整 + +1. `priv_conn_check_add_for_candidate_pair_matched()` 原来会将 READY 降为 CONNECTED。 + 对已打开 P0 端点且处于原升级窗口内的连接保留 READY,正常 STUN 排队、优先级、 + valid pair 和 nomination 不变。取消增强检查时,尚存的 selected relay 不因 + checklist 中提名对象已裁剪而被错误宣告 FAILED。 +2. 直接接收分流必须排除两类中继:本端 TURN 解封装,以及**对端 allocation 向 + 本端 host 发出的普通外层 UDP**。后者在 pool 为 controlling 的负向测试中被发现, + 因而在原始接收钩子中额外排除已知远端 relay candidate 地址。 +3. 初始实验复现了既有 DTLS 次序问题:ClientHello 到达时本端仍为 CONNECTED, + 原代码丢包且对端没有后续包推动握手。现在最多暂存 4 个、每个不超过 2048 字节的 + 早到 DTLS 数据报,仍由原 READY 回调启动同一个 DTLS 会话;没有重新握手、修改 + ICE/DTLS 超时或延长升级窗口。BIO 方法初始化也改为线程安全的一次性初始化。 +4. libnice 的 SDP 解析器按 LF 切分,直接复制 ufrag/password;实验初版擅自将 + libnice 输出改成 CRLF,造成密码尾部多出 CR 与真实 STUN integrity 失败。 + 夹具已保留原 LF 格式。此修正仅在测试信令中,不改写生产信令行为。 + +### 15.2 当前后端契约及边界 + +公开名称刻意标为 `NICE_PUNCH_P0_ABI`,不是 `udp-punch:1` 的运行能力证明。 + +| 接口 | P0 实际语义 | +| --- | --- | +| `nice_agent_punch_get_abi/live_sockets` | 实验 ABI、进程额外 socket 存量 | +| `nice_agent_punch_get_deadline` | 读取现有有效 relay upgrade 截止,无新窗口 | +| `nice_agent_punch_open` | 指定真实 IPv4 host base,在 READY relay 上借用或创建一个端点 | +| `nice_agent_punch_send` | 指定 generation/不透明 handle,向明确地址发送 80 字节实验探测 | +| `punch-packet` | 锁外信号,带范围、实际外层地址与有界字节副本;不表示 ICE valid | +| `nice_agent_punch_promote` | 保持 socket,加入一个普通 host/已知 anchor remote 检查对 | +| `nice_agent_punch_stop(..., FALSE)` | 停止探索,关闭未提升端点,继续已提升检查 | +| `nice_agent_punch_stop(..., TRUE)` | 先剪除未选中检查再关 socket;已选中端点保持传输 | + +原始 anchor、relay 的所有权始终属于原 ICE;新 socket 属于 component 的 +SocketSource;提升不关闭或重建 socket。额外 socket 配额在实际 SocketSource +释放之后返还,选中后仍占配额直到关闭。句柄不暴露 FD,不在 libnice 锁内反向调用。 + +P0 仅允许每 component 一个实验端点,进程硬限制 256 个额外 socket。它没有实现 +P1 的生产认证上下文、P2 速率公平预算、P3 广范围组合探测或 P4 完整重连协调器。 +实验 HMAC 密钥仅来自可信夹具/私有测试信令,不声称已经完成 DTLS exporter。 +`MINIRTC_UDP_PUNCH=wide-only|pool-wide` 目前没有被实现或读取;产品仍关闭。 + +### 15.3 验收和下一步 + +当前可运行的构建与实验步骤见子模块 +`tests/ice/lab/README.md`;可公开的测试摘要保存在同目录的结果记录中。 +成功意味着本机真实中继 → 验证过的 DTLS → 新 socket 的认证入站 → 正常 STUN/ +nomination → 同一 socket 的双向 SRTP,不能将编译、短包 ACK 或一次局域网成功 +当作 P0 总验收或 P5 的真实网络收益。 + +P0 总门槛仍需 Windows x64 编译/执行、Windows/macOS 双端抓包、两个 ICE 角色、 +实际桌面媒体连续性及退出检查。完成这些后再冻结生产后端接口并进入 P1,沿用本文 +其余协议、预算和 P1–P5 验收要求。此处没有通过降低验收要求提前启动广范围探索。