From 47fa9d2ae8433b56d951f07edbc91e8f118728fa Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 21:21:28 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20issue=20=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E6=9B=B4=E6=96=B0=E6=AD=A5=E9=AA=A4=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 issue/task 清单与自动化验证方案文档,明确分层测试、完成门禁和任务拆分\n- 新增更新步骤文档,按安全、规范、网络媒体和工程化阶段梳理修正路径\n- 补齐各阶段目标、建议修改位置、验收标准和执行顺序 --- docs/rustrtc-issue-task-checklist.md | 880 +++++++++++++++++++++++++++ docs/rustrtc-update-steps.md | 524 ++++++++++++++++ 2 files changed, 1404 insertions(+) create mode 100644 docs/rustrtc-issue-task-checklist.md create mode 100644 docs/rustrtc-update-steps.md diff --git a/docs/rustrtc-issue-task-checklist.md b/docs/rustrtc-issue-task-checklist.md new file mode 100644 index 0000000..58a0397 --- /dev/null +++ b/docs/rustrtc-issue-task-checklist.md @@ -0,0 +1,880 @@ +# rustrtc issue/task 清单与自动化验证方案 + +日期: 2026-03-13 + +关联文档: +- `docs/rustrtc-update-steps.md` +- `docs/rustrtc-audit-2026-03-13.md` +- `docs/rustrtc-security-hardening-plan.md` +- `docs/rustrtc-webrtc-gap-matrix.md` +- `docs/rustrtc-webrtc-completion-plan.md` + +目标: +- 将 `docs/rustrtc-update-steps.md` 拆成可直接进入 issue 或任务系统的工作清单 +- 为每个工作项补齐自动化测试方案 +- 明确哪些项可复用现有测试,哪些项需要新增专门的测试客户端或测试 harness + +原则: +1. 每个任务必须至少对应一个自动化验证入口 +2. 每个高风险协议改动必须同时包含正向测试和负向测试 +3. 每个跨栈能力至少保留一个本地可跑的互操作测试 +4. 优先复用现有 `tests/` 和 `examples/`,不足部分再补专门测试客户端 +5. 每个步骤在“建议命令”全部通过后,必须生成 `git commit memo` 并提交,未提交视为未完成 + +## 1. 自动化验证分层 + +建议将自动化验证固定分成五层: + +### L0: 编译与静态门禁 + +- `cargo check` +- `cargo check --tests --examples` + +用途: +- 防止 API 改动和测试入口失效 + +### L1: 单元与组件测试 + +运行方式: +- `cargo test --lib` +- `cargo test --test ` + +用途: +- 验证纯协议逻辑、状态机分支、包解析、重放窗口、缓冲上限 + +### L2: 本地互操作测试 + +运行方式: +- 现有 `webrtc-rs` 集成测试 +- 本地 loopback peer +- 本地 TURN server / 本地 helper client + +用途: +- 验证跨栈语义而不是只测内部函数 + +### L3: 专门测试客户端 + +适用场景: +- 需要构造异常报文 +- 需要生成特定 codec 协商组合 +- 需要验证 TURN/TCP/TLS、TWCC、VP9/H.265 之类当前仓内缺少现成对端的场景 + +建议增加的测试客户端目录: + +1. `tests/clients/webrtc_rs_peer/` +2. `tests/clients/pion_peer/` +3. `tests/clients/local_turn_server/` +4. `tests/clients/rtcp_feedback_peer/` +5. `tests/clients/malformed_peer/` + +说明: +- `webrtc-rs` 适合 DataChannel、Offer/Answer、TURN、基础媒体互通 +- `Pion` 更适合做 codec 扩展、复杂 SDP 组合和更贴近浏览器的互操作补充 +- `malformed_peer` 用于发非法 DTLS/SCTP/RTP/ICE 输入,不依赖真实浏览器 + +### L4: commit memo 与提交门禁 + +适用范围: +- 本文中的每一个 `ISSUE-xx` + +规则: +1. 只有在该步骤列出的“建议命令”全部通过后,才允许生成 commit +2. commit 前必须先生成一份 `git commit memo` +3. commit memo 至少要覆盖: + - `Issue` + - `变更摘要` + - `涉及模块/文件` + - `自动测试命令` + - `测试结果` + - `风险与后续事项` +4. commit message 应直接吸收该 memo 的核心信息,而不是只写一句模糊标题 +5. 如果某步骤依赖专门测试客户端,该客户端验证结果也必须写入 memo + +建议模板: + +```text + + +Issue: ISSUE-xx +Summary: +- ... + +Files: +- ... + +Verification: +- cargo test ... +- cargo test ... + +Result: +- pass + +Risk: +- ... + +Follow-up: +- ... +``` + +提交要求: +- 每个步骤的“完成门禁”都默认包含这一层 +- 未生成 memo 或未提交 commit,不应将该步骤标记为完成 + +## 2. 建议复用的现有测试资产 + +当前可直接复用: + +1. `src/transports/dtls/tests.rs` + - DTLS 主路径 + - fingerprint mismatch +2. `tests/ordered_channel_test.rs` + - ordered channel + - DCEP ordered path +3. `tests/interop_datachannel.rs` + - webrtc-rs 与 RustRTC DataChannel 互操作 +4. `tests/interop_turn.rs` + - TURN relay 路径 +5. `tests/interop_simulcast.rs` + - simulcast ingest 与 RID/SelectorTrack +6. `tests/rtp_reinvite_test.rs` +7. `tests/rtp_reinvite_comprehensive_test.rs` + - reinvite 相关路径 +8. `tests/sctp_reliability.rs` +9. `tests/sctp_congestion_control_test.rs` + - SCTP 可靠性和拥塞控制 +10. `tests/media_flow.rs` + - 基础媒体收发 + +## 3. issue/task 清单 + +通用说明: +- 以下每个 `ISSUE` 除了各自列出的“完成门禁”,还必须满足上面的 `L4: commit memo 与提交门禁` +- 也就是说,单纯“测试跑通”还不算完成,必须在测试跑通后生成 commit memo 并提交 + +### ISSUE-01 `baseline/regression-safety-net` + +目标: +- 建立后续所有协议改动都要经过的统一自动化底座 + +任务: +1. 建立按能力分组的测试入口: + - `security` + - `signaling` + - `datachannel` + - `network` + - `media` + - `stats` +2. 将现有关键测试纳入固定分组 +3. 补一个统一测试脚本或 CI job 映射表 + +自动测试方案: +- 复用: + - `src/transports/dtls/tests.rs` + - `tests/ordered_channel_test.rs` + - `tests/interop_datachannel.rs` + - `tests/interop_turn.rs` + - `tests/rtp_reinvite_comprehensive_test.rs` +- 新增: + - `tests/regression_baseline.rs` + - 只负责检查关键能力入口没有被误删 + +建议命令: +- `cargo check` +- `cargo check --tests --examples` +- `cargo test --test ordered_channel_test` +- `cargo test --test interop_datachannel` +- `cargo test --test interop_turn` +- `cargo test --test rtp_reinvite_comprehensive_test` + +专门客户端需求: +- 无 + +完成门禁: +- 关键主路径测试可以按分组稳定运行 + +### ISSUE-02 `security/srtp-replay-window` + +目标: +- 补齐 SRTP / SRTCP replay protection + +任务: +1. 为 RTP 增加 replay window +2. 为 SRTCP 增加 replay window +3. 为重复包、过旧包、乱序包定义明确处理结果 +4. 将 reject 事件输出到统计和日志 + +自动测试方案: +- 新增单元测试: + - `tests/srtp_replay_window.rs` + - `duplicate_rtp_packet_rejected` + - `out_of_order_rtp_packet_within_window_accepted` + - `too_old_rtp_packet_rejected` + - `duplicate_srtcp_packet_rejected` +- 新增组件测试: + - `tests/srtp_replay_integration.rs` + - 建立两个本地 SRTP 上下文 + - 注入重复包与乱序包 + - 验证发送侧和接收侧行为 + +建议命令: +- `cargo test --test srtp_replay_window` +- `cargo test --test srtp_replay_integration` + +专门客户端需求: +- 无 +- 使用本地 packet fixture 即可 + +完成门禁: +- RTP/SRTCP replay 正向与负向测试全部通过 + +### ISSUE-03 `security/dtls-sctp-buffer-limits` + +目标: +- 为 DTLS 和 DataChannel 重组链路增加硬上限 + +任务: +1. 为 DTLS handshake 分片重组增加大小上限 +2. 为 DataChannel 单消息重组增加大小上限 +3. 为单 channel 重组缓冲增加上限 +4. 明确超限时的关闭或丢弃策略 + +自动测试方案: +- 新增单元测试: + - `src/transports/dtls/tests.rs` + - `test_dtls_fragment_reassembly_rejects_oversized_message` + - `tests/datachannel_reassembly_limits.rs` + - `oversized_ordered_message_rejected` + - `oversized_unordered_message_rejected` + - `near_limit_message_accepted` +- 新增异常输入测试: + - `tests/security_malformed_buffers.rs` + +专门客户端需求: +- 需要新增 `tests/clients/malformed_peer/` +- 功能: + - 直接发送伪造 DTLS fragment + - 直接发送伪造 SCTP/DataChannel fragment +- 原因: + - 这类非法输入不适合依赖浏览器或 `webrtc-rs` 生成 + +建议命令: +- `cargo test dtls_fragment_reassembly_rejects_oversized_message` +- `cargo test --test datachannel_reassembly_limits` +- `cargo test --test security_malformed_buffers` + +完成门禁: +- 超限输入稳定失败 +- 接近阈值的正常输入不回归 + +### ISSUE-04 `signaling/pranswer-rollback` + +目标: +- 补齐 `pranswer/rollback` + +任务: +1. 实现本地 rollback +2. 实现远端 rollback +3. 实现 `pranswer -> answer` +4. 补齐异常状态分支 + +自动测试方案: +- 新增测试: + - `tests/signaling_pranswer_rollback.rs` + - `local_rollback_restores_stable` + - `remote_rollback_restores_stable` + - `pranswer_then_answer_succeeds` + - `invalid_state_rejected` +- 扩展现有测试: + - `tests/rtp_reinvite_test.rs` + - `tests/rtp_reinvite_comprehensive_test.rs` + +专门客户端需求: +- 首轮不需要 +- 若要验证跨栈兼容性,可在第二阶段补 `tests/clients/webrtc_rs_peer/` + +建议命令: +- `cargo test --test signaling_pranswer_rollback` +- `cargo test --test rtp_reinvite_test` +- `cargo test --test rtp_reinvite_comprehensive_test` + +完成门禁: +- 所有 signaling 状态迁移均有自动测试覆盖 + +### ISSUE-05 `media/codec-runtime-model` + +目标: +- 将 codec 协商结果引入运行时 + +任务: +1. 扩展 `RtpCodecParameters` +2. 解析 `rtpmap/fmtp/rtcp-fb` +3. 在 answer 生成时做能力交集 +4. 为后续 VP9/H.265 保留 codec-specific 参数结构 + +自动测试方案: +- 新增单元测试: + - `tests/codec_runtime_model.rs` + - `extract_payload_map_preserves_codec_name` + - `extract_payload_map_preserves_fmtp` + - `extract_payload_map_preserves_rtcp_fb` + - `answer_rejects_incompatible_codec_pair` +- 新增集成测试: + - `tests/codec_negotiation_integration.rs` + - `opus_fmtp_roundtrip` + - `h264_profile_level_id_roundtrip` + - `h264_packetization_mode_roundtrip` + +专门客户端需求: +- 建议新增 `tests/clients/pion_peer/` +- 用途: + - 生成更复杂的 H264/VP9/H.265 SDP 组合 + - 避免只依赖当前 `webrtc-rs` 的 codec 覆盖面 + +建议命令: +- `cargo test --test codec_runtime_model` +- `cargo test --test codec_negotiation_integration` + +完成门禁: +- Opus/H264 的关键 `fmtp` 参数进入运行时 + +### ISSUE-06 `config/certificate-plumbing` + +目标: +- 让 `RtcConfiguration.certificates` 真正进入运行时 + +任务: +1. 支持从 PEM 链和私钥加载证书 +2. 未配置证书时保留自签 +3. 配置证书后重算 fingerprint +4. 明确错误配置的失败路径 + +自动测试方案: +- 新增单元测试: + - `tests/certificate_config.rs` + - `pem_certificate_load_success` + - `pem_key_mismatch_fails` + - `default_self_signed_fallback_works` +- 新增集成测试: + - `tests/certificate_fingerprint_integration.rs` + - 配置固定证书 + - 生成 SDP + - 校验 fingerprint 与证书一致 + +专门客户端需求: +- 无 +- 可本地生成临时 PEM fixture + +建议命令: +- `cargo test --test certificate_config` +- `cargo test --test certificate_fingerprint_integration` + +完成门禁: +- 配置证书路径和默认路径均可自动验证 + +### ISSUE-07 `interop/datachannel-default-ordering` + +目标: +- 让默认 DataChannel 语义与浏览器一致 + +任务: +1. 将默认值改为 `ordered=true` +2. 校验 DCEP `channel_type` +3. 保持显式 `ordered=false` 可用 + +自动测试方案: +- 扩展现有测试: + - `tests/ordered_channel_test.rs` + - 增加 `create_data_channel(label, None)` 默认行为断言 +- 新增测试: + - `tests/datachannel_default_semantics.rs` + - `default_channel_is_ordered_reliable` + - `explicit_unordered_still_supported` + +专门客户端需求: +- 复用现有 `webrtc-rs` 对端即可 + +建议命令: +- `cargo test --test ordered_channel_test` +- `cargo test --test datachannel_default_semantics` + +完成门禁: +- 默认 `None` 配置的跨栈行为与浏览器语义一致 + +### ISSUE-08 `config/remove-misleading-api-surface` + +目标: +- 消除配置与实现错位 + +任务: +1. `IceCredentialType::Oauth` 明确为实现或提前报错 +2. `bundle_policy` 明确为实现或行为收敛 +3. 审核其它仅停留在配置层的字段 + +自动测试方案: +- 新增测试: + - `tests/config_support_matrix.rs` + - `oauth_credential_fails_early_with_clear_error` + - `bundle_policy_behavior_is_explicit` + - `unsupported_config_is_not_silently_ignored` + +专门客户端需求: +- 无 + +建议命令: +- `cargo test --test config_support_matrix` + +完成门禁: +- 不再存在“静默接受但运行时无效”的配置入口 + +### ISSUE-09 `network/turn-tcp-tls-fix` + +目标: +- 修正 TURN/TCP/TLS 和 candidate transport 语义 + +任务: +1. 修正 `probe_stun()` 的 TCP 伪实现 +2. 修正 relay candidate transport 语义 +3. 打通 TURN over TCP/TLS +4. 增加 `turn:?transport=tcp` 和 `turns:` 回归测试 + +自动测试方案: +- 扩展: + - `tests/interop_turn.rs` +- 新增: + - `tests/interop_turn_tcp.rs` + - `tests/interop_turn_tls.rs` + - `tests/turn_candidate_semantics.rs` + +专门客户端需求: +- 需要新增 `tests/clients/local_turn_server/` +- 功能: + - 在测试进程内或辅助进程里启动本地 TURN server + - 支持 UDP/TCP/TLS 三种入口 +- 说明: + - 这样可避免依赖外部 TURN 环境 + +建议命令: +- `cargo test --test interop_turn` +- `cargo test --test interop_turn_tcp` +- `cargo test --test interop_turn_tls` +- `cargo test --test turn_candidate_semantics` + +完成门禁: +- 仅依赖本地 TURN/TCP/TLS 也可完成建连和 DataChannel 互通 + +### ISSUE-10 `network/ice-tcp-decision` + +目标: +- 明确是否实现 ICE-TCP,并让该结论可自动验证 + +任务 A: 如果决定暂不实现 +1. 在配置、日志和文档中明确声明不支持 +2. 对 TCP candidate 输入提前报错或安全忽略 + +任务 B: 如果决定实现 +1. 增加 `tcptype` +2. 扩展 SDP 解析与生成 +3. 增加 TCP host candidate gather +4. 增加 TCP connectivity check + +自动测试方案: +- 若暂不实现: + - `tests/ice_tcp_not_supported.rs` + - `tcp_candidate_rejected_with_clear_error` + - `tcp_candidate_does_not_trigger_invalid_udp_logic` +- 若实现: + - `tests/ice_tcp_connectivity.rs` + - `tests/ice_tcp_sdp_roundtrip.rs` + +专门客户端需求: +- 若暂不实现: 无 +- 若实现: 需要新增 `tests/clients/tcp_candidate_peer/` + +建议命令: +- `cargo test --test ice_tcp_not_supported` +- 或 +- `cargo test --test ice_tcp_connectivity` +- `cargo test --test ice_tcp_sdp_roundtrip` + +完成门禁: +- 不支持时有清晰自动验证 +- 支持时有完整 TCP 建连回归 + +### ISSUE-11 `media/default-video-path-alignment` + +目标: +- 对齐默认视频 codec 收发链路 + +任务: +1. 增加 `VP8 depacketizer` +2. 明确默认视频能力集 +3. 保证广告与实际收发链路一致 + +自动测试方案: +- 新增单元测试: + - `tests/vp8_depacketizer.rs` +- 新增集成测试: + - `tests/video_default_path.rs` + - `vp8_send_receive_roundtrip` + - `h264_advertised_path_has_receiver` + +专门客户端需求: +- 建议复用 `webrtc-rs` 或 `Pion` +- 若 `webrtc-rs` 的视频能力不足,优先用 `Pion` helper + +建议命令: +- `cargo test --test vp8_depacketizer` +- `cargo test --test video_default_path` + +完成门禁: +- 默认视频能力不再出现“协商成功但媒体解不开”的假成功 + +### ISSUE-12 `cc/remb-twcc-closure` + +目标: +- 让 REMB/TWCC 从报文层实现推进到控制闭环 + +任务: +1. 收到 REMB 后更新发送侧目标码率 +2. 为 TWCC 引入序列号写入、反馈生成和带宽估计 +3. 将估计结果作用到 sender + +自动测试方案: +- 新增单元测试: + - `tests/remb_controller.rs` + - `tests/twcc_feedback.rs` +- 新增集成测试: + - `tests/congestion_control_integration.rs` + - 模拟不同反馈强度 + - 验证目标码率变化 + +专门客户端需求: +- 需要新增 `tests/clients/rtcp_feedback_peer/` +- 功能: + - 精确构造 REMB 和 TWCC 反馈 + - 记录 RustRTC 的码率调整结果 + +建议命令: +- `cargo test --test remb_controller` +- `cargo test --test twcc_feedback` +- `cargo test --test congestion_control_integration` + +完成门禁: +- 反馈到控制行为形成可测闭环 + +### ISSUE-13 `media/vp9-support` + +目标: +- 增加 VP9 协商和收发链路 + +任务: +1. 增加 `VP9` SDP 协商 +2. 增加 `Vp9Payloader` +3. 增加 `Vp9Depacketizer` +4. 验证与 simulcast / reinvite 的兼容性 + +自动测试方案: +- 新增单元测试: + - `tests/vp9_packetizer.rs` + - `tests/vp9_depacketizer.rs` +- 新增协商测试: + - `tests/vp9_negotiation.rs` +- 新增集成测试: + - `tests/vp9_media_flow.rs` + +专门客户端需求: +- 需要新增 `tests/clients/pion_peer/` +- 原因: + - `Pion` 更适合作为 VP9 对端 + +建议命令: +- `cargo test --test vp9_packetizer` +- `cargo test --test vp9_depacketizer` +- `cargo test --test vp9_negotiation` +- `cargo test --test vp9_media_flow` + +完成门禁: +- `VP9 only` 和 `VP8 + VP9 fallback` 可自动验证 + +### ISSUE-14 `media/h265-support` + +目标: +- 增加 H.265 显式启用、协商、收发和回退 + +任务: +1. 增加 H.265 协商 +2. 增加 `H265Payloader` +3. 增加 `H265Depacketizer` +4. 增加显式启用开关 +5. 验证 fallback + +自动测试方案: +- 新增单元测试: + - `tests/h265_packetizer.rs` + - `tests/h265_depacketizer.rs` +- 新增协商测试: + - `tests/h265_negotiation.rs` +- 新增集成测试: + - `tests/h265_media_flow.rs` + - `tests/h265_fallback.rs` + +专门客户端需求: +- 需要新增 `tests/clients/pion_peer/` +- 说明: + - H.265 支持矩阵更碎片化,不建议只依赖仓内假对端 + +建议命令: +- `cargo test --test h265_packetizer` +- `cargo test --test h265_depacketizer` +- `cargo test --test h265_negotiation` +- `cargo test --test h265_media_flow` +- `cargo test --test h265_fallback` + +完成门禁: +- 显式启用才广告 H.265 +- `H264 + H.265 fallback` 可自动验证 + +### ISSUE-15 `stats/transport-and-datachannel` + +目标: +- 让 stats 类型与实际产出一致 + +任务: +1. 增加 `Transport` stats +2. 增加 `IceCandidatePair` stats +3. 增加 `DataChannel` stats +4. 补 RTT 等未完成字段 + +自动测试方案: +- 新增测试: + - `tests/stats_transport.rs` + - `tests/stats_datachannel.rs` + - `tests/stats_ice_candidate_pair.rs` +- 扩展: + - `src/stats_collector.rs` 现有测试 + +专门客户端需求: +- 无 +- 复用本地 peer 和现有 DataChannel / TURN 测试即可触发统计 + +建议命令: +- `cargo test --test stats_transport` +- `cargo test --test stats_datachannel` +- `cargo test --test stats_ice_candidate_pair` + +完成门禁: +- `StatsKind` 暴露的关键类型均有真实产出 + +### ISSUE-16 `ops/security-observability` + +目标: +- 为安全和异常路径增加可观测性 + +任务: +1. 增加 replay reject 计数 +2. 增加 fingerprint mismatch 计数 +3. 增加 DTLS/DataChannel 重组超限计数 +4. 增加 TURN 认证失败计数 + +自动测试方案: +- 新增测试: + - `tests/security_metrics.rs` + - 每个异常路径触发一次 + - 校验计数器或 stats entry 增长 + +专门客户端需求: +- 可复用: + - `malformed_peer` + - `local_turn_server` + +建议命令: +- `cargo test --test security_metrics` + +完成门禁: +- 关键异常路径都能被自动触发并观测到 + +### ISSUE-17 `docs/implementation-scope-sync` + +目标: +- 保持文档、配置和测试事实一致 + +任务: +1. 每次关闭 issue 时同步更新: + - 审计文档 + - gap matrix + - update steps + - completion plan +2. 记录该能力的自动测试入口 +3. 如果能力只在特定模式下成立,必须写明 + +自动测试方案: +- 新增轻量脚本: + - `scripts/check-doc-links.sh` + - 检查核心文档是否都引用最新 checklist +- 新增文档一致性测试: + - `tests/doc_scope_smoke.rs` + - 检查关键未实现项不会被误标为已实现 + +专门客户端需求: +- 无 + +建议命令: +- `bash scripts/check-doc-links.sh` +- `cargo test --test doc_scope_smoke` + +完成门禁: +- 文档不再长期落后于实现 + +## 4. 推荐的专门测试客户端设计 + +### 4.1 `tests/clients/malformed_peer/` + +用途: +- 发送异常 DTLS/SCTP/DataChannel/ICE 输入 + +覆盖任务: +- ISSUE-03 +- ISSUE-16 + +建议实现: +- Rust +- 直接复用仓内报文结构和 socket 抽象 + +### 4.2 `tests/clients/local_turn_server/` + +用途: +- 在本地测试中启动 TURN UDP/TCP/TLS 服务 + +覆盖任务: +- ISSUE-09 +- ISSUE-16 + +建议实现: +- Rust +- 优先复用现有 `turn` 依赖或测试内 helper + +### 4.3 `tests/clients/rtcp_feedback_peer/` + +用途: +- 精确发送 REMB/TWCC/PLI/FIR/NACK 反馈 + +覆盖任务: +- ISSUE-12 +- ISSUE-15 + +建议实现: +- Rust +- 方便与现有 `rtp.rs` 报文结构复用 + +### 4.4 `tests/clients/pion_peer/` + +用途: +- 做 VP9/H.265 和复杂 SDP 互操作 + +覆盖任务: +- ISSUE-05 +- ISSUE-11 +- ISSUE-13 +- ISSUE-14 + +建议实现: +- Go +- 参考现有 `examples/interop_pion_go/` + +### 4.5 `tests/clients/webrtc_rs_peer/` + +用途: +- 做默认浏览器语义近似验证 +- 覆盖 DataChannel、基础媒体、TURN 主路径 + +覆盖任务: +- ISSUE-04 +- ISSUE-07 +- ISSUE-09 +- ISSUE-15 + +建议实现: +- Rust +- 直接复用当前 `tests/interop_*.rs` 里的公共逻辑 + +## 5. 建议的 CI 分组 + +### CI-1 `check` + +- `cargo check` +- `cargo check --tests --examples` + +### CI-2 `security` + +- ISSUE-02 +- ISSUE-03 +- ISSUE-16 + +### CI-3 `signaling-and-datachannel` + +- ISSUE-04 +- ISSUE-07 +- ISSUE-08 + +### CI-4 `network` + +- ISSUE-09 +- ISSUE-10 + +### CI-5 `media-core` + +- ISSUE-05 +- ISSUE-11 +- ISSUE-12 + +### CI-6 `media-extended` + +- ISSUE-13 +- ISSUE-14 + +### CI-7 `stats-and-docs` + +- ISSUE-15 +- ISSUE-17 + +说明: +- `media-extended` 可先作为非阻断 job,等 VP9/H.265 落地后转为阻断 +- `network` 中的 TURN/TLS 若依赖本地证书,可在 CI 内动态生成 + +## 6. 交付顺序建议 + +按可执行性建议按以下顺序建 issue: + +1. ISSUE-01 +2. ISSUE-02 +3. ISSUE-03 +4. ISSUE-04 +5. ISSUE-05 +6. ISSUE-06 +7. ISSUE-07 +8. ISSUE-08 +9. ISSUE-09 +10. ISSUE-10 +11. ISSUE-11 +12. ISSUE-12 +13. ISSUE-15 +14. ISSUE-16 +15. ISSUE-13 +16. ISSUE-14 +17. ISSUE-17 + +说明: +- `stats` 和 `observability` 可以在媒体扩展前完成 +- `VP9 / H.265` 必须排在 codec runtime model 之后 + +## 7. 完成定义 + +只有当以下条件满足,才能说这份 checklist 执行完成: + +1. 每个 issue 都有对应自动化测试入口 +2. 每个高风险改动都有负向测试 +3. 每个跨栈能力都有至少一个本地互操作验证 +4. 外部依赖场景都有本地 helper client 或本地 server 替代 +5. CI 能按分组稳定执行这些测试 + +这样,`rustrtc` 的后续演进才不会再依赖手工验证或临时经验判断。 diff --git a/docs/rustrtc-update-steps.md b/docs/rustrtc-update-steps.md new file mode 100644 index 0000000..728da46 --- /dev/null +++ b/docs/rustrtc-update-steps.md @@ -0,0 +1,524 @@ +# rustrtc 更新步骤文档 + +日期: 2026-03-13 + +关联文档: +- `docs/rustrtc-audit-2026-03-13.md` +- `docs/rustrtc-issue-task-checklist.md` +- `docs/rustrtc-security-hardening-plan.md` +- `docs/rustrtc-webrtc-gap-matrix.md` +- `docs/rustrtc-webrtc-completion-plan.md` + +目的: +- 将安全加固计划与 WebRTC 能力差距矩阵整合成一份可执行的更新步骤文档 +- 明确哪些工作必须先做,哪些工作可以并行,哪些工作属于后续扩展 +- 为 issue 拆分、里程碑规划和回归验收提供统一依据 + +适用范围: +- WebRTC 主路径 +- 安全硬化 +- 协议行为对齐 +- 媒体能力补齐 +- 工程化收尾 + +## 1. 当前基线 + +当前已经具备的能力: + +- 自研 `PeerConnection`、SDP、ICE/STUN/TURN、DTLS、SRTP、SCTP/DataChannel、RTP/RTCP 主路径 +- 常规 Offer/Answer、re-invite、simulcast ingest、direct RTP/SRTP mode +- DTLS fingerprint 绑定校验闭环 +- `cargo check` +- `cargo check --tests --examples` + +当前仍然阻碍项目成为完整生产级 WebRTC 终止栈的关键缺口: + +1. SRTP / SRTCP replay protection 不完整 +2. DTLS / DataChannel 分片与重组缺少硬上限 +3. `pranswer/rollback` 未实现 +4. codec 运行时模型过薄,`fmtp/rtcp-fb` 没有进入真正协商闭环 +5. `RtcConfiguration.certificates` 未接线 +6. DataChannel 默认语义与浏览器默认行为不一致 +7. TURN/TCP/TLS 路径与 candidate 语义尚未收敛 +8. 视频默认收发 codec 能力不对齐 +9. stats 公开类型与实际产出不一致 + +## 2. 更新原则 + +1. 先补安全闭环,再补能力扩展 +2. 先补浏览器互通关键路径,再补复杂网络和高级 codec +3. 先消除“看起来支持、实际上未落地”的配置/API,再扩大公开能力面 +4. 每一步都必须配套最小回归测试 +5. 每个阶段结束时都要能给出可验证的完成标准 + +## 3. 总体阶段划分 + +### 阶段 A: 安全基线补齐 + +目标: +- 让公网环境下最容易出问题的安全短板先闭环 + +包含事项: +1. SRTP / SRTCP replay protection +2. DTLS / DataChannel 分片与重组上限 +3. 安全专项回归测试 + +阶段出口: +- 重放攻击和缓冲膨胀问题不再是主阻断项 + +### 阶段 B: WebRTC 规范关键路径补齐 + +目标: +- 让主流浏览器互通不再依赖“碰巧走通” + +包含事项: +1. `pranswer/rollback` +2. codec runtime model +3. `fmtp/rtcp-fb` runtime negotiation +4. `RtcConfiguration.certificates` 接线 +5. DataChannel 默认行为修正 +6. 收敛 `bundle_policy` / `IceCredentialType::Oauth` 等错位 API + +阶段出口: +- 规范关键状态机和默认行为基本对齐 + +### 阶段 C: 复杂网络与媒体链路补齐 + +目标: +- 让受限网络、复杂 codec 和自适应媒体路径达到可用状态 + +包含事项: +1. TURN/TCP/TLS 修正 +2. 是否实现 ICE-TCP 的明确决策 +3. VP8/H264 默认能力对齐 +4. REMB/TWCC 闭环 +5. VP9 / H.265 扩展 + +阶段出口: +- 在复杂网络和多 codec 条件下具备稳定行为 + +### 阶段 D: 工程化收尾 + +目标: +- 让文档、统计、回归和配置说明足以支撑持续迭代 + +包含事项: +1. Stats 能力补齐 +2. 安全和互操作指标可观测 +3. CI 或准 CI 回归矩阵 +4. 文档和配置说明收尾 + +阶段出口: +- 项目不再只是“能实现”,而是“可维护地演进” + +## 4. 分步骤更新方案 + +### 步骤 1: 建立统一回归底座 + +目标: +- 在修改安全和协商逻辑前,先固定已有正确行为 + +要做的事: +1. 保留并扩展 `fingerprint mismatch` 负向测试 +2. 为当前可用的 Offer/Answer、re-invite、ordered channel、TURN relay 建立基线回归组 +3. 将安全类测试与功能类测试分组,便于后续追踪 + +输出: +- 一组可反复运行的最小回归集合 + +依赖: +- 无 + +### 步骤 2: 补齐 SRTP / SRTCP replay protection + +目标: +- 消除 SRTP/SRTCP 的核心安全缺口 + +要做的事: +1. 为 RTP 引入标准 replay window +2. 为 SRTCP 引入独立 replay window +3. 对重复包、过旧包和窗口外包做明确拒绝 +4. 将 replay reject 结果纳入日志和统计 + +建议修改位置: +- `src/srtp.rs` + +验收标准: +- 重复包被拒绝 +- 窗口内乱序包可接受 +- 过旧包被拒绝 +- RTP 与 SRTCP 都具备对应保护 + +### 步骤 3: 为 DTLS / DataChannel 重组增加硬上限 + +目标: +- 消除最直接的内存型 DoS 面 + +要做的事: +1. 为 DTLS handshake 分片重组增加累计字节上限 +2. 为 DTLS 分片数或消息长度增加限制 +3. 为 DataChannel 单消息重组增加字节上限 +4. 为单 channel 重组缓冲增加限制 +5. 明确超限后的行为: 丢弃、关闭 channel 或失败退出 + +建议修改位置: +- `src/transports/dtls/mod.rs` +- `src/transports/sctp.rs` +- `src/transports/datachannel.rs` +- `src/config.rs` + +验收标准: +- 超限输入不会导致缓冲无限增长 +- 超限路径可观测 +- 正常消息不回归 + +### 步骤 4: 补齐 `pranswer/rollback` + +目标: +- 补上完整 signaling 状态机中的明确缺口 + +要做的事: +1. 定义 `stable/have-local-offer/have-remote-offer` 下的 rollback 规则 +2. 为本地和远端 description 增加 rollback 路径 +3. 为 `pranswer -> answer` 增加合法状态迁移 +4. 明确异常状态返回值 + +建议修改位置: +- `src/peer_connection.rs` +- 必要时 `src/sdp.rs` + +验收标准: +- rollback 后可重新发起协商 +- `pranswer -> answer` 可走通 +- 常规 Offer/Answer 和 re-invite 不回归 + +依赖: +- 建议在步骤 1 之后进行 + +### 步骤 5: 重构 codec 运行时模型 + +目标: +- 让协商结果真正进入运行时,而不是只停留在 SDP 文本层 + +要做的事: +1. 扩展 `RtpCodecParameters` +2. 增加 `codec_name` +3. 增加 `fmtp` +4. 增加 `rtcp_fbs` +5. 按 payload type 合并 `rtpmap/fmtp/rtcp-fb` +6. 在生成 answer 时真正做本地能力与远端能力交集 + +建议修改位置: +- `src/peer_connection.rs` +- `src/config.rs` +- 如有必要新增 `src/media/codecs.rs` + +验收标准: +- Opus `fmtp` 能进入运行时 +- H264 `packetization-mode/profile-level-id` 不丢失 +- 不兼容 codec 组合会被拒绝或正确降级 + +依赖: +- 这是后续 VP8/VP9/H264/H.265 对齐和扩展的前置条件 + +### 步骤 6: 修正证书配置与默认 DataChannel 语义 + +目标: +- 解决两个最容易误导使用者的运行时行为问题 + +要做的事: +1. 让 `RtcConfiguration.certificates` 真正进入运行时 +2. 在未配置证书时继续保留自签回退 +3. 配置证书后重算本地 fingerprint +4. 将 DataChannel 默认值从 `ordered=false` 调整为 `ordered=true` +5. 校验 DCEP `channel_type` 与默认行为一致 + +建议修改位置: +- `src/config.rs` +- `src/peer_connection.rs` +- `src/transports/dtls/mod.rs` +- `src/transports/datachannel.rs` +- `src/transports/sctp.rs` + +验收标准: +- 配置证书后实际使用配置证书 +- 错误证书配置会明确失败 +- `create_data_channel(label, None)` 表现为 ordered reliable +- 显式 `ordered=false` 不回归 + +### 步骤 7: 收敛 API 与实现错位 + +目标: +- 消除“配置存在但实现不成立”的工程噪音 + +要做的事: +1. 明确 `IceCredentialType::Oauth` 是实现还是提前报错 +2. 明确 `bundle_policy` 是真正实现还是文档收敛 +3. 检查其它仅停留在配置层的字段是否需要降级处理 + +建议修改位置: +- `src/config.rs` +- `src/peer_connection.rs` +- `src/transports/ice/turn.rs` + +验收标准: +- 用户不会再通过公开配置误判支持范围 + +依赖: +- 可与步骤 6 并行 + +### 步骤 8: 修正 TURN/TCP/TLS,并明确 ICE-TCP 策略 + +目标: +- 让复杂网络回退路径具备明确、真实的支持边界 + +要做的事: +1. 保证 TURN over TCP/TLS 可用 +2. 修正 `probe_stun()` 的 TCP 伪实现 +3. 修正 relay candidate `transport` 语义 +4. 增加 `turn:?transport=tcp` 与 `turns:` 测试 +5. 明确是否实现 ICE-TCP + +决策建议: +1. 如果近期目标是浏览器公网互通,优先完成 TURN/TCP/TLS +2. 如果目标包含企业内网 TCP 直连,再启动 ICE-TCP + +若选择实现 ICE-TCP: +1. 为 candidate 增加 `tcptype` +2. 扩展 SDP `to_sdp()/from_sdp()` +3. 增加 TCP host candidate gather +4. 增加 TCP connectivity check + +建议修改位置: +- `src/transports/ice/mod.rs` +- `src/transports/ice/turn.rs` +- `src/sdp.rs` + +验收标准: +- 仅依赖 TURN/TCP/TLS 时仍可建连 +- 不会再出现 server transport 与 candidate transport 语义错位 + +### 步骤 9: 补齐媒体默认链路与拥塞控制闭环 + +目标: +- 让默认视频能力和控制反馈从“半实现”变成“可用闭环” + +要做的事: +1. 补 `VP8 depacketizer` +2. 对齐默认发送与接收的视频 codec 组合 +3. 明确 H264 默认能力是否保留 +4. 将 REMB 从“只解析”推进到实际控制 +5. 将 TWCC 从“报文结构存在”推进到完整闭环 + +建议修改位置: +- `src/media/depacketizer.rs` +- `src/media/packetizer.rs` +- `src/config.rs` +- `src/peer_connection.rs` +- `src/rtp.rs` + +验收标准: +- VP8 默认视频双向收发通过 +- H264 若保留能力广告,则存在对应收发链路 +- REMB/TWCC 能影响发送侧行为,而不是只打印日志 + +依赖: +- 建议在步骤 5 完成后推进 + +### 步骤 10: 增加 VP9 / H.265 扩展 + +目标: +- 在现有链路收敛后,再增加现代视频 codec + +要做的事: +1. 为 `VP9` 增加 `rtpmap/fmtp` 协商与收发链路 +2. 为 `H.265` 增加显式启用开关 +3. 新增 `Vp9Payloader` / `Vp9Depacketizer` +4. 新增 `H265Payloader` / `H265Depacketizer` +5. 按协商结果动态选择 depacketizer +6. 明确 fallback: + - `VP9` 回退到 `VP8/H264` + - `H.265` 只在显式启用且对端支持时启用 + +建议修改位置: +- `src/config.rs` +- `src/peer_connection.rs` +- `src/media/packetizer.rs` +- `src/media/depacketizer.rs` +- 如实现复杂度上升,可新增 `src/media/vp9.rs` +- 如实现复杂度上升,可新增 `src/media/h265.rs` + +验收标准: +- `VP9 only` +- `H.265 only` +- `VP8 + VP9` fallback +- `H264 + H.265` fallback +- reinvite 中 codec 切换不回归 + +依赖: +- 必须在步骤 5 基本完成后推进 + +### 步骤 11: 补齐 stats、观测与文档收尾 + +目标: +- 让系统具备可维护性和上线后的排障能力 + +要做的事: +1. 产出 `Transport` stats +2. 产出 `IceCandidatePair` stats +3. 产出 `DataChannel` stats +4. 补齐 RTT 等未完成统计 +5. 增加安全与异常事件计数: + - fingerprint mismatch + - SRTP replay reject + - SRTCP replay reject + - DTLS 重组超限 + - DataChannel 重组超限 + - TURN 认证失败 +6. 更新文档,使其与实现范围保持一致 + +建议修改位置: +- `src/stats.rs` +- `src/stats_collector.rs` +- 相关 transport / srtp / datachannel 模块 +- `docs/` + +验收标准: +- stats 类型与实际产出一致 +- 关键异常路径可通过统计观测 +- 文档不再出现配置声明与运行时能力错位 + +## 5. 并行关系与依赖顺序 + +建议严格按以下依赖推进: + +1. 步骤 1 +2. 步骤 2 和 步骤 3 +3. 步骤 4 +4. 步骤 5 +5. 步骤 6 和 步骤 7 +6. 步骤 8 +7. 步骤 9 +8. 步骤 10 +9. 步骤 11 + +可并行部分: + +- 步骤 2 与 步骤 3 +- 步骤 6 与 步骤 7 +- 步骤 11 中的文档整理可与后期开发并行 + +不建议提前做的项: + +- 在步骤 5 之前推进 `VP9 / H.265` +- 在步骤 8 尚未收敛前承诺 `ICE-TCP` +- 在步骤 2 和步骤 3 之前宣称项目具备公网试运行条件 + +## 6. 最小验收矩阵 + +浏览器互通最小矩阵: + +1. Chrome: + - 音频 + - 视频 + - DataChannel + - re-invite + - ICE restart + - TURN/UDP + - TURN/TCP + - TURN/TLS +2. Firefox: + - 音频 + - 视频 + - DataChannel + - re-invite + - ICE restart + - TURN/UDP + - TURN/TCP + - TURN/TLS + +负向与安全矩阵: + +1. fingerprint mismatch +2. oversized DTLS fragment +3. oversized DataChannel message +4. SRTP replay +5. SRTCP replay +6. rollback +7. `pranswer -> answer` +8. TURN 错误凭据 +9. 异常 ICE candidate 输入 + +codec 矩阵: + +1. Opus +2. VP8 +3. H264 +4. VP9 +5. H.265 +6. codec fallback + +## 7. 建议的 issue 拆分 + +建议直接按以下工作包拆任务: + +1. `baseline/regression-safety-net` +2. `security/srtp-replay-window` +3. `security/dtls-sctp-buffer-limits` +4. `signaling/pranswer-rollback` +5. `media/codec-runtime-model` +6. `config/certificate-plumbing` +7. `interop/datachannel-default-ordering` +8. `config/remove-misleading-api-surface` +9. `network/turn-tcp-tls-fix` +10. `network/ice-tcp-decision` +11. `media/default-video-path-alignment` +12. `cc/remb-twcc-closure` +13. `media/vp9-support` +14. `media/h265-support` +15. `stats/transport-and-datachannel` +16. `ops/security-observability` +17. `docs/implementation-scope-sync` + +## 8. 里程碑完成定义 + +### 可考虑公网试运行 + +至少同时满足: + +1. 步骤 2 完成 +2. 步骤 3 完成 +3. 步骤 4 完成 +4. 步骤 6 完成 +5. 至少一组安全负向测试进入固定回归 + +### 可称为“浏览器级可用主路径” + +至少同时满足: + +1. 步骤 5 完成 +2. 步骤 8 完成 +3. 步骤 9 完成 +4. Chrome / Firefox 主路径互通通过 + +### 可称为“具备持续维护条件” + +至少同时满足: + +1. 步骤 11 完成 +2. issue 和文档同步维护 +3. 公开 API 与实现边界一致 + +## 9. 结论 + +`rustrtc` 当前最需要的不是继续扩 API,而是把已经存在的协议栈能力收敛成可靠、可验证、可维护的实现。 + +更新顺序上,应优先保证: + +1. 安全闭环 +2. 规范关键路径 +3. 网络与媒体复杂场景 +4. 高级 codec 扩展 +5. 工程化收尾 + +只有按这个顺序推进,后续 `VP9 / H.265`、复杂网络回退和更大规模互操作测试才不会建立在不稳定基线上。 From 837ca75da08a8d32db42f6c2baae02a55ed6ec38 Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 21:30:50 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20ISSUE-01=20=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=9B=9E=E5=BD=92=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-01 baseline/regression-safety-net Summary: - 新增 regression_baseline smoke test,覆盖 security、signaling、datachannel、network、media、stats 六类关键入口 - 新增 scripts/run_regression_group.sh,提供分组与 all 模式的统一本地回归入口 - 将 DTLS、reinvite、ordered channel、TURN、simulcast、media flow 等现有关键测试纳入固定分组映射 Files: - tests/regression_baseline.rs - scripts/run_regression_group.sh Verification: - cargo fmt --all - cargo test --test regression_baseline - bash -n scripts/run_regression_group.sh - ./scripts/run_regression_group.sh all Result: - pass Risk: - stats 组当前仍以 smoke test 为主,真实统计项覆盖要在后续 stats issue 中继续补齐 - 分组脚本当前是本地执行入口,CI 映射仍可在后续单独收敛 Follow-up: - 继续执行 ISSUE-02 security/srtp-replay-window - 如需进入 CI,可把同一分组映射迁移到 workflow job --- scripts/run_regression_group.sh | 72 ++++++++++++++++++++ tests/regression_baseline.rs | 114 ++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100755 scripts/run_regression_group.sh create mode 100644 tests/regression_baseline.rs diff --git a/scripts/run_regression_group.sh b/scripts/run_regression_group.sh new file mode 100755 index 0000000..a65d186 --- /dev/null +++ b/scripts/run_regression_group.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +GROUP="${1:-all}" + +run_cmd() { + echo "+ $*" + ( + cd "${REPO_ROOT}" + "$@" + ) +} + +# Keep these groups aligned with docs/rustrtc-issue-task-checklist.md so the +# script doubles as the local execution map for ISSUE-01 and later protocol work. +run_group() { + case "$1" in + security) + run_cmd cargo test --test regression_baseline regression_security_entrypoints_exist + run_cmd cargo test --lib test_dtls_handshake_full_flow + run_cmd cargo test --lib test_dtls_handshake_fails_on_fingerprint_mismatch + ;; + signaling) + run_cmd cargo test --test regression_baseline regression_signaling_entrypoints_exist + run_cmd cargo test --test rtp_reinvite_comprehensive_test + ;; + datachannel) + run_cmd cargo test --test regression_baseline regression_datachannel_entrypoints_exist + run_cmd cargo test --test ordered_channel_test + run_cmd cargo test --test interop_datachannel + ;; + network) + run_cmd cargo test --test regression_baseline regression_network_entrypoints_exist + run_cmd cargo test --test interop_turn + ;; + media) + run_cmd cargo test --test regression_baseline regression_media_entrypoints_exist + run_cmd cargo test --test media_flow + run_cmd cargo test --test interop_simulcast + ;; + stats) + run_cmd cargo test --test regression_baseline regression_stats_entrypoints_exist + ;; + *) + echo "unknown group: $1" >&2 + exit 1 + ;; + esac +} + +case "${GROUP}" in + list) + printf '%s\n' security signaling datachannel network media stats all + ;; + all) + run_cmd cargo check + run_cmd cargo check --tests --examples + for group in security signaling datachannel network media stats; do + run_group "${group}" + done + ;; + security|signaling|datachannel|network|media|stats) + run_group "${GROUP}" + ;; + *) + echo "usage: $(basename "$0") [security|signaling|datachannel|network|media|stats|all|list]" >&2 + exit 1 + ;; +esac diff --git a/tests/regression_baseline.rs b/tests/regression_baseline.rs new file mode 100644 index 0000000..a1158ff --- /dev/null +++ b/tests/regression_baseline.rs @@ -0,0 +1,114 @@ +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use rustrtc::media::{MediaKind, MediaStreamTrack, sample_track}; +use rustrtc::stats::{DynProvider, StatsEntry, StatsId, StatsKind, StatsProvider, gather_once}; +use rustrtc::transports::datachannel::DataChannelState; +use rustrtc::transports::dtls; +use rustrtc::{ + IceTransport, IceTransportState, PeerConnection, RtcConfiguration, SdpType, SessionDescription, + SignalingState, +}; + +struct StaticStatsProvider; + +#[async_trait] +impl StatsProvider for StaticStatsProvider { + async fn collect(&self) -> rustrtc::RtcResult> { + Ok(vec![ + StatsEntry::new(StatsId::new("baseline-transport"), StatsKind::Transport) + .with_value("state", json!("new")), + ]) + } +} + +// These smoke tests intentionally stay shallow: they exist to catch deleted or +// accidentally disconnected public entrypoints before deeper interop suites run. +#[test] +fn regression_security_entrypoints_exist() { + let cert = dtls::generate_certificate().expect("certificate generation should remain wired"); + let fingerprint = dtls::fingerprint(&cert); + assert!( + !fingerprint.is_empty(), + "DTLS fingerprint generation should remain available" + ); + + let sdp = "v=0\r\n\ +o=- 1 1 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=fingerprint:sha-256 aa:bb:cc:dd\r\n\ +m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\ +a=mid:0\r\n"; + let desc = SessionDescription::parse(SdpType::Offer, sdp) + .expect("SDP parsing should keep supporting DTLS fingerprints"); + let parsed = desc + .dtls_fingerprint() + .expect("fingerprint extraction should remain available") + .expect("fingerprint should be present in the baseline SDP"); + assert_eq!(parsed.algorithm, "sha-256"); + assert_eq!(parsed.value, "AA:BB:CC:DD"); +} + +#[tokio::test] +async fn regression_signaling_entrypoints_exist() { + let pc = PeerConnection::new(RtcConfiguration::default()); + + assert_eq!(pc.signaling_state(), SignalingState::Stable); + assert!(pc.local_description().is_none()); + assert!(pc.remote_description().is_none()); + + let state_rx = pc.subscribe_signaling_state(); + assert_eq!(*state_rx.borrow(), SignalingState::Stable); + + pc.close(); +} + +#[tokio::test] +async fn regression_datachannel_entrypoints_exist() { + let pc = PeerConnection::new(RtcConfiguration::default()); + let data_channel = pc + .create_data_channel("baseline", None) + .expect("data channel creation should remain available"); + + assert_eq!(data_channel.label, "baseline"); + assert_eq!( + data_channel.state.load(Ordering::SeqCst), + DataChannelState::Connecting as usize + ); + + pc.close(); +} + +#[test] +fn regression_network_entrypoints_exist() { + let (transport, _runner) = IceTransport::new(RtcConfiguration::default()); + let params = transport.local_parameters(); + + assert_eq!(transport.state(), IceTransportState::New); + assert!(!params.username_fragment.is_empty()); + assert!(!params.password.is_empty()); +} + +#[test] +fn regression_media_entrypoints_exist() { + let (source, track, _feedback_rx) = sample_track(MediaKind::Audio, 4); + + assert_eq!(source.kind(), MediaKind::Audio); + assert_eq!(track.kind(), MediaKind::Audio); + assert_eq!(track.id(), source.id()); + assert!(!track.id().is_empty()); +} + +#[tokio::test] +async fn regression_stats_entrypoints_exist() { + let provider: Arc = Arc::new(StaticStatsProvider); + let report = gather_once(&[provider]) + .await + .expect("stats gathering should remain available"); + + assert_eq!(report.entries.len(), 1); + assert_eq!(report.entries[0].kind, StatsKind::Transport); +} From 50a8d757f770185ff08bcc4ab8a2781ddc3510c7 Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 21:42:58 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20SRTP/SRTCP=20replay?= =?UTF-8?q?=20protection=20=E4=B8=8E=E6=8B=92=E7=BB=9D=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-02 security/srtp-replay-window Summary: - 为 RTP 和 SRTCP 增加 64 包 replay window,拒绝重复包和窗口外旧包 - 为 replay reject 增加明确错误类型,并在 RtpTransport 中输出日志 - 将 SRTP/SRTCP replay reject 计入 StatsCollector 的 Transport 统计项 - 新增 replay window 和本地集成测试覆盖乱序、重复包和过旧包场景 Files: - src/errors.rs - src/peer_connection.rs - src/srtp.rs - src/stats_collector.rs - src/transports/rtp.rs - tests/srtp_replay_window.rs - tests/srtp_replay_integration.rs Verification: - cargo fmt --all - cargo test --test srtp_replay_window - cargo test --test srtp_replay_integration - cargo test --lib protect_and_unprotect_roundtrip - cargo test --lib roc_rollover_reordered - cargo test --lib test_stats_collector_replay_rejects Result: - pass Risk: - SRTCP index wraparound 的长期行为仍未单独覆盖 Follow-up: - 继续 ISSUE-03 dtls-sctp-buffer-limits --- src/errors.rs | 10 +++ src/peer_connection.rs | 1 + src/srtp.rs | 78 ++++++++++++++++++------ src/stats_collector.rs | 66 +++++++++++++++++++- src/transports/rtp.rs | 31 +++++++++- tests/srtp_replay_integration.rs | 94 ++++++++++++++++++++++++++++ tests/srtp_replay_window.rs | 101 +++++++++++++++++++++++++++++++ 7 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 tests/srtp_replay_integration.rs create mode 100644 tests/srtp_replay_window.rs diff --git a/src/errors.rs b/src/errors.rs index b8db6a6..c398c85 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -49,10 +49,20 @@ pub enum SrtpError { PacketTooShort, #[error("SRTP authentication failed")] AuthenticationFailed, + #[error("SRTP replay detected")] + ReplayDetected, + #[error("SRTP packet is outside the replay window")] + PacketTooOld, #[error("SRTP internal error: {0}")] Internal(String), } +impl SrtpError { + pub fn is_replay_related(&self) -> bool { + matches!(self, Self::ReplayDetected | Self::PacketTooOld) + } +} + impl From for SrtpError { fn from(value: RtpError) -> Self { SrtpError::Internal(value.to_string()) diff --git a/src/peer_connection.rs b/src/peer_connection.rs index 1c34297..fab1c1c 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -1318,6 +1318,7 @@ impl PeerConnection { srtp_required, allow_ssrc_change, )); + rtp_transport.attach_stats_collector(self.inner.stats_collector.clone()); { let mut rx = ice_conn.rtp_receiver.write().unwrap(); *rx = Some(Arc::downgrade(&rtp_transport) diff --git a/src/srtp.rs b/src/srtp.rs index 7892b39..bbe73d8 100644 --- a/src/srtp.rs +++ b/src/srtp.rs @@ -171,6 +171,47 @@ struct SessionKeys { salt: Vec, } +#[derive(Debug, Clone, Default)] +struct ReplayWindow { + max_index: Option, + bitmap: u64, +} + +impl ReplayWindow { + fn check_and_accept(&mut self, index: u64) -> SrtpResult<()> { + let Some(max_index) = self.max_index else { + self.max_index = Some(index); + self.bitmap = 1; + return Ok(()); + }; + + if index > max_index { + let delta = index - max_index; + // Jumping past the full bitmap discards all older history at once. + self.bitmap = if delta >= u64::BITS as u64 { + 1 + } else { + (self.bitmap << (delta as u32)) | 1 + }; + self.max_index = Some(index); + return Ok(()); + } + + let delta = max_index - index; + if delta >= u64::BITS as u64 { + return Err(SrtpError::PacketTooOld); + } + + let mask = 1u64 << (delta as u32); + if self.bitmap & mask != 0 { + return Err(SrtpError::ReplayDetected); + } + + self.bitmap |= mask; + Ok(()) + } +} + #[derive(Clone)] pub struct SrtpContext { ssrc: u32, @@ -183,7 +224,9 @@ pub struct SrtpContext { direction: SrtpDirection, rollover_counter: u32, last_sequence: Option, - rtcp_index: u32, + rtp_replay_window: ReplayWindow, + next_rtcp_index: u32, + rtcp_replay_window: ReplayWindow, } impl fmt::Debug for SrtpContext { @@ -250,7 +293,9 @@ impl SrtpContext { direction, rollover_counter: 0, last_sequence: None, - rtcp_index: 0, + rtp_replay_window: ReplayWindow::default(), + next_rtcp_index: 0, + rtcp_replay_window: ReplayWindow::default(), }) } @@ -322,8 +367,8 @@ impl SrtpContext { } pub fn protect_rtcp(&mut self, packet: &mut Vec) -> SrtpResult<()> { - self.rtcp_index += 1; - let index = self.rtcp_index; + self.next_rtcp_index += 1; + let index = self.next_rtcp_index; // E-bit = 1 (Encrypted) let index_with_e = index | 0x8000_0000; @@ -392,11 +437,6 @@ impl SrtpContext { ]); let index = index_with_e & 0x7FFF_FFFF; - // Replay check - if index > self.rtcp_index { - self.rtcp_index = index; - } - let nonce = self.build_gcm_rtcp_nonce(index); let cipher = self .rtcp_gcm_cipher @@ -425,6 +465,7 @@ impl SrtpContext { // Reconstruct packet: Header || Plaintext packet.truncate(8); packet.extend_from_slice(&plaintext); + self.rtcp_replay_window.check_and_accept(index as u64)?; return Ok(()); } @@ -453,16 +494,12 @@ impl SrtpContext { let e_bit = (index_with_e & 0x8000_0000) != 0; let index = index_with_e & 0x7FFF_FFFF; - // Replay check (simplified: just check if index is newer than last seen?) - // For now, we just update. - if index > self.rtcp_index { - self.rtcp_index = index; - } - if e_bit && packet.len() > 8 { self.cipher_rtcp(packet, index)?; } + self.rtcp_replay_window.check_and_accept(index as u64)?; + Ok(()) } @@ -549,6 +586,7 @@ impl SrtpContext { } let roc = self.estimate_roc(packet.header.sequence_number); + let packet_index = Self::packet_index(packet.header.sequence_number, roc); if let SrtpProfile::AeadAes128Gcm = self._profile { let nonce = self.build_gcm_nonce(packet.header.sequence_number, roc); @@ -572,6 +610,7 @@ impl SrtpContext { .map_err(|_| SrtpError::AuthenticationFailed)?; packet.payload = plaintext; + self.rtp_replay_window.check_and_accept(packet_index)?; self.update(packet.header.sequence_number, roc); return Ok(()); } @@ -585,6 +624,7 @@ impl SrtpContext { return Err(SrtpError::AuthenticationFailed); } self.cipher_payload(packet, roc)?; + self.rtp_replay_window.check_and_accept(packet_index)?; self.update(packet.header.sequence_number, roc); Ok(()) } @@ -651,7 +691,7 @@ impl SrtpContext { } fn build_iv(&self, sequence: u16, roc: u32) -> [u8; 16] { - let index = ((roc as u64) << 16) | sequence as u64; + let index = Self::packet_index(sequence, roc); let mut iv = [0u8; 16]; for (i, byte) in self.rtp_keys.salt.iter().enumerate().take(14) { iv[i] = *byte; @@ -670,6 +710,10 @@ impl SrtpContext { iv } + fn packet_index(sequence: u16, roc: u32) -> u64 { + ((roc as u64) << 16) | sequence as u64 + } + fn estimate_roc(&self, sequence: u16) -> u32 { let Some(last_seq) = self.last_sequence else { return self.rollover_counter; @@ -696,7 +740,7 @@ impl SrtpContext { let current_index = ((self.rollover_counter as u64) << 16) | (self.last_sequence.unwrap() as u64); - let new_index = ((roc as u64) << 16) | (sequence as u64); + let new_index = Self::packet_index(sequence, roc); if new_index > current_index { self.rollover_counter = roc; diff --git a/src/stats_collector.rs b/src/stats_collector.rs index c691694..b678439 100644 --- a/src/stats_collector.rs +++ b/src/stats_collector.rs @@ -1,4 +1,4 @@ -use crate::errors::RtcResult; +use crate::errors::{RtcResult, SrtpError}; use crate::peer_connection::{RtpReceiverInterceptor, RtpSenderInterceptor}; use crate::rtp::{ReceiverReport, RtcpPacket, RtpPacket, SenderReport}; use crate::stats::{StatsEntry, StatsId, StatsKind, StatsProvider}; @@ -82,12 +82,30 @@ impl Default for LocalOutboundStats { } } +#[derive(Debug, Clone, Default)] +struct ReplayRejectStats { + rtp_duplicates: u64, + rtp_too_old: u64, + rtcp_duplicates: u64, + rtcp_too_old: u64, +} + +impl ReplayRejectStats { + fn has_rejects(&self) -> bool { + self.rtp_duplicates != 0 + || self.rtp_too_old != 0 + || self.rtcp_duplicates != 0 + || self.rtcp_too_old != 0 + } +} + #[derive(Default)] pub struct StatsCollector { remote_inbound: Mutex>, remote_outbound: Mutex>, local_inbound: Mutex>, local_outbound: Mutex>, + replay_rejects: Mutex, } impl StatsCollector { @@ -103,6 +121,17 @@ impl StatsCollector { } } + pub fn record_srtp_replay_reject(&self, is_rtcp: bool, error: &SrtpError) { + let mut rejects = self.replay_rejects.lock().unwrap(); + match (is_rtcp, error) { + (false, SrtpError::ReplayDetected) => rejects.rtp_duplicates += 1, + (false, SrtpError::PacketTooOld) => rejects.rtp_too_old += 1, + (true, SrtpError::ReplayDetected) => rejects.rtcp_duplicates += 1, + (true, SrtpError::PacketTooOld) => rejects.rtcp_too_old += 1, + _ => {} + } + } + fn handle_sr(&self, sr: &SenderReport) { { let mut outbound = self.remote_outbound.lock().unwrap(); @@ -245,6 +274,22 @@ impl StatsProvider for StatsCollector { } } + { + let rejects = self.replay_rejects.lock().unwrap(); + if rejects.has_rejects() { + let entry = + StatsEntry::new(StatsId::new("transport-security"), StatsKind::Transport) + .with_value("srtpReplayRejectDuplicates", json!(rejects.rtp_duplicates)) + .with_value("srtpReplayRejectTooOld", json!(rejects.rtp_too_old)) + .with_value( + "srtcpReplayRejectDuplicates", + json!(rejects.rtcp_duplicates), + ) + .with_value("srtcpReplayRejectTooOld", json!(rejects.rtcp_too_old)); + entries.push(entry); + } + } + Ok(entries) } } @@ -335,4 +380,23 @@ mod tests { assert_eq!(inbound.values["packetsReceived"], 1); assert_eq!(inbound.values["bytesReceived"], 112); } + + #[tokio::test] + async fn test_stats_collector_replay_rejects() { + let collector = StatsCollector::new(); + collector.record_srtp_replay_reject(false, &SrtpError::ReplayDetected); + collector.record_srtp_replay_reject(false, &SrtpError::PacketTooOld); + collector.record_srtp_replay_reject(true, &SrtpError::ReplayDetected); + + let stats = collector.collect().await.unwrap(); + let transport = stats + .iter() + .find(|entry| entry.kind == StatsKind::Transport) + .unwrap(); + + assert_eq!(transport.values["srtpReplayRejectDuplicates"], 1); + assert_eq!(transport.values["srtpReplayRejectTooOld"], 1); + assert_eq!(transport.values["srtcpReplayRejectDuplicates"], 1); + assert_eq!(transport.values["srtcpReplayRejectTooOld"], 0); + } } diff --git a/src/transports/rtp.rs b/src/transports/rtp.rs index fbf09f2..9ffe8e4 100644 --- a/src/transports/rtp.rs +++ b/src/transports/rtp.rs @@ -1,5 +1,7 @@ +use crate::errors::SrtpError; use crate::rtp::{RtcpPacket, RtpPacket, is_rtcp, marshal_rtcp_packets, parse_rtcp_packets}; use crate::srtp::SrtpSession; +use crate::stats_collector::StatsCollector; use crate::transports::PacketReceiver; use crate::transports::ice::conn::IceConn; use anyhow::Result; @@ -20,6 +22,7 @@ pub struct RtpTransport { provisional_listener: Mutex>>, rid_extension_id: Mutex>, abs_send_time_extension_id: Mutex>, + stats_collector: Mutex>>, srtp_required: bool, } @@ -43,6 +46,7 @@ impl RtpTransport { provisional_listener: Mutex::new(None), rid_extension_id: Mutex::new(None), abs_send_time_extension_id: Mutex::new(None), + stats_collector: Mutex::new(None), srtp_required, // allow_ssrc_change, // pt_to_ssrc: Mutex::new(HashMap::new()), @@ -59,6 +63,11 @@ impl RtpTransport { *session = Some(Arc::new(Mutex::new(srtp_session))); } + pub fn attach_stats_collector(&self, stats_collector: Arc) { + let mut collector = self.stats_collector.lock().unwrap(); + *collector = Some(stats_collector); + } + pub fn register_listener_sync(&self, ssrc: u32, tx: mpsc::Sender<(RtpPacket, SocketAddr)>) { let mut listeners = self.listeners.lock().unwrap(); listeners.insert(ssrc, tx); @@ -200,6 +209,13 @@ impl RtpTransport { count } + + fn record_srtp_replay_reject(&self, is_rtcp: bool, error: &SrtpError) { + let collector = self.stats_collector.lock().unwrap().clone(); + if let Some(collector) = collector { + collector.record_srtp_replay_reject(is_rtcp, error); + } + } } #[async_trait] @@ -216,7 +232,12 @@ impl PacketReceiver for RtpTransport { match srtp.unprotect_rtcp(&mut buf) { Ok(_) => buf, Err(e) => { - tracing::warn!("SRTP unprotect RTCP failed: {}", e); + self.record_srtp_replay_reject(true, &e); + if e.is_replay_related() { + tracing::warn!("SRTP replay rejected RTCP packet: {}", e); + } else { + tracing::warn!("SRTP unprotect RTCP failed: {}", e); + } return; } } @@ -230,7 +251,13 @@ impl PacketReceiver for RtpTransport { return; } }, - Err(_) => { + Err(e) => { + self.record_srtp_replay_reject(false, &e); + if e.is_replay_related() { + tracing::warn!("SRTP replay rejected RTP packet: {}", e); + } else { + tracing::debug!("SRTP unprotect RTP failed: {}", e); + } return; } }, diff --git a/tests/srtp_replay_integration.rs b/tests/srtp_replay_integration.rs new file mode 100644 index 0000000..d82847e --- /dev/null +++ b/tests/srtp_replay_integration.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use rustrtc::errors::SrtpError; +use rustrtc::rtp::{PictureLossIndication, RtcpPacket, RtpHeader, RtpPacket, marshal_rtcp_packets}; +use rustrtc::stats::{DynProvider, StatsKind, gather_once}; +use rustrtc::stats_collector::StatsCollector; +use rustrtc::{SrtpKeyingMaterial, SrtpProfile, SrtpSession}; +use std::sync::Arc; + +fn material() -> SrtpKeyingMaterial { + SrtpKeyingMaterial::new(vec![0; 16], vec![0; 14]) +} + +fn sample_rtp(seq: u16) -> RtpPacket { + let header = RtpHeader::new(96, seq, 1234, 0xdead_beef); + RtpPacket::new(header, vec![1, 2, 3, 4]) +} + +fn sample_rtcp() -> Vec { + marshal_rtcp_packets(&[RtcpPacket::PictureLossIndication(PictureLossIndication { + sender_ssrc: 1234, + media_ssrc: 5678, + })]) + .expect("marshal rtcp packet") +} + +fn sessions() -> Result<(SrtpSession, SrtpSession)> { + let keying = material(); + Ok(( + SrtpSession::new(SrtpProfile::Aes128Sha1_80, keying.clone(), keying.clone())?, + SrtpSession::new(SrtpProfile::Aes128Sha1_80, keying.clone(), keying.clone())?, + )) +} + +#[test] +fn local_srtp_sessions_accept_reordered_packets_and_reject_duplicates() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + + let mut packet_100 = sample_rtp(100); + let mut packet_101 = sample_rtp(101); + let mut packet_102 = sample_rtp(102); + sender.protect_rtp(&mut packet_100)?; + sender.protect_rtp(&mut packet_101)?; + sender.protect_rtp(&mut packet_102)?; + + let mut duplicate = packet_100.clone(); + receiver.unprotect_rtp(&mut packet_100)?; + receiver.unprotect_rtp(&mut packet_102)?; + receiver.unprotect_rtp(&mut packet_101)?; + + let duplicate_err = receiver.unprotect_rtp(&mut duplicate).unwrap_err(); + assert_eq!(duplicate_err, SrtpError::ReplayDetected); + Ok(()) +} + +#[tokio::test] +async fn stats_report_includes_replay_reject_counters() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + let collector = StatsCollector::new(); + + let mut late_packet = sample_rtp(1); + sender.protect_rtp(&mut late_packet)?; + for seq in 2..=65 { + let mut packet = sample_rtp(seq); + sender.protect_rtp(&mut packet)?; + receiver.unprotect_rtp(&mut packet)?; + } + + let late_err = receiver.unprotect_rtp(&mut late_packet).unwrap_err(); + collector.record_srtp_replay_reject(false, &late_err); + assert_eq!(late_err, SrtpError::PacketTooOld); + + let mut rtcp = sample_rtcp(); + sender.protect_rtcp(&mut rtcp)?; + let mut rtcp_duplicate = rtcp.clone(); + receiver.unprotect_rtcp(&mut rtcp)?; + + let rtcp_err = receiver.unprotect_rtcp(&mut rtcp_duplicate).unwrap_err(); + collector.record_srtp_replay_reject(true, &rtcp_err); + assert_eq!(rtcp_err, SrtpError::ReplayDetected); + + let provider: Arc = Arc::new(collector); + let report = gather_once(&[provider]).await?; + let transport = report + .entries + .iter() + .find(|entry| entry.kind == StatsKind::Transport) + .expect("transport stats entry with replay counters"); + + assert_eq!(transport.values["srtpReplayRejectDuplicates"], 0); + assert_eq!(transport.values["srtpReplayRejectTooOld"], 1); + assert_eq!(transport.values["srtcpReplayRejectDuplicates"], 1); + assert_eq!(transport.values["srtcpReplayRejectTooOld"], 0); + Ok(()) +} diff --git a/tests/srtp_replay_window.rs b/tests/srtp_replay_window.rs new file mode 100644 index 0000000..619e83b --- /dev/null +++ b/tests/srtp_replay_window.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use rustrtc::errors::SrtpError; +use rustrtc::rtp::{PictureLossIndication, RtcpPacket, RtpHeader, RtpPacket, marshal_rtcp_packets}; +use rustrtc::{SrtpKeyingMaterial, SrtpProfile, SrtpSession}; + +fn material() -> SrtpKeyingMaterial { + SrtpKeyingMaterial::new(vec![0; 16], vec![0; 14]) +} + +fn sample_rtp(seq: u16) -> RtpPacket { + let header = RtpHeader::new(96, seq, 1234, 0xdead_beef); + RtpPacket::new(header, vec![1, 2, 3, 4]) +} + +fn sample_rtcp() -> Vec { + marshal_rtcp_packets(&[RtcpPacket::PictureLossIndication(PictureLossIndication { + sender_ssrc: 1234, + media_ssrc: 5678, + })]) + .expect("marshal rtcp packet") +} + +fn sessions() -> Result<(SrtpSession, SrtpSession)> { + let keying = material(); + Ok(( + SrtpSession::new(SrtpProfile::Aes128Sha1_80, keying.clone(), keying.clone())?, + SrtpSession::new(SrtpProfile::Aes128Sha1_80, keying.clone(), keying.clone())?, + )) +} + +#[test] +fn duplicate_rtp_packet_rejected() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + let mut packet = sample_rtp(100); + sender.protect_rtp(&mut packet)?; + + let mut duplicate = packet.clone(); + receiver.unprotect_rtp(&mut packet)?; + + let err = receiver.unprotect_rtp(&mut duplicate).unwrap_err(); + assert_eq!(err, SrtpError::ReplayDetected); + Ok(()) +} + +#[test] +fn out_of_order_rtp_packet_within_window_accepted() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + + let mut first = sample_rtp(10); + let mut second = sample_rtp(11); + let mut third = sample_rtp(12); + + sender.protect_rtp(&mut first)?; + sender.protect_rtp(&mut second)?; + sender.protect_rtp(&mut third)?; + + receiver.unprotect_rtp(&mut first)?; + receiver.unprotect_rtp(&mut third)?; + receiver.unprotect_rtp(&mut second)?; + + assert_eq!(first.payload, vec![1, 2, 3, 4]); + assert_eq!(second.payload, vec![1, 2, 3, 4]); + assert_eq!(third.payload, vec![1, 2, 3, 4]); + Ok(()) +} + +#[test] +fn too_old_rtp_packet_rejected() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + let mut late_packet = sample_rtp(1); + sender.protect_rtp(&mut late_packet)?; + + let mut newer_packets = Vec::new(); + for seq in 2..=65 { + let mut packet = sample_rtp(seq); + sender.protect_rtp(&mut packet)?; + newer_packets.push(packet); + } + + for packet in &mut newer_packets { + receiver.unprotect_rtp(packet)?; + } + + let err = receiver.unprotect_rtp(&mut late_packet).unwrap_err(); + assert_eq!(err, SrtpError::PacketTooOld); + Ok(()) +} + +#[test] +fn duplicate_srtcp_packet_rejected() -> Result<()> { + let (mut sender, mut receiver) = sessions()?; + let mut packet = sample_rtcp(); + sender.protect_rtcp(&mut packet)?; + + let mut duplicate = packet.clone(); + receiver.unprotect_rtcp(&mut packet)?; + + let err = receiver.unprotect_rtcp(&mut duplicate).unwrap_err(); + assert_eq!(err, SrtpError::ReplayDetected); + Ok(()) +} From fa69e5b58e2595c3c8775a0acbb30225282f132e Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 22:00:34 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E4=B8=BA=20DTLS/SCTP=20=E9=87=8D?= =?UTF-8?q?=E7=BB=84=E9=93=BE=E8=B7=AF=E5=A2=9E=E5=8A=A0=E7=A1=AC=E4=B8=8A?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-03 security/dtls-sctp-buffer-limits Summary: - 将 DTLS handshake fragment 重组上限接入 dtls_buffer_size,并在 fragment 头范围非法或超限时直接失败握手 - 修正 HandshakeMessage 编码长度字段,使分片握手消息保留 total_length - 为 DataChannel 单消息重组增加 64KiB 上限,并为 ordered channel 待排序队列增加 128KiB 上限 - DataChannel 入站重组超限时清空缓冲并关闭触发问题的 channel,避免单 channel 持续占用内存 - 新增 DTLS 超限与 malformed fragment 测试,以及 DataChannel 接近阈值/超限的集成测试 Files: - src/transports/dtls/handshake.rs - src/transports/dtls/mod.rs - src/transports/dtls/tests.rs - src/transports/sctp.rs - tests/datachannel_reassembly_limits.rs - tests/security_malformed_buffers.rs Verification: - cargo fmt --all - cargo test dtls_fragment_reassembly_rejects_oversized_message - cargo test --test datachannel_reassembly_limits - cargo test --test security_malformed_buffers - cargo test test_handshake_message_encode_decode_fragment_header Result: - pass Risk: - 目前异常输入测试覆盖到 DTLS malformed fragment 和 DataChannel 超限路径,尚未单独补原始 SCTP DATA chunk 级别的恶意输入夹具 Follow-up: - 继续 ISSUE-04 signaling/pranswer-rollback --- src/transports/dtls/handshake.rs | 29 ++++- src/transports/dtls/mod.rs | 39 +++++- src/transports/dtls/tests.rs | 50 ++++++++ src/transports/sctp.rs | 70 +++++++++-- tests/datachannel_reassembly_limits.rs | 160 +++++++++++++++++++++++++ tests/security_malformed_buffers.rs | 92 ++++++++++++++ 6 files changed, 423 insertions(+), 17 deletions(-) create mode 100644 tests/datachannel_reassembly_limits.rs create mode 100644 tests/security_malformed_buffers.rs diff --git a/src/transports/dtls/handshake.rs b/src/transports/dtls/handshake.rs index 36c54c4..82b131f 100644 --- a/src/transports/dtls/handshake.rs +++ b/src/transports/dtls/handshake.rs @@ -54,9 +54,8 @@ impl HandshakeMessage { pub fn encode(&self, buf: &mut BytesMut) { buf.put_u8(self.msg_type as u8); - // Length (24-bit) - length of the body (not fragment) - // For simple non-fragmented messages, this is body.len() - let len = self.body.len() as u32; + // Length (24-bit) is the full handshake body length, not just this fragment. + let len = self.total_length; buf.put_u8((len >> 16) as u8); buf.put_u8((len >> 8) as u8); buf.put_u8(len as u8); @@ -579,12 +578,36 @@ mod tests { let decoded = HandshakeMessage::decode(&mut decode_buf).unwrap().unwrap(); assert_eq!(decoded.msg_type, msg.msg_type); + assert_eq!(decoded.total_length, msg.total_length); assert_eq!(decoded.message_seq, msg.message_seq); assert_eq!(decoded.fragment_offset, msg.fragment_offset); assert_eq!(decoded.fragment_length, msg.fragment_length); assert_eq!(decoded.body, msg.body); } + #[test] + fn test_handshake_message_encode_decode_fragment_header() { + let msg = HandshakeMessage { + msg_type: HandshakeType::ClientHello, + message_seq: 2, + fragment_offset: 8, + fragment_length: 4, + total_length: 12, + body: Bytes::from_static(b"frag"), + }; + + let mut buf = BytesMut::new(); + msg.encode(&mut buf); + + let mut decode_buf = buf.freeze(); + let decoded = HandshakeMessage::decode(&mut decode_buf).unwrap().unwrap(); + + assert_eq!(decoded.total_length, 12); + assert_eq!(decoded.fragment_offset, 8); + assert_eq!(decoded.fragment_length, 4); + assert_eq!(decoded.body, Bytes::from_static(b"frag")); + } + #[test] fn test_client_hello_encode() { let client_hello = ClientHello { diff --git a/src/transports/dtls/mod.rs b/src/transports/dtls/mod.rs index 86bde88..0d7fad1 100644 --- a/src/transports/dtls/mod.rs +++ b/src/transports/dtls/mod.rs @@ -175,6 +175,7 @@ struct DtlsInner { write_epoch: AtomicU16, is_client: bool, expected_remote_fingerprint: Option, + handshake_reassembly_limit: usize, } pub struct DtlsTransport { @@ -225,7 +226,7 @@ impl DtlsTransport { conn: Arc, certificate: Certificate, is_client: bool, - _buffer_size: usize, + buffer_size: usize, expected_remote_fingerprint: Option, ) -> Result<( Arc, @@ -246,6 +247,7 @@ impl DtlsTransport { write_epoch: AtomicU16::new(0), is_client, expected_remote_fingerprint, + handshake_reassembly_limit: buffer_size.max(1), }); let close_tx = Arc::new(tokio::sync::Notify::new()); @@ -390,6 +392,11 @@ impl Drop for DtlsTransport { } impl DtlsInner { + fn mark_failed(&self) { + *self.state.lock().unwrap() = DtlsState::Failed; + let _ = self.state_tx.send(DtlsState::Failed); + } + async fn handle_retransmit(&self, ctx: &HandshakeContext, _is_client: bool) { if *self.state.lock().unwrap() != DtlsState::Handshaking { return; @@ -585,6 +592,16 @@ impl DtlsInner { Ok(Some(msg)) => { let consumed = msg_buf.len() - body.len(); let raw_msg = msg_buf.slice(0..consumed); + let fragment_end = msg + .fragment_offset + .checked_add(msg.fragment_length) + .ok_or_else(|| anyhow::anyhow!("DTLS fragment range overflow"))?; + if fragment_end > msg.total_length { + self.mark_failed(); + return Err(anyhow::anyhow!( + "DTLS fragment range exceeds declared message length" + )); + } if msg.message_seq < ctx.recv_message_seq { // If we just processed a HelloVerifyRequest, the server may @@ -648,12 +665,32 @@ impl DtlsInner { let (processing_msg, processing_raw) = if msg.total_length != msg.fragment_length { + if msg.total_length as usize > self.handshake_reassembly_limit { + self.mark_failed(); + return Err(anyhow::anyhow!( + "DTLS handshake message exceeds reassembly limit: {} > {}", + msg.total_length, + self.handshake_reassembly_limit + )); + } + if ctx.incomplete_msg_seq != msg.message_seq || msg.fragment_offset == 0 { // New message or first fragment, reset buffer ctx.incomplete_handshake.clear(); ctx.incomplete_msg_seq = msg.message_seq; } + if ctx.incomplete_handshake.len() + msg.body.len() + > self.handshake_reassembly_limit + { + self.mark_failed(); + return Err(anyhow::anyhow!( + "DTLS handshake reassembly buffer exceeded limit: {} > {}", + ctx.incomplete_handshake.len() + msg.body.len(), + self.handshake_reassembly_limit + )); + } + ctx.incomplete_handshake.extend_from_slice(&msg.body[..]); if ctx.incomplete_handshake.len() < msg.total_length as usize { diff --git a/src/transports/dtls/tests.rs b/src/transports/dtls/tests.rs index 7ce4222..8a0b968 100644 --- a/src/transports/dtls/tests.rs +++ b/src/transports/dtls/tests.rs @@ -313,6 +313,56 @@ async fn test_dtls_handshake_fails_on_fingerprint_mismatch() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_dtls_fragment_reassembly_rejects_oversized_message() -> Result<()> { + let client_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + let server_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + + let client_addr = client_socket.local_addr()?; + let server_addr = server_socket.local_addr()?; + + let (server_socket_tx, _) = watch::channel(Some(IceSocketWrapper::Udp(server_socket.clone()))); + let server_conn = IceConn::new(server_socket_tx.subscribe(), client_addr); + + let cert = generate_certificate()?; + let (server_dtls, _server_rx, runner) = + DtlsTransport::new(server_conn.clone(), cert, false, 64, None).await?; + tokio::spawn(runner); + spawn_socket_pump(server_socket, server_conn); + + for (sequence_number, fragment_offset) in [(0, 0u32), (1, 48u32)] { + let fragment_body = Bytes::from(vec![0u8; 48]); + let handshake_msg = HandshakeMessage { + msg_type: HandshakeType::ClientHello, + total_length: 96, + message_seq: 0, + fragment_offset, + fragment_length: 48, + body: fragment_body, + }; + + let mut handshake_buf = BytesMut::new(); + handshake_msg.encode(&mut handshake_buf); + let record = DtlsRecord { + content_type: ContentType::Handshake, + version: ProtocolVersion::DTLS_1_2, + epoch: 0, + sequence_number, + payload: handshake_buf.freeze(), + }; + + let mut record_buf = BytesMut::new(); + record.encode(&mut record_buf); + client_socket.send_to(&record_buf, server_addr).await?; + } + + assert!(matches!( + wait_for_terminal_state(&server_dtls).await?, + DtlsState::Failed + )); + Ok(()) +} + #[test] fn test_verify_server_key_exchange_signature_rejects_tampering() -> Result<()> { let certificate = generate_certificate()?; diff --git a/src/transports/sctp.rs b/src/transports/sctp.rs index c9e746e..60f0112 100644 --- a/src/transports/sctp.rs +++ b/src/transports/sctp.rs @@ -36,6 +36,8 @@ const MAX_BUFFERED_AMOUNT: usize = 256 * 1024; // 256KB - reduced for lower memo // Memory limits for inbound queues - balanced for memory efficiency and loss tolerance // These values provide good memory efficiency while maintaining tolerance for packet loss const MAX_INBOUND_STREAM_PENDING: usize = 128; // max pending ordered messages per stream +const MAX_INBOUND_MESSAGE_REASSEMBLY_SIZE: usize = 64 * 1024; +const MAX_INBOUND_STREAM_BUFFER_SIZE: usize = 128 * 1024; const MAX_DUPS_BUFFER_SIZE: usize = 32; // max duplicate TSNs to track (increased for lossy networks) const MAX_RECEIVED_QUEUE_SIZE: usize = 512; // max out-of-order packets (increased for lossy networks) @@ -74,6 +76,7 @@ pub(crate) struct ChunkRecord { struct InboundStream { next_ssn: u16, pending: BTreeMap, + buffered_bytes: usize, } impl InboundStream { @@ -81,29 +84,23 @@ impl InboundStream { Self { next_ssn: 0, pending: BTreeMap::new(), + buffered_bytes: 0, } } fn enqueue(&mut self, ssn: u16, msg: Bytes) -> Vec { - // Limit pending queue size to prevent memory bloat - if self.pending.len() >= MAX_INBOUND_STREAM_PENDING { - // Drain any ready messages first - let ready = self.drain_ready(); - if !ready.is_empty() { - return ready; - } - // If still full, drop oldest pending message to prevent unbounded growth - if let Some(&oldest_ssn) = self.pending.keys().next() { - self.pending.remove(&oldest_ssn); - } + let msg_len = msg.len(); + if let Some(old_msg) = self.pending.insert(ssn, msg) { + self.buffered_bytes = self.buffered_bytes.saturating_sub(old_msg.len()); } - self.pending.insert(ssn, msg); + self.buffered_bytes += msg_len; self.drain_ready() } fn drain_ready(&mut self) -> Vec { let mut out = Vec::new(); while let Some(msg) = self.pending.remove(&self.next_ssn) { + self.buffered_bytes = self.buffered_bytes.saturating_sub(msg.len()); out.push(msg); self.next_ssn = self.next_ssn.wrapping_add(1); } @@ -121,7 +118,9 @@ impl InboundStream { .cloned() .collect(); for s in remove { - self.pending.remove(&s); + if let Some(msg) = self.pending.remove(&s) { + self.buffered_bytes = self.buffered_bytes.saturating_sub(msg.len()); + } } } } @@ -2325,6 +2324,29 @@ impl SctpInner { Ok(()) } + fn close_data_channel_for_reassembly_overflow( + &self, + dc: &Arc, + stream_id: u16, + reason: &str, + ) { + // Drop partially reassembled data before closing so malformed peers cannot pin memory. + dc.reassembly_buffer.lock().unwrap().clear(); + self.inbound_streams.lock().unwrap().remove(&stream_id); + + let old_state = dc + .state + .swap(DataChannelState::Closed as usize, Ordering::SeqCst); + if old_state != DataChannelState::Closed as usize { + debug!( + "Closing DataChannel {} because inbound reassembly exceeded limits: {}", + stream_id, reason + ); + dc.send_event(DataChannelEvent::Close); + dc.close_channel(); + } + } + async fn handle_data(&self, flags: u8, chunk: Bytes) -> Result<()> { let mut buf = chunk.clone(); if buf.remaining() < 12 { @@ -2475,6 +2497,17 @@ impl SctpInner { } buffer.clear(); } + + if buffer.len() + user_data.len() > MAX_INBOUND_MESSAGE_REASSEMBLY_SIZE { + drop(buffer); + self.close_data_channel_for_reassembly_overflow( + &dc, + stream_id, + "single message exceeded the inbound reassembly limit", + ); + return Ok(()); + } + buffer.extend_from_slice(&user_data); if e_bit { let msg = std::mem::take(&mut *buffer).freeze(); @@ -2486,6 +2519,17 @@ impl SctpInner { let mut streams = self.inbound_streams.lock().unwrap(); let stream = streams.entry(stream_id).or_insert_with(InboundStream::new); let ready = stream.enqueue(stream_seq, msg); + if stream.pending.len() > MAX_INBOUND_STREAM_PENDING + || stream.buffered_bytes > MAX_INBOUND_STREAM_BUFFER_SIZE + { + drop(streams); + self.close_data_channel_for_reassembly_overflow( + &dc, + stream_id, + "ordered reassembly queue exceeded the per-channel buffer limit", + ); + return Ok(()); + } for m in ready { dc.send_event(DataChannelEvent::Message(m)); } diff --git a/tests/datachannel_reassembly_limits.rs b/tests/datachannel_reassembly_limits.rs new file mode 100644 index 0000000..4d4f8dd --- /dev/null +++ b/tests/datachannel_reassembly_limits.rs @@ -0,0 +1,160 @@ +use anyhow::{Result, anyhow}; +use rustrtc::transports::sctp::{DataChannel, DataChannelConfig, DataChannelEvent}; +use rustrtc::{PeerConnection, PeerConnectionEvent, RtcConfiguration}; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; + +const NEAR_LIMIT_MESSAGE_SIZE: usize = 63 * 1024; +const OVERSIZED_MESSAGE_SIZE: usize = 65 * 1024; + +async fn wait_for_channel_open(dc: &Arc) -> Result<()> { + loop { + match timeout(Duration::from_secs(10), dc.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for data channel open"))? + { + Some(DataChannelEvent::Open) => return Ok(()), + Some(_) => continue, + None => return Err(anyhow!("data channel closed before open")), + } + } +} + +async fn wait_for_remote_data_channel(pc: &PeerConnection) -> Result> { + loop { + match timeout(Duration::from_secs(10), pc.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for remote data channel"))? + { + Some(PeerConnectionEvent::DataChannel(dc)) => return Ok(dc), + Some(_) => continue, + None => return Err(anyhow!("peer connection closed before remote data channel")), + } + } +} + +async fn connect_data_channel( + ordered: bool, +) -> Result<( + PeerConnection, + PeerConnection, + Arc, + Arc, +)> { + let config = RtcConfiguration::default(); + let pc1 = PeerConnection::new(config.clone()); + let pc2 = PeerConnection::new(config); + + let dc1 = pc1.create_data_channel( + "limit-test", + Some(DataChannelConfig { + ordered, + ..Default::default() + }), + )?; + + let offer = pc1.create_offer().await?; + pc1.set_local_description(offer)?; + pc1.wait_for_gathering_complete().await; + let offer = pc1 + .local_description() + .ok_or_else(|| anyhow!("missing local offer"))?; + + pc2.set_remote_description(offer).await?; + let answer = pc2.create_answer().await?; + pc2.set_local_description(answer)?; + pc2.wait_for_gathering_complete().await; + let answer = pc2 + .local_description() + .ok_or_else(|| anyhow!("missing local answer"))?; + + pc1.set_remote_description(answer).await?; + tokio::try_join!(pc1.wait_for_connected(), pc2.wait_for_connected())?; + + let dc2 = wait_for_remote_data_channel(&pc2).await?; + wait_for_channel_open(&dc1).await?; + wait_for_channel_open(&dc2).await?; + + Ok((pc1, pc2, dc1, dc2)) +} + +#[tokio::test] +async fn near_limit_message_accepted() -> Result<()> { + let (pc1, pc2, dc1, dc2) = connect_data_channel(true).await?; + let payload = vec![0x2A; NEAR_LIMIT_MESSAGE_SIZE]; + + pc1.send_data(dc1.id, &payload).await?; + + match timeout(Duration::from_secs(10), dc2.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for near-limit message"))? + { + Some(DataChannelEvent::Message(msg)) => assert_eq!(msg.len(), payload.len()), + Some(DataChannelEvent::Close) => { + return Err(anyhow!( + "near-limit message should not close the data channel" + )); + } + Some(DataChannelEvent::Open) => return Err(anyhow!("unexpected extra open event")), + None => return Err(anyhow!("remote data channel closed unexpectedly")), + } + + pc1.close(); + pc2.close(); + Ok(()) +} + +#[tokio::test] +async fn oversized_ordered_message_rejected() -> Result<()> { + let (pc1, pc2, dc1, dc2) = connect_data_channel(true).await?; + let payload = vec![0x55; OVERSIZED_MESSAGE_SIZE]; + + pc1.send_data(dc1.id, &payload).await?; + + match timeout(Duration::from_secs(10), dc2.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for ordered channel close"))? + { + Some(DataChannelEvent::Close) => {} + Some(DataChannelEvent::Message(msg)) => { + return Err(anyhow!( + "oversized ordered message should be rejected, got {} bytes", + msg.len() + )); + } + Some(DataChannelEvent::Open) => return Err(anyhow!("unexpected extra open event")), + None => return Err(anyhow!("remote data channel closed without close event")), + } + + pc1.close(); + pc2.close(); + Ok(()) +} + +#[tokio::test] +async fn oversized_unordered_message_rejected() -> Result<()> { + let (pc1, pc2, dc1, dc2) = connect_data_channel(false).await?; + let payload = vec![0x7E; OVERSIZED_MESSAGE_SIZE]; + + pc1.send_data(dc1.id, &payload).await?; + + match timeout(Duration::from_secs(10), dc2.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for unordered channel close"))? + { + Some(DataChannelEvent::Close) => {} + Some(DataChannelEvent::Message(msg)) => { + return Err(anyhow!( + "oversized unordered message should be rejected, got {} bytes", + msg.len() + )); + } + Some(DataChannelEvent::Open) => return Err(anyhow!("unexpected extra open event")), + None => return Err(anyhow!("remote data channel closed without close event")), + } + + pc1.close(); + pc2.close(); + Ok(()) +} diff --git a/tests/security_malformed_buffers.rs b/tests/security_malformed_buffers.rs new file mode 100644 index 0000000..094505c --- /dev/null +++ b/tests/security_malformed_buffers.rs @@ -0,0 +1,92 @@ +use anyhow::{Result, anyhow}; +use bytes::{Bytes, BytesMut}; +use rustrtc::transports::PacketReceiver; +use rustrtc::transports::dtls::handshake::{HandshakeMessage, HandshakeType}; +use rustrtc::transports::dtls::record::{ContentType, DtlsRecord, ProtocolVersion}; +use rustrtc::transports::dtls::{DtlsState, DtlsTransport, generate_certificate}; +use rustrtc::transports::ice::IceSocketWrapper; +use rustrtc::transports::ice::conn::IceConn; +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::watch; + +fn spawn_socket_pump(socket: Arc, conn: Arc) { + tokio::spawn(async move { + let mut buf = vec![0u8; 2048]; + loop { + if let Ok((len, addr)) = socket.recv_from(&mut buf).await { + let packet = Bytes::copy_from_slice(&buf[..len]); + conn.receive(packet, addr).await; + } + } + }); +} + +async fn wait_for_terminal_state(dtls: &Arc) -> Result { + let mut state_rx = dtls.subscribe_state(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + + loop { + let state = state_rx.borrow().clone(); + if matches!( + state, + DtlsState::Connected(..) | DtlsState::Failed | DtlsState::Closed + ) { + return Ok(state); + } + + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(anyhow!("timed out waiting for DTLS terminal state")); + } + + tokio::time::timeout(deadline - now, state_rx.changed()).await??; + } +} + +#[tokio::test] +async fn malformed_dtls_fragment_range_fails_handshake() -> Result<()> { + let client_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + let server_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + + let client_addr = client_socket.local_addr()?; + let server_addr = server_socket.local_addr()?; + + let (server_socket_tx, _) = watch::channel(Some(IceSocketWrapper::Udp(server_socket.clone()))); + let server_conn = IceConn::new(server_socket_tx.subscribe(), client_addr); + + let cert = generate_certificate()?; + let (server_dtls, _server_rx, runner) = + DtlsTransport::new(server_conn.clone(), cert, false, 128, None).await?; + tokio::spawn(runner); + spawn_socket_pump(server_socket, server_conn); + + let handshake_msg = HandshakeMessage { + msg_type: HandshakeType::ClientHello, + total_length: 32, + message_seq: 0, + fragment_offset: 16, + fragment_length: 32, + body: Bytes::from(vec![0u8; 32]), + }; + + let mut handshake_buf = BytesMut::new(); + handshake_msg.encode(&mut handshake_buf); + let record = DtlsRecord { + content_type: ContentType::Handshake, + version: ProtocolVersion::DTLS_1_2, + epoch: 0, + sequence_number: 0, + payload: handshake_buf.freeze(), + }; + + let mut record_buf = BytesMut::new(); + record.encode(&mut record_buf); + client_socket.send_to(&record_buf, server_addr).await?; + + assert!(matches!( + wait_for_terminal_state(&server_dtls).await?, + DtlsState::Failed + )); + Ok(()) +} From 27d36c6c0bc5d4ae6cd214db25c5cadebf6a02f7 Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 22:21:29 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20pranswer/rollback=20?= =?UTF-8?q?=E4=BF=A1=E4=BB=A4=E7=8A=B6=E6=80=81=E6=9C=BA=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 src/peer_connection.rs 中实现本地与远端 rollback 恢复,支持 pranswer 过渡到最终 answer,并补齐无效状态分支。 - 新增 tests/signaling_pranswer_rollback.rs,覆盖 local rollback、remote rollback、pranswer->answer 与 invalid state。 - 扩展 tests/rtp_reinvite_test.rs 和 tests/rtp_reinvite_comprehensive_test.rs,补充 rollback 与 pranswer 的 reinvite 回归。 --- src/peer_connection.rs | 251 ++++++++++++++++++++--- tests/rtp_reinvite_comprehensive_test.rs | 78 +++++++ tests/rtp_reinvite_test.rs | 58 ++++++ tests/signaling_pranswer_rollback.rs | 155 ++++++++++++++ 4 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 tests/signaling_pranswer_rollback.rs diff --git a/src/peer_connection.rs b/src/peer_connection.rs index fab1c1c..e5b648e 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -282,6 +282,9 @@ struct PeerConnectionInner { _ice_gathering_state_rx: watch::Receiver, local_description: Mutex>, remote_description: Mutex>, + // Rollback must restore the last stable SDP plus the RTP runtime state + // derived from it, otherwise renegotiation side effects leak past abort. + rollback_snapshot: Mutex>, transceivers: Mutex>>, next_mid: AtomicU16, ice_transport: IceTransport, @@ -302,6 +305,28 @@ struct PeerConnectionInner { _disconnect_reason_rx: watch::Receiver>, } +#[derive(Clone)] +struct NegotiationSnapshot { + local_description: Option, + remote_description: Option, + transceivers: Vec, + next_mid: u16, + remote_dtls_fingerprint: Option, + dtls_role: Option, +} + +#[derive(Clone)] +struct TransceiverSnapshot { + transceiver: Arc, + direction: TransceiverDirection, + mid: Option, + payload_map: HashMap, + extmap: HashMap, + receiver_ssrc: Option, + receiver_rtx_ssrc: Option, + receiver_track_event_sent: Option, +} + fn generate_sdes_key_params() -> String { let mut key_salt = [0u8; 30]; rand::fill(&mut key_salt); @@ -365,6 +390,7 @@ impl PeerConnection { _ice_gathering_state_rx: ice_gathering_state_rx, local_description: Mutex::new(None), remote_description: Mutex::new(None), + rollback_snapshot: Mutex::new(None), transceivers: Mutex::new(Vec::new()), next_mid: AtomicU16::new(0), ice_transport, @@ -609,7 +635,10 @@ impl PeerConnection { pub async fn create_answer(&self) -> RtcResult { let state = &self.inner.signaling_state; - if *state.borrow() != SignalingState::HaveRemoteOffer { + if !matches!( + *state.borrow(), + SignalingState::HaveRemoteOffer | SignalingState::HaveLocalPranswer + ) { return Err(RtcError::InvalidState( "create_answer requires remote offer".into(), )); @@ -622,8 +651,120 @@ impl PeerConnection { .await } + fn capture_rollback_snapshot_if_absent(&self) { + let mut snapshot = self.inner.rollback_snapshot.lock().unwrap(); + if snapshot.is_some() { + return; + } + + let local_description = self.inner.local_description.lock().unwrap().clone(); + let remote_description = self.inner.remote_description.lock().unwrap().clone(); + let transceivers = self.inner.transceivers.lock().unwrap().clone(); + let next_mid = self.inner.next_mid.load(Ordering::SeqCst); + let remote_dtls_fingerprint = self.inner.remote_dtls_fingerprint.lock().unwrap().clone(); + let dtls_role = *self.inner.dtls_role.borrow(); + + *snapshot = Some(NegotiationSnapshot { + local_description, + remote_description, + transceivers: transceivers + .into_iter() + .map(|transceiver| { + let receiver = transceiver.receiver(); + TransceiverSnapshot { + direction: transceiver.direction(), + mid: transceiver.mid(), + payload_map: transceiver.get_payload_map(), + extmap: transceiver.get_extmap(), + receiver_ssrc: receiver.as_ref().map(|rx| rx.ssrc()), + receiver_rtx_ssrc: receiver.as_ref().and_then(|rx| rx.rtx_ssrc()), + receiver_track_event_sent: receiver + .as_ref() + .map(|rx| rx.track_event_sent.load(Ordering::SeqCst)), + transceiver, + } + }) + .collect(), + next_mid, + remote_dtls_fingerprint, + dtls_role, + }); + } + + fn has_established_negotiation(&self) -> bool { + if let Some(snapshot) = self.inner.rollback_snapshot.lock().unwrap().as_ref() { + snapshot.local_description.is_some() || snapshot.remote_description.is_some() + } else { + let local = self.inner.local_description.lock().unwrap(); + let remote = self.inner.remote_description.lock().unwrap(); + local.is_some() || remote.is_some() + } + } + + fn clear_rollback_snapshot(&self) { + self.inner.rollback_snapshot.lock().unwrap().take(); + } + + fn restore_rollback_snapshot(&self) -> RtcResult<()> { + let snapshot = self + .inner + .rollback_snapshot + .lock() + .unwrap() + .take() + .ok_or_else(|| { + RtcError::InvalidState("rollback requires a pending negotiation".into()) + })?; + + *self.inner.local_description.lock().unwrap() = snapshot.local_description; + *self.inner.remote_description.lock().unwrap() = snapshot.remote_description; + self.inner + .next_mid + .store(snapshot.next_mid, Ordering::SeqCst); + *self.inner.remote_dtls_fingerprint.lock().unwrap() = snapshot.remote_dtls_fingerprint; + let _ = self.inner.dtls_role.send(snapshot.dtls_role); + + let restored_transceivers: Vec> = snapshot + .transceivers + .iter() + .map(|entry| entry.transceiver.clone()) + .collect(); + *self.inner.transceivers.lock().unwrap() = restored_transceivers; + + for entry in snapshot.transceivers { + entry.transceiver.set_direction(entry.direction); + *entry.transceiver.mid.lock().unwrap() = entry.mid; + entry.transceiver.update_payload_map(entry.payload_map)?; + entry.transceiver.update_extmap(entry.extmap)?; + + if let Some(receiver) = entry.transceiver.receiver() { + if let Some(ssrc) = entry.receiver_ssrc { + receiver.set_ssrc(ssrc); + } + *receiver.rtx_ssrc.lock().unwrap() = entry.receiver_rtx_ssrc; + if let Some(track_event_sent) = entry.receiver_track_event_sent { + receiver + .track_event_sent + .store(track_event_sent, Ordering::SeqCst); + } + } + } + + Ok(()) + } + pub fn set_local_description(&self, desc: SessionDescription) -> RtcResult<()> { self.inner.validate_sdp_type(&desc.sdp_type)?; + let current_state = *self.inner.signaling_state.borrow(); + + if matches!(desc.sdp_type, SdpType::Offer) { + if current_state != SignalingState::Stable { + return Err(RtcError::InvalidState( + "set_local_description(offer) requires stable signaling state".into(), + )); + } + self.capture_rollback_snapshot_if_absent(); + } // For Offerer: extract parameters from local offer (our intended changes) // This allows Offerer to immediately update transceivers with new parameters @@ -689,28 +830,53 @@ impl PeerConnection { let state = &self.inner.signaling_state; match desc.sdp_type { SdpType::Offer => { - if *state.borrow() != SignalingState::Stable { - return Err(RtcError::InvalidState( - "set_local_description(offer) requires stable signaling state".into(), - )); - } let _ = state.send(SignalingState::HaveLocalOffer); } SdpType::Answer => { - if *state.borrow() != SignalingState::HaveRemoteOffer { + if !matches!( + current_state, + SignalingState::HaveRemoteOffer | SignalingState::HaveLocalPranswer + ) { return Err(RtcError::InvalidState( "set_local_description(answer) requires remote offer".into(), )); } let _ = state.send(SignalingState::Stable); } - SdpType::Rollback | SdpType::Pranswer => { - return Err(RtcError::NotImplemented("pranswer/rollback")); + SdpType::Pranswer => { + if !matches!( + current_state, + SignalingState::HaveRemoteOffer | SignalingState::HaveLocalPranswer + ) { + return Err(RtcError::InvalidState( + "set_local_description(pranswer) requires remote offer".into(), + )); + } + let _ = state.send(SignalingState::HaveLocalPranswer); + } + SdpType::Rollback => { + if !matches!( + current_state, + SignalingState::HaveLocalOffer + | SignalingState::HaveRemoteOffer + | SignalingState::HaveLocalPranswer + | SignalingState::HaveRemotePranswer + ) { + return Err(RtcError::InvalidState( + "set_local_description(rollback) requires pending negotiation".into(), + )); + } + self.restore_rollback_snapshot()?; + let _ = state.send(SignalingState::Stable); + return Ok(()); } } } let mut local = self.inner.local_description.lock().unwrap(); *local = Some(desc); + if matches!(local.as_ref().map(|d| d.sdp_type), Some(SdpType::Answer)) { + self.clear_rollback_snapshot(); + } Ok(()) } @@ -743,15 +909,21 @@ impl PeerConnection { None }; + let current_state = *self.inner.signaling_state.borrow(); + if matches!(desc.sdp_type, SdpType::Offer) { + if current_state != SignalingState::Stable { + return Err(RtcError::InvalidState( + "set_remote_description(offer) requires stable signaling state".into(), + )); + } + self.capture_rollback_snapshot_if_absent(); + } + // Check if this is a reinvite (not first negotiation) - let is_reinvite = { - let remote = self.inner.remote_description.lock().unwrap(); - remote.is_some() - }; + let is_reinvite = self.has_established_negotiation(); if is_reinvite { // Apply reinvite at correct timing based on role - let current_state = *self.inner.signaling_state.borrow(); match (desc.sdp_type, current_state) { // Answerer receiving offer: apply immediately (SdpType::Offer, SignalingState::Stable) => { @@ -759,7 +931,10 @@ impl PeerConnection { self.handle_reinvite(&desc).await?; } // Offerer receiving answer: apply now (was pending since we sent offer) - (SdpType::Answer, SignalingState::HaveLocalOffer) => { + ( + SdpType::Answer, + SignalingState::HaveLocalOffer | SignalingState::HaveRemotePranswer, + ) => { debug!("Offerer: applying reinvite from answer"); self.handle_reinvite(&desc).await?; } @@ -784,23 +959,45 @@ impl PeerConnection { let state = &self.inner.signaling_state; match desc.sdp_type { SdpType::Offer => { - if *state.borrow() != SignalingState::Stable { - return Err(RtcError::InvalidState( - "set_remote_description(offer) requires stable signaling state".into(), - )); - } let _ = state.send(SignalingState::HaveRemoteOffer); } SdpType::Answer => { - if *state.borrow() != SignalingState::HaveLocalOffer { + if !matches!( + current_state, + SignalingState::HaveLocalOffer | SignalingState::HaveRemotePranswer + ) { return Err(RtcError::InvalidState( "set_remote_description(answer) requires local offer".into(), )); } let _ = state.send(SignalingState::Stable); } - SdpType::Rollback | SdpType::Pranswer => { - return Err(RtcError::NotImplemented("pranswer/rollback")); + SdpType::Pranswer => { + if !matches!( + current_state, + SignalingState::HaveLocalOffer | SignalingState::HaveRemotePranswer + ) { + return Err(RtcError::InvalidState( + "set_remote_description(pranswer) requires local offer".into(), + )); + } + let _ = state.send(SignalingState::HaveRemotePranswer); + } + SdpType::Rollback => { + if !matches!( + current_state, + SignalingState::HaveLocalOffer + | SignalingState::HaveRemoteOffer + | SignalingState::HaveLocalPranswer + | SignalingState::HaveRemotePranswer + ) { + return Err(RtcError::InvalidState( + "set_remote_description(rollback) requires pending negotiation".into(), + )); + } + self.restore_rollback_snapshot()?; + let _ = state.send(SignalingState::Stable); + return Ok(()); } } } @@ -1267,6 +1464,9 @@ impl PeerConnection { let mut remote = self.inner.remote_description.lock().unwrap(); *remote = Some(desc); + if matches!(remote.as_ref().map(|d| d.sdp_type), Some(SdpType::Answer)) { + self.clear_rollback_snapshot(); + } Ok(()) } @@ -3217,8 +3417,7 @@ impl PeerConnectionInner { fn validate_sdp_type(&self, sdp_type: &SdpType) -> RtcResult<()> { match sdp_type { - SdpType::Offer | SdpType::Answer => Ok(()), - _ => Err(RtcError::NotImplemented("pranswer/rollback")), + SdpType::Offer | SdpType::Answer | SdpType::Pranswer | SdpType::Rollback => Ok(()), } } @@ -3506,6 +3705,8 @@ pub enum SignalingState { Stable, HaveLocalOffer, HaveRemoteOffer, + HaveLocalPranswer, + HaveRemotePranswer, Closed, } diff --git a/tests/rtp_reinvite_comprehensive_test.rs b/tests/rtp_reinvite_comprehensive_test.rs index b74af90..e74ca1f 100644 --- a/tests/rtp_reinvite_comprehensive_test.rs +++ b/tests/rtp_reinvite_comprehensive_test.rs @@ -485,3 +485,81 @@ async fn test_extmap_changes_in_reinvite() { "Should contain new extmap ID 7" ); } + +/// Test 11: Reinvite can progress through pranswer to final answer +#[tokio::test] +async fn test_reinvite_pranswer_then_answer() { + let config = RtcConfiguration::default(); + let pc = PeerConnection::new(config); + + pc.add_transceiver( + MediaKind::Audio, + peer_connection::TransceiverDirection::SendRecv, + ); + + let initial_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv); + pc.set_local_description(initial_offer).unwrap(); + + let initial_answer = create_minimal_sdp(SdpType::Answer, "0", Direction::SendRecv); + pc.set_remote_description(initial_answer).await.unwrap(); + assert_eq!(pc.signaling_state(), SignalingState::Stable); + + let mut reinvite_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv); + reinvite_offer.media_sections[0].attributes.clear(); + reinvite_offer.media_sections[0] + .attributes + .push(Attribute::new( + "rtpmap", + Some("120 opus/48000/2".to_string()), + )); + reinvite_offer.media_sections[0] + .attributes + .push(Attribute::new("ssrc", Some("12345 cname:test".to_string()))); + + pc.set_local_description(reinvite_offer).unwrap(); + assert_eq!(pc.signaling_state(), SignalingState::HaveLocalOffer); + assert!( + pc.get_transceivers()[0] + .get_payload_map() + .contains_key(&120) + ); + + let mut remote_pranswer = create_minimal_sdp(SdpType::Pranswer, "0", Direction::SendRecv); + remote_pranswer.media_sections[0].attributes.clear(); + remote_pranswer.media_sections[0] + .attributes + .push(Attribute::new( + "rtpmap", + Some("120 opus/48000/2".to_string()), + )); + remote_pranswer.media_sections[0] + .attributes + .push(Attribute::new("ssrc", Some("12345 cname:test".to_string()))); + + pc.set_remote_description(remote_pranswer).await.unwrap(); + assert_eq!(pc.signaling_state(), SignalingState::HaveRemotePranswer); + assert!( + pc.get_transceivers()[0] + .get_payload_map() + .contains_key(&120) + ); + + let mut remote_answer = create_minimal_sdp(SdpType::Answer, "0", Direction::SendRecv); + remote_answer.media_sections[0].attributes.clear(); + remote_answer.media_sections[0] + .attributes + .push(Attribute::new( + "rtpmap", + Some("120 opus/48000/2".to_string()), + )); + remote_answer.media_sections[0] + .attributes + .push(Attribute::new("ssrc", Some("12345 cname:test".to_string()))); + + pc.set_remote_description(remote_answer).await.unwrap(); + + let payload_map = pc.get_transceivers()[0].get_payload_map(); + assert_eq!(pc.signaling_state(), SignalingState::Stable); + assert!(payload_map.contains_key(&120)); + assert!(!payload_map.contains_key(&111)); +} diff --git a/tests/rtp_reinvite_test.rs b/tests/rtp_reinvite_test.rs index 9b03aef..652c6db 100644 --- a/tests/rtp_reinvite_test.rs +++ b/tests/rtp_reinvite_test.rs @@ -1,6 +1,33 @@ +use rustrtc::sdp::{ + Attribute, Direction, MediaSection, SdpType, SessionDescription, SessionSection, +}; use rustrtc::*; use std::collections::HashMap; +fn create_audio_sdp( + sdp_type: SdpType, + mid: &str, + direction: Direction, + payload_type: u8, + ssrc: u32, +) -> SessionDescription { + let mut desc = SessionDescription::new(sdp_type); + desc.session = SessionSection::default(); + + let mut section = MediaSection::new(MediaKind::Audio, mid); + section.direction = direction; + section.attributes.push(Attribute::new( + "rtpmap", + Some(format!("{payload_type} opus/48000/2")), + )); + section + .attributes + .push(Attribute::new("ssrc", Some(format!("{ssrc} cname:test")))); + + desc.media_sections.push(section); + desc +} + /// Test basic payload type map update functionality #[tokio::test] async fn test_payload_type_update() { @@ -420,6 +447,37 @@ async fn test_reinvite_comprehensive() { assert!(!payload_map.contains_key(&98)); } +#[tokio::test] +async fn test_local_reinvite_rollback_restores_payload_map() { + let pc = PeerConnection::new(RtcConfiguration::default()); + pc.add_transceiver( + MediaKind::Audio, + peer_connection::TransceiverDirection::SendRecv, + ); + + let initial_offer = create_audio_sdp(SdpType::Offer, "0", Direction::SendRecv, 111, 12345); + pc.set_local_description(initial_offer).unwrap(); + + let initial_answer = create_audio_sdp(SdpType::Answer, "0", Direction::SendRecv, 111, 12345); + pc.set_remote_description(initial_answer).await.unwrap(); + + let transceiver = pc.get_transceivers()[0].clone(); + assert!(transceiver.get_payload_map().contains_key(&111)); + + let reinvite_offer = create_audio_sdp(SdpType::Offer, "0", Direction::SendRecv, 120, 12345); + pc.set_local_description(reinvite_offer).unwrap(); + assert_eq!(pc.signaling_state(), SignalingState::HaveLocalOffer); + assert!(transceiver.get_payload_map().contains_key(&120)); + + pc.set_local_description(SessionDescription::new(SdpType::Rollback)) + .unwrap(); + + let payload_map = transceiver.get_payload_map(); + assert_eq!(pc.signaling_state(), SignalingState::Stable); + assert!(payload_map.contains_key(&111)); + assert!(!payload_map.contains_key(&120)); +} + // Helper functions to test private methods fn extract_payload_map_helper( section: &rustrtc::MediaSection, diff --git a/tests/signaling_pranswer_rollback.rs b/tests/signaling_pranswer_rollback.rs new file mode 100644 index 0000000..f7dab66 --- /dev/null +++ b/tests/signaling_pranswer_rollback.rs @@ -0,0 +1,155 @@ +use anyhow::Result; +use rustrtc::sdp::{ + Attribute, Direction, MediaSection, SdpType, SessionDescription, SessionSection, +}; +use rustrtc::*; + +fn create_minimal_sdp( + sdp_type: SdpType, + mid: &str, + direction: Direction, + payload_type: u8, + ssrc: u32, +) -> SessionDescription { + let mut desc = SessionDescription::new(sdp_type); + desc.session = SessionSection::default(); + + let mut section = MediaSection::new(MediaKind::Audio, mid); + section.direction = direction; + section.attributes.push(Attribute::new( + "rtpmap", + Some(format!("{payload_type} opus/48000/2")), + )); + section + .attributes + .push(Attribute::new("ssrc", Some(format!("{ssrc} cname:test")))); + + desc.media_sections.push(section); + desc +} + +#[tokio::test] +async fn local_rollback_restores_stable() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); + + let local_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv, 111, 12345); + pc.set_local_description(local_offer.clone())?; + let remote_answer = create_minimal_sdp(SdpType::Answer, "0", Direction::SendRecv, 111, 12345); + pc.set_remote_description(remote_answer.clone()).await?; + assert_eq!(pc.signaling_state(), SignalingState::Stable); + + let transceiver = pc.get_transceivers()[0].clone(); + assert!(transceiver.get_payload_map().contains_key(&111)); + + let reinvite_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv, 120, 12345); + pc.set_local_description(reinvite_offer)?; + assert_eq!(pc.signaling_state(), SignalingState::HaveLocalOffer); + assert!(transceiver.get_payload_map().contains_key(&120)); + + pc.set_local_description(SessionDescription::new(SdpType::Rollback))?; + + assert_eq!(pc.signaling_state(), SignalingState::Stable); + assert_eq!(pc.local_description().unwrap().sdp_type, SdpType::Offer); + assert_eq!(pc.remote_description().unwrap().sdp_type, SdpType::Answer); + assert!(transceiver.get_payload_map().contains_key(&111)); + assert!(!transceiver.get_payload_map().contains_key(&120)); + Ok(()) +} + +#[tokio::test] +async fn remote_rollback_restores_stable() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + + let remote_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv, 111, 12345); + pc.set_remote_description(remote_offer).await?; + + assert_eq!(pc.signaling_state(), SignalingState::HaveRemoteOffer); + assert_eq!(pc.get_transceivers().len(), 1); + + pc.set_remote_description(SessionDescription::new(SdpType::Rollback)) + .await?; + + assert_eq!(pc.signaling_state(), SignalingState::Stable); + assert!(pc.local_description().is_none()); + assert!(pc.remote_description().is_none()); + assert!(pc.get_transceivers().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn pranswer_then_answer_succeeds() -> Result<()> { + let offerer = PeerConnection::new(RtcConfiguration::default()); + offerer.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); + + let local_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv, 111, 12345); + offerer.set_local_description(local_offer)?; + + let remote_pranswer = + create_minimal_sdp(SdpType::Pranswer, "0", Direction::SendRecv, 111, 12345); + offerer.set_remote_description(remote_pranswer).await?; + assert_eq!( + offerer.signaling_state(), + SignalingState::HaveRemotePranswer + ); + + let remote_answer = create_minimal_sdp(SdpType::Answer, "0", Direction::SendRecv, 111, 12345); + offerer.set_remote_description(remote_answer).await?; + assert_eq!(offerer.signaling_state(), SignalingState::Stable); + assert_eq!( + offerer.remote_description().unwrap().sdp_type, + SdpType::Answer + ); + + let answerer = PeerConnection::new(RtcConfiguration::default()); + let remote_offer = create_minimal_sdp(SdpType::Offer, "0", Direction::SendRecv, 111, 22222); + answerer.set_remote_description(remote_offer).await?; + + let local_pranswer = + create_minimal_sdp(SdpType::Pranswer, "0", Direction::SendRecv, 111, 22222); + answerer.set_local_description(local_pranswer)?; + assert_eq!( + answerer.signaling_state(), + SignalingState::HaveLocalPranswer + ); + + let local_answer = answerer.create_answer().await?; + answerer.set_local_description(local_answer)?; + assert_eq!(answerer.signaling_state(), SignalingState::Stable); + Ok(()) +} + +#[tokio::test] +async fn invalid_state_rejected() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + + let local_pranswer = pc.set_local_description(create_minimal_sdp( + SdpType::Pranswer, + "0", + Direction::SendRecv, + 111, + 1, + )); + assert!(matches!(local_pranswer, Err(RtcError::InvalidState(_)))); + + let remote_pranswer = pc + .set_remote_description(create_minimal_sdp( + SdpType::Pranswer, + "0", + Direction::SendRecv, + 111, + 1, + )) + .await; + assert!(matches!(remote_pranswer, Err(RtcError::InvalidState(_)))); + + let local_rollback = pc.set_local_description(SessionDescription::new(SdpType::Rollback)); + assert!(matches!(local_rollback, Err(RtcError::InvalidState(_)))); + + let remote_rollback = pc + .set_remote_description(SessionDescription::new(SdpType::Rollback)) + .await; + assert!(matches!(remote_rollback, Err(RtcError::InvalidState(_)))); + + Ok(()) +} From ebe1256b1569f2218a8cee1ead1c12461c0bb72c Mon Sep 17 00:00:00 2001 From: VIctor Date: Fri, 13 Mar 2026 23:05:41 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E5=BC=95=E5=85=A5=20codec=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=A8=A1=E5=9E=8B=E5=B9=B6=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=20answer=20=E5=8D=8F=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-05 media/codec-runtime-model Summary: - 扩展 RtpCodecParameters,保留 codec_name、fmtp、rtcp-fb,并提供 codec_specific_parameters 解析入口。 - 在 SDP 解析与 answer 生成中引入 codec 能力交集,answer 复用远端 payload type,不兼容 codec 生成拒绝的 m-line。 - 本地 answer/pranswer 落地后将协商结果写回 transceiver 运行时,同时避免覆盖远端 extmap。 Files: - src/peer_connection.rs - src/sdp.rs - src/config.rs - tests/codec_runtime_model.rs - tests/codec_negotiation_integration.rs - 以及受 RtpCodecParameters 扩展影响的现有 RTP/interop 测试 Verification: - cargo fmt --all - cargo test --test codec_runtime_model - cargo test --test codec_negotiation_integration - cargo check --tests - cargo test --test rtp_reinvite_test - cargo test --test rtp_reinvite_comprehensive_test - cargo test --test media_flow Result: - pass Risk: - 当前 answer 的 fmtp 仍以本地能力为主、远端 offer 为辅,复杂 H264 profile 兼容策略后续还需要更细粒度协商。 Follow-up: - 继续 ISSUE-06 config/certificate-plumbing --- src/config.rs | 2 + src/peer_connection.rs | 494 +++++++++++++++++++++-- src/sdp.rs | 6 + tests/codec_negotiation_integration.rs | 165 ++++++++ tests/codec_runtime_model.rs | 131 ++++++ tests/interop_webrtc.rs | 6 + tests/media_flow.rs | 3 + tests/repro_padding.rs | 6 + tests/rtp_latching_test.rs | 3 + tests/rtp_mode_bug_repro.rs | 3 + tests/rtp_mode_extensions_test.rs | 3 + tests/rtp_mode_scenarios_test.rs | 6 + tests/rtp_mode_test.rs | 3 + tests/rtp_reinvite_test.rs | 124 ++---- tests/rtp_sender_test.rs | 6 + tests/test_remote_addr_and_raw_packet.rs | 3 + 16 files changed, 829 insertions(+), 135 deletions(-) create mode 100644 tests/codec_negotiation_integration.rs create mode 100644 tests/codec_runtime_model.rs diff --git a/src/config.rs b/src/config.rs index 3ec9d0a..e4f6165 100644 --- a/src/config.rs +++ b/src/config.rs @@ -181,6 +181,7 @@ pub struct VideoCapability { pub payload_type: u8, pub codec_name: String, pub clock_rate: u32, + pub fmtp: Option, pub rtcp_fbs: Vec, } @@ -190,6 +191,7 @@ impl Default for VideoCapability { payload_type: 96, codec_name: "VP8".to_string(), clock_rate: 90000, + fmtp: None, rtcp_fbs: vec![ "nack".to_string(), "nack pli".to_string(), diff --git a/src/peer_connection.rs b/src/peer_connection.rs index e5b648e..a71b64c 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -321,6 +321,7 @@ struct TransceiverSnapshot { direction: TransceiverDirection, mid: Option, payload_map: HashMap, + active_codec: Option, extmap: HashMap, receiver_ssrc: Option, receiver_rtx_ssrc: Option, @@ -675,6 +676,10 @@ impl PeerConnection { direction: transceiver.direction(), mid: transceiver.mid(), payload_map: transceiver.get_payload_map(), + active_codec: transceiver + .sender() + .map(|sender| sender.params()) + .or_else(|| receiver.as_ref().map(|rx| rx.params())), extmap: transceiver.get_extmap(), receiver_ssrc: receiver.as_ref().map(|rx| rx.ssrc()), receiver_rtx_ssrc: receiver.as_ref().and_then(|rx| rx.rtx_ssrc()), @@ -735,6 +740,9 @@ impl PeerConnection { entry.transceiver.set_direction(entry.direction); *entry.transceiver.mid.lock().unwrap() = entry.mid; entry.transceiver.update_payload_map(entry.payload_map)?; + if let Some(codec) = entry.active_codec { + entry.transceiver.set_active_codec(codec); + } entry.transceiver.update_extmap(entry.extmap)?; if let Some(receiver) = entry.transceiver.receiver() { @@ -796,12 +804,7 @@ impl PeerConnection { } if let Some(t) = matched_transceiver { - let payload_map = Self::extract_payload_map(section); - if !payload_map.is_empty() { - let _ = t.update_payload_map(payload_map); - } - let extmap = Self::extract_extmap(section); - let _ = t.update_extmap(extmap); + let _ = Self::apply_section_codec_state(&t, section); } } } else { @@ -872,6 +875,9 @@ impl PeerConnection { } } } + if matches!(desc.sdp_type, SdpType::Answer | SdpType::Pranswer) { + self.apply_local_description_media_state(&desc); + } let mut local = self.inner.local_description.lock().unwrap(); *local = Some(desc); if matches!(local.as_ref().map(|d| d.sdp_type), Some(SdpType::Answer)) { @@ -1284,13 +1290,7 @@ impl PeerConnection { } if let Some(t) = found_transceiver { - // Update transceiver parameters - let payload_map = Self::extract_payload_map(section); - if !payload_map.is_empty() { - let _ = t.update_payload_map(payload_map); - } - let extmap = Self::extract_extmap(section); - let _ = t.update_extmap(extmap); + let _ = Self::apply_section_codec_state(&t, section); let direction: TransceiverDirection = section.direction.into(); t.set_direction(direction); @@ -1394,6 +1394,7 @@ impl PeerConnection { } *t.receiver.lock().unwrap() = Some(receiver); + let _ = Self::apply_section_codec_state(&t, section); transceivers.push(t.clone()); @@ -1420,13 +1421,7 @@ impl PeerConnection { } if let Some(t) = found_transceiver { - // Update transceiver parameters - let payload_map = Self::extract_payload_map(section); - if !payload_map.is_empty() { - let _ = t.update_payload_map(payload_map); - } - let extmap = Self::extract_extmap(section); - let _ = t.update_extmap(extmap); + let _ = Self::apply_section_codec_state(t, section); let direction: TransceiverDirection = section.direction.into(); t.set_direction(direction); @@ -2293,20 +2288,7 @@ impl PeerConnection { } } - // Extract and validate payload type mapping - let payload_map = Self::extract_payload_map(section); - if !payload_map.is_empty() { - // Basic validation: check if we support these codecs - for (pt, params) in &payload_map { - trace!("Validating PT {}: clock_rate={}", pt, params.clock_rate); - // TODO: Add full codec capability check against local capabilities - } - t.update_payload_map(payload_map)?; - } - - // Extract and update extension mapping - let extmap = Self::extract_extmap(section); - t.update_extmap(extmap)?; + Self::apply_section_codec_state(t, section)?; // Handle direction changes let new_direction: TransceiverDirection = section.direction.into(); @@ -2329,9 +2311,112 @@ impl PeerConnection { Ok(()) } + fn parse_fmtp_map(section: &crate::MediaSection) -> HashMap { + let mut fmtp_map = HashMap::new(); + + for attr in §ion.attributes { + if attr.key != "fmtp" { + continue; + } + let Some(value) = attr.value.as_ref() else { + continue; + }; + let mut parts = value.splitn(2, char::is_whitespace); + let Some(pt_str) = parts.next() else { + continue; + }; + let Some(fmtp) = parts.next() else { + continue; + }; + if let Ok(pt) = pt_str.parse::() { + fmtp_map.insert(pt, fmtp.trim().to_string()); + } + } + + fmtp_map + } + + fn parse_rtcp_feedback_map(section: &crate::MediaSection) -> HashMap> { + let mut feedback = HashMap::new(); + + for attr in §ion.attributes { + if attr.key != "rtcp-fb" { + continue; + } + let Some(value) = attr.value.as_ref() else { + continue; + }; + let mut parts = value.splitn(2, char::is_whitespace); + let Some(pt_str) = parts.next() else { + continue; + }; + let Some(fb) = parts.next() else { + continue; + }; + if let Ok(pt) = pt_str.parse::() { + feedback + .entry(pt) + .or_insert_with(Vec::new) + .push(fb.trim().to_string()); + } + } + + feedback + } + + fn parse_fmtp_parameters(fmtp: &str) -> HashMap { + let mut params = HashMap::new(); + + for item in fmtp.split(';') { + let item = item.trim(); + if item.is_empty() { + continue; + } + if let Some((key, value)) = item.split_once('=') { + params.insert(key.trim().to_string(), value.trim().to_string()); + } else { + params.insert(item.to_string(), String::new()); + } + } + + params + } + + fn extract_payload_types(section: &crate::MediaSection) -> Vec { + if !section.formats.is_empty() { + return section + .formats + .iter() + .filter_map(|fmt| fmt.parse::().ok()) + .collect(); + } + + let mut payload_types = Vec::new(); + for attr in §ion.attributes { + if attr.key != "rtpmap" { + continue; + } + let Some(value) = attr.value.as_ref() else { + continue; + }; + let Some(pt_str) = value.split_whitespace().next() else { + continue; + }; + if let Ok(pt) = pt_str.parse::() + && !payload_types.contains(&pt) + { + payload_types.push(pt); + } + } + + payload_types + } + /// Extract payload type to codec parameters mapping from media section fn extract_payload_map(section: &crate::MediaSection) -> HashMap { let mut payload_map = HashMap::new(); + let fmtp_map = Self::parse_fmtp_map(section); + let rtcp_feedback_map = Self::parse_rtcp_feedback_map(section); // Parse rtpmap attributes: "96 opus/48000/2" for attr in §ion.attributes { @@ -2343,19 +2428,26 @@ impl PeerConnection { // Parse codec/rate/channels let codec_parts: Vec<&str> = parts[1].split('/').collect(); if codec_parts.len() >= 2 { + let codec_name = codec_parts[0].to_string(); let clock_rate = codec_parts[1].parse().unwrap_or(90000); let channels = if codec_parts.len() >= 3 { codec_parts[2].parse().unwrap_or(0) } else { 0 }; + let fmtp = fmtp_map.get(&pt).cloned(); + let rtcp_fbs = + rtcp_feedback_map.get(&pt).cloned().unwrap_or_default(); payload_map.insert( pt, RtpCodecParameters { payload_type: pt, + codec_name, clock_rate, channels, + fmtp, + rtcp_fbs, }, ); } @@ -2368,6 +2460,141 @@ impl PeerConnection { payload_map } + fn codecs_match( + local: &RtpCodecParameters, + remote: &RtpCodecParameters, + kind: MediaKind, + ) -> bool { + if !local.codec_name.eq_ignore_ascii_case(&remote.codec_name) { + return false; + } + if local.clock_rate != remote.clock_rate { + return false; + } + + if kind == MediaKind::Audio + && local.channels != 0 + && remote.channels != 0 + && local.channels != remote.channels + { + return false; + } + + true + } + + fn intersect_rtcp_feedback(remote: &[String], local: &[String]) -> Vec { + remote + .iter() + .filter(|fb| local.iter().any(|candidate| candidate == *fb)) + .cloned() + .collect() + } + + fn rewrite_section_codecs( + section: &mut crate::MediaSection, + codecs: &[RtpCodecParameters], + kind: MediaKind, + ) { + section + .attributes + .retain(|attr| !matches!(attr.key.as_str(), "rtpmap" | "fmtp" | "rtcp-fb")); + section.formats = codecs + .iter() + .map(|codec| codec.payload_type.to_string()) + .collect(); + + for codec in codecs { + let rtpmap = if kind == MediaKind::Audio { + format!( + "{} {}/{}/{}", + codec.payload_type, codec.codec_name, codec.clock_rate, codec.channels + ) + } else { + format!( + "{} {}/{}", + codec.payload_type, codec.codec_name, codec.clock_rate + ) + }; + section + .attributes + .push(Attribute::new("rtpmap", Some(rtpmap))); + + if let Some(fmtp) = &codec.fmtp { + section.attributes.push(Attribute::new( + "fmtp", + Some(format!("{} {}", codec.payload_type, fmtp)), + )); + } + for fb in &codec.rtcp_fbs { + section.attributes.push(Attribute::new( + "rtcp-fb", + Some(format!("{} {}", codec.payload_type, fb)), + )); + } + } + } + + fn reject_media_section(section: &mut crate::MediaSection) { + section.port = 0; + section.direction = Direction::Inactive; + section + .attributes + .retain(|attr| !matches!(attr.key.as_str(), "rtpmap" | "fmtp" | "rtcp-fb")); + if section.formats.is_empty() { + section.formats.push("0".to_string()); + } + } + + fn apply_section_payload_state( + transceiver: &Arc, + section: &crate::MediaSection, + ) -> RtcResult<()> { + let payload_map = Self::extract_payload_map(section); + let preferred_codec = Self::extract_payload_types(section) + .into_iter() + .find_map(|pt| payload_map.get(&pt).cloned()) + .or_else(|| payload_map.values().next().cloned()); + + transceiver.update_payload_map(payload_map)?; + if let Some(codec) = preferred_codec { + transceiver.set_active_codec(codec); + } + + Ok(()) + } + + fn apply_section_codec_state( + transceiver: &Arc, + section: &crate::MediaSection, + ) -> RtcResult<()> { + Self::apply_section_payload_state(transceiver, section)?; + let extmap = Self::extract_extmap(section); + transceiver.update_extmap(extmap)?; + Ok(()) + } + + fn apply_local_description_media_state(&self, desc: &SessionDescription) { + let transceivers = self.inner.transceivers.lock().unwrap().clone(); + + for section in &desc.media_sections { + let matched_transceiver = transceivers + .iter() + .find(|t| t.mid().as_ref() == Some(§ion.mid)) + .cloned() + .or_else(|| { + transceivers + .iter() + .find(|t| t.mid().is_none() && t.kind() == section.kind) + .cloned() + }); + + if let Some(transceiver) = matched_transceiver { + let _ = Self::apply_section_payload_state(&transceiver, section); + } + } + } + /// Extract extension header mapping from media section fn extract_extmap(section: &crate::MediaSection) -> HashMap { let mut extmap = HashMap::new(); @@ -3286,7 +3513,9 @@ impl PeerConnectionInner { if sdp_type == SdpType::Answer && !remote_offered_rtcp_mux { section.attributes.retain(|attr| attr.key != "rtcp-mux"); } - if let Some(sender) = sender_info { + if section.port != 0 + && let Some(sender) = sender_info + { Self::attach_sender_attributes( &mut section, sender.ssrc(), @@ -3295,7 +3524,7 @@ impl PeerConnectionInner { sender.track_id(), &mode, ); - } else if direction.sends() { + } else if section.port != 0 && direction.sends() { if let Some(ssrc) = *transceiver.sender_ssrc.lock().unwrap() { let cname = format!("rustrtc-cname-{ssrc}"); let stream_id = transceiver @@ -3421,6 +3650,102 @@ impl PeerConnectionInner { } } + fn local_codec_capabilities(&self, kind: MediaKind) -> Vec { + match kind { + MediaKind::Audio => { + let default_cap = AudioCapability::default(); + let caps = if let Some(config) = &self.config.media_capabilities { + if config.audio.is_empty() { + vec![default_cap] + } else { + config.audio.clone() + } + } else { + vec![default_cap] + }; + + caps.into_iter() + .map(|cap| RtpCodecParameters { + payload_type: cap.payload_type, + codec_name: cap.codec_name, + clock_rate: cap.clock_rate, + channels: cap.channels, + fmtp: cap.fmtp, + rtcp_fbs: cap.rtcp_fbs, + }) + .collect() + } + MediaKind::Video => { + let default_cap = VideoCapability::default(); + let caps = if let Some(config) = &self.config.media_capabilities { + if config.video.is_empty() { + vec![default_cap] + } else { + config.video.clone() + } + } else { + vec![default_cap] + }; + + caps.into_iter() + .map(|cap| RtpCodecParameters { + payload_type: cap.payload_type, + codec_name: cap.codec_name, + clock_rate: cap.clock_rate, + channels: 0, + fmtp: cap.fmtp, + rtcp_fbs: cap.rtcp_fbs, + }) + .collect() + } + MediaKind::Application => Vec::new(), + } + } + + fn negotiate_answer_codecs(&self, section: &crate::MediaSection) -> Vec { + let remote = self.remote_description.lock().unwrap(); + let Some(remote) = remote.as_ref() else { + return Vec::new(); + }; + let Some(remote_section) = remote + .media_sections + .iter() + .find(|item| item.mid == section.mid) + else { + return Vec::new(); + }; + + let offered_codecs = PeerConnection::extract_payload_map(remote_section); + let offered_pts = PeerConnection::extract_payload_types(remote_section); + let local_capabilities = self.local_codec_capabilities(section.kind); + let mut negotiated = Vec::new(); + + for pt in offered_pts { + let Some(remote_codec) = offered_codecs.get(&pt) else { + continue; + }; + let Some(local_codec) = local_capabilities.iter().find(|candidate| { + PeerConnection::codecs_match(candidate, remote_codec, section.kind) + }) else { + continue; + }; + + let mut codec = local_codec.clone(); + codec.payload_type = pt; + codec.codec_name = remote_codec.codec_name.clone(); + codec.rtcp_fbs = PeerConnection::intersect_rtcp_feedback( + &remote_codec.rtcp_fbs, + &local_codec.rtcp_fbs, + ); + if codec.fmtp.is_none() { + codec.fmtp = remote_codec.fmtp.clone(); + } + negotiated.push(codec); + } + + negotiated + } + fn populate_media_capabilities( &self, section: &mut MediaSection, @@ -3429,6 +3754,15 @@ impl PeerConnectionInner { ) { section.apply_config(&self.config); + if sdp_type == SdpType::Answer && matches!(kind, MediaKind::Audio | MediaKind::Video) { + let negotiated_codecs = self.negotiate_answer_codecs(section); + if negotiated_codecs.is_empty() { + PeerConnection::reject_media_section(section); + } else { + PeerConnection::rewrite_section_codecs(section, &negotiated_codecs, kind); + } + } + // Add extmap for Video if kind == MediaKind::Video { let (mut rid_id, mut repaired_rid_id) = self.get_remote_video_extmap_ids(§ion.mid); @@ -3779,23 +4113,38 @@ impl From for TransceiverDirection { static TRANSCEIVER_COUNTER: AtomicU64 = AtomicU64::new(1); -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RtpCodecParameters { pub payload_type: u8, + pub codec_name: String, pub clock_rate: u32, pub channels: u8, + pub fmtp: Option, + pub rtcp_fbs: Vec, } impl Default for RtpCodecParameters { fn default() -> Self { Self { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), } } } +impl RtpCodecParameters { + pub fn codec_specific_parameters(&self) -> HashMap { + self.fmtp + .as_deref() + .map(PeerConnection::parse_fmtp_parameters) + .unwrap_or_default() + } +} + pub struct RtpTransceiver { id: u64, kind: MediaKind, @@ -3978,6 +4327,15 @@ impl RtpTransceiver { self.payload_map.read().unwrap().clone() } + pub fn set_active_codec(&self, codec: RtpCodecParameters) { + if let Some(sender) = self.sender() { + sender.set_params(codec.clone()); + } + if let Some(receiver) = self.receiver() { + receiver.set_params(codec); + } + } + /// Get current extmap (for testing/debugging) pub fn get_extmap(&self) -> HashMap { self.extmap.read().unwrap().clone() @@ -4131,6 +4489,10 @@ impl RtpSender { self.params.lock().unwrap().clone() } + pub fn set_params(&self, params: RtpCodecParameters) { + *self.params.lock().unwrap() = params; + } + pub fn interceptors(&self) -> &[Arc] { &self.interceptors } @@ -4419,13 +4781,25 @@ impl RtpReceiverBuilder { let params = match self.kind { MediaKind::Audio => RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: Some("minptime=10;useinbandfec=1".to_string()), + rtcp_fbs: Vec::new(), }, MediaKind::Video => RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: vec![ + "nack".to_string(), + "nack pli".to_string(), + "ccm fir".to_string(), + "goog-remb".to_string(), + "transport-cc".to_string(), + ], }, _ => RtpCodecParameters::default(), }; @@ -4470,13 +4844,25 @@ impl RtpReceiver { let params = match kind { MediaKind::Audio => RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: Some("minptime=10;useinbandfec=1".to_string()), + rtcp_fbs: Vec::new(), }, MediaKind::Video => RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: vec![ + "nack".to_string(), + "nack pli".to_string(), + "ccm fir".to_string(), + "goog-remb".to_string(), + "transport-cc".to_string(), + ], }, _ => RtpCodecParameters::default(), }; @@ -4558,6 +4944,10 @@ impl RtpReceiver { tracks.keys().cloned().collect() } + pub fn params(&self) -> RtpCodecParameters { + self.params.lock().unwrap().clone() + } + pub fn set_params(&self, params: RtpCodecParameters) { *self.params.lock().unwrap() = params; } @@ -4944,8 +5334,11 @@ mod tests { let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: Some("minptime=10;useinbandfec=1".to_string()), + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("stream".to_string()) @@ -5221,8 +5614,11 @@ mod tests { let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: Some("minptime=10;useinbandfec=1".to_string()), + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("stream".to_string()) @@ -5632,8 +6028,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("s".to_string()) @@ -5754,8 +6153,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("s".to_string()) @@ -5857,8 +6259,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 0, + codec_name: "PCMU".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 42) .stream_id("s".to_string()) @@ -6112,8 +6517,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 100) .stream_id("s".to_string()) @@ -6143,8 +6551,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 100) .stream_id("s".to_string()) @@ -6175,8 +6586,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 0, + codec_name: "PCMU".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 100) .stream_id("s".to_string()) @@ -6239,8 +6653,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 100) .stream_id("s".to_string()) @@ -6299,8 +6716,11 @@ a=mid:0 let (_, track, _) = sample_track(crate::media::frame::MediaKind::Audio, 48000); let params = RtpCodecParameters { payload_type: 8, + codec_name: "PCMA".to_string(), clock_rate: 8000, channels: 1, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 100) .stream_id("s".to_string()) diff --git a/src/sdp.rs b/src/sdp.rs index 383df05..1275ea1 100644 --- a/src/sdp.rs +++ b/src/sdp.rs @@ -776,6 +776,12 @@ impl MediaSection { video.payload_type, video.codec_name, video.clock_rate )), )); + if let Some(fmtp) = &video.fmtp { + self.attributes.push(Attribute::new( + "fmtp", + Some(format!("{} {}", video.payload_type, fmtp)), + )); + } for fb in &video.rtcp_fbs { self.attributes.push(Attribute::new( "rtcp-fb", diff --git a/tests/codec_negotiation_integration.rs b/tests/codec_negotiation_integration.rs new file mode 100644 index 0000000..2a692d2 --- /dev/null +++ b/tests/codec_negotiation_integration.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use rustrtc::config::{ApplicationCapability, MediaCapabilities}; +use rustrtc::sdp::{ + Attribute, Direction, MediaSection, SdpType, SessionDescription, SessionSection, +}; +use rustrtc::*; + +fn create_offer_with_codec( + kind: MediaKind, + mid: &str, + payload_type: u8, + rtpmap: &str, + fmtp: Option<&str>, +) -> SessionDescription { + let mut desc = SessionDescription::new(SdpType::Offer); + desc.session = SessionSection::default(); + + let mut section = MediaSection::new(kind, mid); + section.direction = Direction::SendRecv; + section.formats.push(payload_type.to_string()); + section.attributes.push(Attribute::new( + "rtpmap", + Some(format!("{payload_type} {rtpmap}")), + )); + if let Some(fmtp) = fmtp { + section.attributes.push(Attribute::new( + "fmtp", + Some(format!("{payload_type} {fmtp}")), + )); + } + desc.media_sections.push(section); + desc +} + +fn find_attr<'a>(section: &'a MediaSection, key: &str) -> Vec<&'a str> { + section + .attributes + .iter() + .filter(|attr| attr.key == key) + .filter_map(|attr| attr.value.as_deref()) + .collect() +} + +#[tokio::test] +async fn opus_fmtp_roundtrip() -> Result<()> { + let config = RtcConfigurationBuilder::new() + .media_capabilities(MediaCapabilities { + audio: vec![AudioCapability::opus()], + video: vec![VideoCapability::default()], + application: Some(ApplicationCapability::default()), + }) + .build(); + let pc = PeerConnection::new(config); + let offer = create_offer_with_codec( + MediaKind::Audio, + "0", + 111, + "opus/48000/2", + Some("minptime=10;useinbandfec=1"), + ); + + pc.set_remote_description(offer).await?; + let answer = pc.create_answer().await?; + pc.set_local_description(answer.clone())?; + + let fmtp_values = find_attr(&answer.media_sections[0], "fmtp"); + assert!( + fmtp_values + .iter() + .any(|value| *value == "111 minptime=10;useinbandfec=1") + ); + assert_eq!( + pc.get_transceivers()[0].get_payload_map()[&111] + .fmtp + .as_deref(), + Some("minptime=10;useinbandfec=1") + ); + Ok(()) +} + +#[tokio::test] +async fn h264_profile_level_id_roundtrip() -> Result<()> { + let config = RtcConfigurationBuilder::new() + .media_capabilities(MediaCapabilities { + audio: vec![AudioCapability::opus()], + video: vec![VideoCapability { + payload_type: 96, + codec_name: "H264".to_string(), + clock_rate: 90000, + fmtp: Some("profile-level-id=42e01f;packetization-mode=1".to_string()), + rtcp_fbs: vec![], + }], + application: Some(ApplicationCapability::default()), + }) + .build(); + let pc = PeerConnection::new(config); + let offer = create_offer_with_codec( + MediaKind::Video, + "1", + 102, + "H264/90000", + Some("profile-level-id=42e01f;packetization-mode=1"), + ); + + pc.set_remote_description(offer).await?; + let answer = pc.create_answer().await?; + pc.set_local_description(answer.clone())?; + + let fmtp_values = find_attr(&answer.media_sections[0], "fmtp"); + assert!( + fmtp_values.iter().any(|value| { + value.contains("profile-level-id=42e01f") && value.starts_with("102 ") + }) + ); + assert_eq!( + pc.get_transceivers()[0].get_payload_map()[&102] + .codec_specific_parameters() + .get("profile-level-id"), + Some(&"42e01f".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn h264_packetization_mode_roundtrip() -> Result<()> { + let config = RtcConfigurationBuilder::new() + .media_capabilities(MediaCapabilities { + audio: vec![AudioCapability::opus()], + video: vec![VideoCapability { + payload_type: 96, + codec_name: "H264".to_string(), + clock_rate: 90000, + fmtp: Some("packetization-mode=1;profile-level-id=42e01f".to_string()), + rtcp_fbs: vec![], + }], + application: Some(ApplicationCapability::default()), + }) + .build(); + let pc = PeerConnection::new(config); + let offer = create_offer_with_codec( + MediaKind::Video, + "2", + 104, + "H264/90000", + Some("packetization-mode=1;profile-level-id=42e01f"), + ); + + pc.set_remote_description(offer).await?; + let answer = pc.create_answer().await?; + pc.set_local_description(answer.clone())?; + + let fmtp_values = find_attr(&answer.media_sections[0], "fmtp"); + assert!( + fmtp_values + .iter() + .any(|value| { value.contains("packetization-mode=1") && value.starts_with("104 ") }) + ); + assert_eq!( + pc.get_transceivers()[0].get_payload_map()[&104] + .codec_specific_parameters() + .get("packetization-mode"), + Some(&"1".to_string()) + ); + Ok(()) +} diff --git a/tests/codec_runtime_model.rs b/tests/codec_runtime_model.rs new file mode 100644 index 0000000..1e7ee19 --- /dev/null +++ b/tests/codec_runtime_model.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use rustrtc::config::{ApplicationCapability, MediaCapabilities}; +use rustrtc::sdp::{ + Attribute, Direction, MediaSection, SdpType, SessionDescription, SessionSection, +}; +use rustrtc::*; + +fn create_codec_offer( + kind: MediaKind, + mid: &str, + payload_type: u8, + rtpmap: &str, + fmtp: Option<&str>, + rtcp_fbs: &[&str], +) -> SessionDescription { + let mut desc = SessionDescription::new(SdpType::Offer); + desc.session = SessionSection::default(); + + let mut section = MediaSection::new(kind, mid); + section.direction = Direction::SendRecv; + section.formats.push(payload_type.to_string()); + section.attributes.push(Attribute::new( + "rtpmap", + Some(format!("{payload_type} {rtpmap}")), + )); + if let Some(fmtp) = fmtp { + section.attributes.push(Attribute::new( + "fmtp", + Some(format!("{payload_type} {fmtp}")), + )); + } + for fb in rtcp_fbs { + section.attributes.push(Attribute::new( + "rtcp-fb", + Some(format!("{payload_type} {fb}")), + )); + } + desc.media_sections.push(section); + desc +} + +#[tokio::test] +async fn extract_payload_map_preserves_codec_name() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + let offer = create_codec_offer(MediaKind::Audio, "0", 111, "opus/48000/2", None, &[]); + + pc.set_remote_description(offer).await?; + + let payload_map = pc.get_transceivers()[0].get_payload_map(); + assert_eq!(payload_map.get(&111).unwrap().codec_name, "opus"); + Ok(()) +} + +#[tokio::test] +async fn extract_payload_map_preserves_fmtp() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + let offer = create_codec_offer( + MediaKind::Video, + "0", + 102, + "H264/90000", + Some("profile-level-id=42e01f;packetization-mode=1"), + &[], + ); + + pc.set_remote_description(offer).await?; + + let payload_map = pc.get_transceivers()[0].get_payload_map(); + let codec = payload_map.get(&102).unwrap(); + assert_eq!( + codec.fmtp.as_deref(), + Some("profile-level-id=42e01f;packetization-mode=1") + ); + assert_eq!( + codec.codec_specific_parameters().get("profile-level-id"), + Some(&"42e01f".to_string()) + ); + assert_eq!( + codec.codec_specific_parameters().get("packetization-mode"), + Some(&"1".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn extract_payload_map_preserves_rtcp_fb() -> Result<()> { + let pc = PeerConnection::new(RtcConfiguration::default()); + let offer = create_codec_offer( + MediaKind::Video, + "0", + 96, + "VP8/90000", + None, + &["nack", "nack pli", "transport-cc"], + ); + + pc.set_remote_description(offer).await?; + + let payload_map = pc.get_transceivers()[0].get_payload_map(); + let codec = payload_map.get(&96).unwrap(); + assert_eq!( + codec.rtcp_fbs, + vec![ + "nack".to_string(), + "nack pli".to_string(), + "transport-cc".to_string() + ] + ); + Ok(()) +} + +#[tokio::test] +async fn answer_rejects_incompatible_codec_pair() -> Result<()> { + let config = RtcConfigurationBuilder::new() + .media_capabilities(MediaCapabilities { + audio: vec![AudioCapability::pcmu()], + video: vec![VideoCapability::default()], + application: Some(ApplicationCapability::default()), + }) + .build(); + let pc = PeerConnection::new(config); + let offer = create_codec_offer(MediaKind::Audio, "0", 111, "opus/48000/2", None, &[]); + + pc.set_remote_description(offer).await?; + let answer = pc.create_answer().await?; + assert_eq!(answer.media_sections[0].port, 0); + + pc.set_local_description(answer)?; + assert!(pc.get_transceivers()[0].get_payload_map().is_empty()); + Ok(()) +} diff --git a/tests/interop_webrtc.rs b/tests/interop_webrtc.rs index 496589c..c04ee3c 100644 --- a/tests/interop_webrtc.rs +++ b/tests/interop_webrtc.rs @@ -119,8 +119,11 @@ async fn interop_vp8_echo() -> Result<()> { let (source, track, _) = rustrtc::media::sample_track(rustrtc::media::MediaKind::Video, 10); let params = rustrtc::RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = rustrtc::peer_connection::RtpSender::builder(track, 12345) .stream_id("stream".to_string()) @@ -303,8 +306,11 @@ async fn interop_vp8_echo_with_pli() -> Result<()> { let (source, track, _) = rustrtc::media::sample_track(rustrtc::media::MediaKind::Video, 10); let params = rustrtc::RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = rustrtc::peer_connection::RtpSender::builder(track, 12345) .stream_id("stream".to_string()) diff --git a/tests/media_flow.rs b/tests/media_flow.rs index c64ac14..7f9149c 100644 --- a/tests/media_flow.rs +++ b/tests/media_flow.rs @@ -25,8 +25,11 @@ async fn test_media_flow_and_pli() -> Result<()> { let source = Arc::new(source); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let _sender = pc1.add_track(track.clone(), params.clone())?; diff --git a/tests/repro_padding.rs b/tests/repro_padding.rs index 0640c11..4edcb98 100644 --- a/tests/repro_padding.rs +++ b/tests/repro_padding.rs @@ -33,8 +33,11 @@ async fn test_padding_packet_drop() -> Result<()> { .stream_id("stream".to_string()) .params(rustrtc::RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }) .build(); t1.set_sender(Some(s1.clone())); @@ -64,8 +67,11 @@ async fn test_padding_packet_drop() -> Result<()> { .stream_id("stream".to_string()) .params(rustrtc::RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }) .build(); t2.set_sender(Some(s2)); diff --git a/tests/rtp_latching_test.rs b/tests/rtp_latching_test.rs index d04f141..f25a586 100644 --- a/tests/rtp_latching_test.rs +++ b/tests/rtp_latching_test.rs @@ -98,8 +98,11 @@ async fn test_rtp_latching() -> Result<()> { let source = Arc::new(source); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let _sender = pc.add_track(track.clone(), params.clone())?; diff --git a/tests/rtp_mode_bug_repro.rs b/tests/rtp_mode_bug_repro.rs index 210f7fa..309406e 100644 --- a/tests/rtp_mode_bug_repro.rs +++ b/tests/rtp_mode_bug_repro.rs @@ -62,8 +62,11 @@ async fn test_rtp_mode_missing_data_bug_repro() -> Result<()> { rustrtc::media::track::sample_track(rustrtc::media::frame::MediaKind::Video, 100); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc_fake.add_track(track, params)?; diff --git a/tests/rtp_mode_extensions_test.rs b/tests/rtp_mode_extensions_test.rs index 1669a89..b03acb8 100644 --- a/tests/rtp_mode_extensions_test.rs +++ b/tests/rtp_mode_extensions_test.rs @@ -25,8 +25,11 @@ async fn test_rtp_mode_no_default_extensions() -> Result<()> { let params_video = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc.add_track(track_video, params_video)?; diff --git a/tests/rtp_mode_scenarios_test.rs b/tests/rtp_mode_scenarios_test.rs index 421e53b..b081044 100644 --- a/tests/rtp_mode_scenarios_test.rs +++ b/tests/rtp_mode_scenarios_test.rs @@ -83,8 +83,11 @@ async fn test_rtp_mode_callee_no_ssrc_signaled() -> Result<()> { rustrtc::media::track::sample_track(rustrtc::media::frame::MediaKind::Video, 100); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc_fake.add_track(track, params)?; @@ -180,8 +183,11 @@ async fn test_rtp_mode_callee_no_ssrc_signaled_stun_first() -> Result<()> { rustrtc::media::track::sample_track(rustrtc::media::frame::MediaKind::Video, 100); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc_fake.add_track(track, params)?; diff --git a/tests/rtp_mode_test.rs b/tests/rtp_mode_test.rs index 79c4bce..90cb665 100644 --- a/tests/rtp_mode_test.rs +++ b/tests/rtp_mode_test.rs @@ -28,8 +28,11 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { let source = Arc::new(source); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let _sender = pc1.add_track(track.clone(), params.clone())?; diff --git a/tests/rtp_reinvite_test.rs b/tests/rtp_reinvite_test.rs index 652c6db..28ac662 100644 --- a/tests/rtp_reinvite_test.rs +++ b/tests/rtp_reinvite_test.rs @@ -28,6 +28,19 @@ fn create_audio_sdp( desc } +fn codec_params( + payload_type: u8, + clock_rate: u32, + channels: u8, +) -> peer_connection::RtpCodecParameters { + peer_connection::RtpCodecParameters { + payload_type, + clock_rate, + channels, + ..peer_connection::RtpCodecParameters::default() + } +} + /// Test basic payload type map update functionality #[tokio::test] async fn test_payload_type_update() { @@ -39,14 +52,7 @@ async fn test_payload_type_update() { // Initial mapping: PT 111 = Opus at 48000Hz let mut initial_map = HashMap::new(); - initial_map.insert( - 111, - peer_connection::RtpCodecParameters { - payload_type: 111, - clock_rate: 48000, - channels: 2, - }, - ); + initial_map.insert(111, codec_params(111, 48000, 2)); transceiver.update_payload_map(initial_map.clone()).unwrap(); // Verify initial state @@ -57,14 +63,7 @@ async fn test_payload_type_update() { // Update mapping: change PT 111 to different parameters let mut updated_map = HashMap::new(); - updated_map.insert( - 111, - peer_connection::RtpCodecParameters { - payload_type: 111, - clock_rate: 16000, - channels: 1, - }, - ); + updated_map.insert(111, codec_params(111, 16000, 1)); transceiver.update_payload_map(updated_map).unwrap(); // Verify updated state @@ -74,14 +73,7 @@ async fn test_payload_type_update() { // Add new PT mapping let mut new_map = HashMap::new(); - new_map.insert( - 120, - peer_connection::RtpCodecParameters { - payload_type: 120, - clock_rate: 90000, - channels: 0, - }, - ); + new_map.insert(120, codec_params(120, 90000, 0)); transceiver.update_payload_map(new_map).unwrap(); // Verify old PT is removed and new one exists @@ -142,14 +134,7 @@ async fn test_concurrent_payload_map_access() { // Initial mapping let mut initial_map = HashMap::new(); - initial_map.insert( - 96, - peer_connection::RtpCodecParameters { - payload_type: 96, - clock_rate: 90000, - channels: 0, - }, - ); + initial_map.insert(96, codec_params(96, 90000, 0)); transceiver.update_payload_map(initial_map).unwrap(); // Spawn multiple reader tasks that read before the write @@ -175,14 +160,7 @@ async fn test_concurrent_payload_map_access() { // Perform a write in the middle tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; let mut new_map = HashMap::new(); - new_map.insert( - 97, - peer_connection::RtpCodecParameters { - payload_type: 97, - clock_rate: 90000, - channels: 0, - }, - ); + new_map.insert(97, codec_params(97, 90000, 0)); transceiver.update_payload_map(new_map).unwrap(); // Wait for all readers @@ -298,14 +276,7 @@ async fn test_reinvite_payload_change() { // Initial payload map let mut initial_map = HashMap::new(); - initial_map.insert( - 111, - peer_connection::RtpCodecParameters { - payload_type: 111, - clock_rate: 48000, - channels: 2, - }, - ); + initial_map.insert(111, codec_params(111, 48000, 2)); transceiver.update_payload_map(initial_map).unwrap(); // Verify initial state @@ -316,14 +287,7 @@ async fn test_reinvite_payload_change() { // Simulate reinvite with different PT let mut reinvite_map = HashMap::new(); - reinvite_map.insert( - 120, - peer_connection::RtpCodecParameters { - payload_type: 120, - clock_rate: 48000, - channels: 2, - }, - ); + reinvite_map.insert(120, codec_params(120, 48000, 2)); transceiver.update_payload_map(reinvite_map).unwrap(); // Verify updated state - old PT removed, new PT added @@ -343,22 +307,8 @@ async fn test_reinvite_comprehensive() { // Stage 1: Initial negotiation let mut initial_payload_map = HashMap::new(); - initial_payload_map.insert( - 96, - peer_connection::RtpCodecParameters { - payload_type: 96, - clock_rate: 90000, - channels: 0, - }, - ); - initial_payload_map.insert( - 97, - peer_connection::RtpCodecParameters { - payload_type: 97, - clock_rate: 90000, - channels: 0, - }, - ); + initial_payload_map.insert(96, codec_params(96, 90000, 0)); + initial_payload_map.insert(97, codec_params(97, 90000, 0)); transceiver.update_payload_map(initial_payload_map).unwrap(); let mut initial_extmap = HashMap::new(); @@ -384,19 +334,11 @@ async fn test_reinvite_comprehensive() { let mut updated_payload_map = HashMap::new(); updated_payload_map.insert( 98, // Changed from 96 - peer_connection::RtpCodecParameters { - payload_type: 98, - clock_rate: 90000, - channels: 0, - }, + codec_params(98, 90000, 0), ); updated_payload_map.insert( 97, // Kept - peer_connection::RtpCodecParameters { - payload_type: 97, - clock_rate: 90000, - channels: 0, - }, + codec_params(97, 90000, 0), ); transceiver.update_payload_map(updated_payload_map).unwrap(); @@ -429,14 +371,7 @@ async fn test_reinvite_comprehensive() { // Stage 3: Another reinvite - simplify to single codec let mut final_payload_map = HashMap::new(); - final_payload_map.insert( - 100, - peer_connection::RtpCodecParameters { - payload_type: 100, - clock_rate: 90000, - channels: 0, - }, - ); + final_payload_map.insert(100, codec_params(100, 90000, 0)); transceiver.update_payload_map(final_payload_map).unwrap(); // Verify final state @@ -499,14 +434,7 @@ fn extract_payload_map_helper( 0 }; - payload_map.insert( - pt, - peer_connection::RtpCodecParameters { - payload_type: pt, - clock_rate, - channels, - }, - ); + payload_map.insert(pt, codec_params(pt, clock_rate, channels)); } } } diff --git a/tests/rtp_sender_test.rs b/tests/rtp_sender_test.rs index d013aa6..065c14f 100644 --- a/tests/rtp_sender_test.rs +++ b/tests/rtp_sender_test.rs @@ -31,8 +31,11 @@ mod tests { // 3. Create RtpSender let params = RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: Some("minptime=10;useinbandfec=1".to_string()), + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("stream".to_string()) @@ -102,8 +105,11 @@ mod tests { // 3. Create RtpSender let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let sender = RtpSender::builder(track, 12345) .stream_id("stream".to_string()) diff --git a/tests/test_remote_addr_and_raw_packet.rs b/tests/test_remote_addr_and_raw_packet.rs index 480d297..b1df8a4 100644 --- a/tests/test_remote_addr_and_raw_packet.rs +++ b/tests/test_remote_addr_and_raw_packet.rs @@ -28,8 +28,11 @@ async fn test_remote_addr_and_raw_packet() -> Result<()> { let source = Arc::new(source); let params = RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; let _sender = pc1.add_track(track.clone(), params.clone())?; From 717eb1b138fc385f7cd02c15884b3211377918b7 Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 00:24:14 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E8=83=BD=E5=8A=9B=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - 新增基于 headless_chrome 的 browser_interop 浏览器集成测试 - 覆盖 pranswer/answer、Opus fmtp 协商和 DataChannel 消息往返 - 将三条浏览器子用例接入 signaling、datachannel、media 回归分组 Files: - Cargo.toml - tests/browser_interop.rs - scripts/run_regression_group.sh Verification: - cargo fmt --all - cargo test --test browser_interop - ./scripts/run_regression_group.sh signaling - ./scripts/run_regression_group.sh datachannel - ./scripts/run_regression_group.sh media Result: - pass Risk: - 浏览器用例需要在非沙箱环境运行,沙箱内会受本地 Chrome 调试端口绑定限制 --- Cargo.toml | 1 + scripts/run_regression_group.sh | 3 + tests/browser_interop.rs | 572 ++++++++++++++++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 tests/browser_interop.rs diff --git a/Cargo.toml b/Cargo.toml index ac316f0..6ae21fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } indicatif = "0.18.3" rustls = { version = "0.23.36" } serial_test = "3" +headless_chrome = "1" [features] default = [] diff --git a/scripts/run_regression_group.sh b/scripts/run_regression_group.sh index a65d186..6bedf31 100755 --- a/scripts/run_regression_group.sh +++ b/scripts/run_regression_group.sh @@ -26,11 +26,13 @@ run_group() { signaling) run_cmd cargo test --test regression_baseline regression_signaling_entrypoints_exist run_cmd cargo test --test rtp_reinvite_comprehensive_test + run_cmd cargo test --test browser_interop browser_pranswer_then_answer_connects ;; datachannel) run_cmd cargo test --test regression_baseline regression_datachannel_entrypoints_exist run_cmd cargo test --test ordered_channel_test run_cmd cargo test --test interop_datachannel + run_cmd cargo test --test browser_interop browser_datachannel_message_roundtrip ;; network) run_cmd cargo test --test regression_baseline regression_network_entrypoints_exist @@ -40,6 +42,7 @@ run_group() { run_cmd cargo test --test regression_baseline regression_media_entrypoints_exist run_cmd cargo test --test media_flow run_cmd cargo test --test interop_simulcast + run_cmd cargo test --test browser_interop browser_offer_preserves_opus_fmtp_in_answer ;; stats) run_cmd cargo test --test regression_baseline regression_stats_entrypoints_exist diff --git a/tests/browser_interop.rs b/tests/browser_interop.rs new file mode 100644 index 0000000..bb8508d --- /dev/null +++ b/tests/browser_interop.rs @@ -0,0 +1,572 @@ +use std::ffi::OsStr; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use headless_chrome::{Browser, LaunchOptionsBuilder, Tab}; +use rustrtc::config::{ApplicationCapability, MediaCapabilities}; +use rustrtc::transports::datachannel::{DataChannel, DataChannelEvent}; +use rustrtc::{ + AudioCapability, MediaSection, PeerConnection, PeerConnectionEvent, RtcConfiguration, + RtcConfigurationBuilder, SdpType, SessionDescription, +}; +use serial_test::serial; +use tokio::time::timeout; + +const BROWSER_TIMEOUT: Duration = Duration::from_secs(15); +const CHANNEL_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, serde::Deserialize)] +struct BrowserDescription { + #[serde(rename = "type")] + kind: String, + sdp: String, + #[serde(rename = "signalingState")] + signaling_state: String, +} + +#[derive(Debug, serde::Deserialize)] +struct BrowserPeerState { + #[serde(rename = "signalingState")] + signaling_state: String, + #[serde(rename = "connectionState")] + connection_state: String, +} + +struct BrowserPeer { + _browser: Browser, + tab: Arc, +} + +impl BrowserPeer { + fn launch() -> Result> { + let Some(path) = find_browser_binary() else { + eprintln!("Skipping browser interop test: no Chrome/Chromium binary found"); + return Ok(None); + }; + + let options = LaunchOptionsBuilder::default() + .path(Some(path)) + .port(Some(allocate_debug_port()?)) + .headless(true) + .sandbox(false) + .enable_gpu(false) + .idle_browser_timeout(Duration::from_secs(120)) + .args(vec![ + OsStr::new("--disable-features=WebRtcHideLocalIpsWithMdns"), + OsStr::new("--use-fake-ui-for-media-stream"), + OsStr::new("--use-fake-device-for-media-stream"), + OsStr::new("--no-first-run"), + OsStr::new("--no-default-browser-check"), + ]) + .build() + .map_err(|err| anyhow!("failed to build Chrome launch options: {err}"))?; + let browser = Browser::new(options).context("failed to launch Chrome")?; + let tab = browser.new_tab().context("failed to create browser tab")?; + tab.navigate_to("about:blank") + .context("failed to open blank page")?; + tab.wait_until_navigated() + .context("failed to finish browser navigation")?; + + Ok(Some(Self { + _browser: browser, + tab, + })) + } + + fn setup_peer(&self, add_audio: bool, add_data_channel: bool) -> Result<()> { + let audio_setup = if add_audio { + "pc.addTransceiver('audio', { direction: 'sendrecv' });" + } else { + "" + }; + let data_channel_setup = if add_data_channel { + r#" + const dc = pc.createDataChannel("browser-channel"); + state.dc = dc; + dc.onmessage = (event) => state.messages.push(String(event.data)); + "# + } else { + "" + }; + + self.eval_json::(&format!( + r#"(async () => {{ + const pc = new RTCPeerConnection(); + const state = {{ + pc, + dc: null, + messages: [], + }}; + pc.onconnectionstatechange = () => {{ + state.lastConnectionState = pc.connectionState; + }}; + pc.onsignalingstatechange = () => {{ + state.lastSignalingState = pc.signalingState; + }}; + {audio_setup} + {data_channel_setup} + window.__rustrtc = state; + return true; + }})()"# + ))?; + Ok(()) + } + + fn create_offer(&self) -> Result { + let desc: BrowserDescription = self.eval_json( + r#"(async () => { + const pc = window.__rustrtc.pc; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + if (pc.iceGatheringState !== "complete") { + await new Promise((resolve) => { + const onState = () => { + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", onState); + resolve(); + } + }; + pc.addEventListener("icegatheringstatechange", onState); + setTimeout(() => { + pc.removeEventListener("icegatheringstatechange", onState); + resolve(); + }, 5000); + }); + } + return { + type: pc.localDescription.type, + sdp: pc.localDescription.sdp, + signalingState: pc.signalingState, + }; + })()"#, + )?; + if desc.kind != "offer" { + bail!("expected browser offer, got {}", desc.kind); + } + assert_eq!(desc.signaling_state, "have-local-offer"); + SessionDescription::parse(SdpType::Offer, &desc.sdp) + .context("failed to parse browser offer SDP") + } + + fn set_remote_description(&self, desc: &SessionDescription) -> Result { + let kind = sdp_type_string(desc.sdp_type); + let sdp = desc.to_sdp_string(); + self.eval_json(&format!( + r#"(async () => {{ + const pc = window.__rustrtc.pc; + await pc.setRemoteDescription({{ + type: {kind}, + sdp: {sdp}, + }}); + return {{ + signalingState: pc.signalingState, + connectionState: pc.connectionState, + }}; + }})()"#, + kind = serde_json::to_string(kind)?, + sdp = serde_json::to_string(&sdp)?, + )) + } + + fn signaling_state(&self) -> Result { + self.eval_json(r#"(async () => window.__rustrtc.pc.signalingState)()"#) + } + + fn wait_connected(&self) -> Result<()> { + self.wait_for_js_condition( + r#"(async () => ({ + connectionState: window.__rustrtc.pc.connectionState, + signalingState: window.__rustrtc.pc.signalingState, + }))()"#, + |state: &BrowserPeerState| match state.connection_state.as_str() { + "connected" => Ok(true), + "failed" | "closed" => bail!( + "browser peer reached terminal state {}", + state.connection_state + ), + _ => Ok(false), + }, + ) + } + + fn wait_data_channel_open(&self) -> Result<()> { + self.wait_for_js_condition( + r#"(async () => ({ + readyState: window.__rustrtc.dc ? window.__rustrtc.dc.readyState : null + }))()"#, + |value: &serde_json::Value| { + let state = value + .get("readyState") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + match state { + "open" => Ok(true), + "closing" | "closed" => bail!("browser data channel is {state}"), + _ => Ok(false), + } + }, + ) + } + + fn send_data_channel_text(&self, text: &str) -> Result<()> { + self.eval_json::(&format!( + r#"(async () => {{ + window.__rustrtc.dc.send({text}); + return true; + }})()"#, + text = serde_json::to_string(text)?, + ))?; + Ok(()) + } + + fn wait_for_message(&self, expected: &str) -> Result<()> { + let expected = expected.to_string(); + self.wait_for_js_condition( + r#"(async () => window.__rustrtc.messages)()"#, + move |messages: &Vec| Ok(messages.iter().any(|msg| msg == &expected)), + ) + } + + fn close(&self) -> Result<()> { + self.eval_json::( + r#"(async () => { + if (window.__rustrtc?.dc) { + window.__rustrtc.dc.close(); + } + if (window.__rustrtc?.pc) { + window.__rustrtc.pc.close(); + } + return true; + })()"#, + )?; + Ok(()) + } + + fn eval_json(&self, expression: &str) -> Result { + // The CDP evaluate API returns objects by reference, so stringify in-page and decode + // in Rust to keep the helper stable for nested JS results like SDP payloads. + let script = format!( + r#"(async () => {{ + const value = await ({expression}); + return JSON.stringify(value); + }})()"# + ); + let remote = self + .tab + .evaluate(&script, true) + .context("failed to evaluate browser script")?; + let value = remote + .value + .context("browser script returned no serializable value")?; + let json_text = value + .as_str() + .context("browser script did not return a JSON string")?; + serde_json::from_str(json_text).context("failed to decode browser JSON result") + } + + fn wait_for_js_condition(&self, expression: &str, mut predicate: F) -> Result<()> + where + T: serde::de::DeserializeOwned, + F: FnMut(&T) -> Result, + { + let started = std::time::Instant::now(); + loop { + let value: T = self.eval_json(expression)?; + if predicate(&value)? { + return Ok(()); + } + if started.elapsed() > BROWSER_TIMEOUT { + bail!("timed out waiting for browser condition"); + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +fn find_browser_binary() -> Option { + if let Ok(path) = std::env::var("CHROME_BIN") { + let path = PathBuf::from(path); + if path.exists() { + return Some(path); + } + } + + for candidate in [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + ] { + let path = PathBuf::from(candidate); + if path.exists() { + return Some(path); + } + } + + find_on_path(["google-chrome", "chromium", "chromium-browser", "chrome"]) +} + +fn allocate_debug_port() -> Result { + // Prefer an OS-assigned loopback port so grouped regression runs don't depend on + // headless_chrome's built-in 8000-9000 scan window. + let listener = + TcpListener::bind(("127.0.0.1", 0)).context("failed to reserve Chrome debug port")?; + let port = listener + .local_addr() + .context("failed to inspect Chrome debug port")? + .port(); + drop(listener); + Ok(port) +} + +fn find_on_path(names: [&str; N]) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + for name in names { + let candidate = dir.join(name); + if candidate.exists() { + return Some(candidate); + } + #[cfg(windows)] + { + let candidate = dir.join(format!("{name}.exe")); + if candidate.exists() { + return Some(candidate); + } + } + } + } + None +} + +fn sdp_type_string(sdp_type: SdpType) -> &'static str { + match sdp_type { + SdpType::Offer => "offer", + SdpType::Pranswer => "pranswer", + SdpType::Answer => "answer", + SdpType::Rollback => "rollback", + } +} + +fn find_attr_values<'a>(section: &'a MediaSection, key: &str) -> Vec<&'a str> { + section + .attributes + .iter() + .filter(|attr| attr.key == key) + .filter_map(|attr| attr.value.as_deref()) + .collect() +} + +fn find_codec_payload_type(section: &MediaSection, codec_name: &str) -> Option { + let codec_name = codec_name.to_ascii_lowercase(); + section + .attributes + .iter() + .filter(|attr| attr.key == "rtpmap") + .filter_map(|attr| attr.value.as_deref()) + .find_map(|value| { + let (payload_type, codec) = value.split_once(' ')?; + let codec = codec.to_ascii_lowercase(); + if codec.starts_with(&format!("{codec_name}/")) { + payload_type.parse().ok() + } else { + None + } + }) +} + +async fn wait_for_incoming_data_channel(pc: &PeerConnection) -> Result> { + loop { + let event = timeout(CHANNEL_TIMEOUT, pc.recv()) + .await + .context("timed out waiting for incoming peer event")? + .ok_or_else(|| anyhow!("peer connection event channel closed"))?; + match event { + PeerConnectionEvent::DataChannel(dc) => return Ok(dc), + PeerConnectionEvent::Track(_) => {} + } + } +} + +async fn wait_for_rust_data_channel_open(dc: &Arc) -> Result<()> { + loop { + let event = timeout(CHANNEL_TIMEOUT, dc.recv()) + .await + .context("timed out waiting for Rust data channel event")? + .ok_or_else(|| anyhow!("Rust data channel event channel closed"))?; + match event { + DataChannelEvent::Open => return Ok(()), + DataChannelEvent::Close => bail!("Rust data channel closed before opening"), + DataChannelEvent::Message(_) => {} + } + } +} + +async fn wait_for_rust_data_channel_message(dc: &Arc, expected: &str) -> Result<()> { + loop { + let event = timeout(CHANNEL_TIMEOUT, dc.recv()) + .await + .context("timed out waiting for Rust data channel message")? + .ok_or_else(|| anyhow!("Rust data channel event channel closed"))?; + match event { + DataChannelEvent::Message(data) => { + let text = String::from_utf8(data.to_vec()) + .context("Rust data channel received non-UTF8 payload")?; + if text == expected { + return Ok(()); + } + } + DataChannelEvent::Close => bail!("Rust data channel closed before message arrived"), + DataChannelEvent::Open => {} + } + } +} + +fn init_browser_test_runtime() { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + let _ = env_logger::builder().is_test(true).try_init(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn browser_pranswer_then_answer_connects() -> Result<()> { + init_browser_test_runtime(); + let Some(browser) = BrowserPeer::launch()? else { + return Ok(()); + }; + browser.setup_peer(true, false)?; + + let offer = browser.create_offer()?; + let pc = PeerConnection::new(RtcConfiguration::default()); + pc.set_remote_description(offer).await?; + + let mut pranswer = pc.create_answer().await?; + pranswer.sdp_type = SdpType::Pranswer; + pc.set_local_description(pranswer)?; + pc.wait_for_gathering_complete().await; + let pranswer = pc + .local_description() + .context("missing local pranswer after gathering")?; + + let browser_state = browser.set_remote_description(&pranswer)?; + assert_eq!(browser_state.signaling_state, "have-remote-pranswer"); + assert_eq!(browser.signaling_state()?, "have-remote-pranswer"); + + let answer = pc.create_answer().await?; + pc.set_local_description(answer)?; + let answer = pc + .local_description() + .context("missing final local answer")?; + + let browser_state = browser.set_remote_description(&answer)?; + assert_eq!(browser_state.signaling_state, "stable"); + + pc.wait_for_connected().await?; + browser + .wait_connected() + .context("waiting for browser peer to reach connected")?; + + browser.close()?; + pc.close(); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn browser_offer_preserves_opus_fmtp_in_answer() -> Result<()> { + init_browser_test_runtime(); + let Some(browser) = BrowserPeer::launch()? else { + return Ok(()); + }; + browser.setup_peer(true, false)?; + + let offer = browser.create_offer()?; + let opus_payload_type = find_codec_payload_type(&offer.media_sections[0], "opus") + .context("browser offer did not advertise opus")?; + + let config = RtcConfigurationBuilder::new() + .media_capabilities(MediaCapabilities { + audio: vec![AudioCapability::opus()], + video: vec![], + application: Some(ApplicationCapability::default()), + }) + .build(); + let pc = PeerConnection::new(config); + pc.set_remote_description(offer).await?; + + let answer = pc.create_answer().await?; + pc.set_local_description(answer)?; + pc.wait_for_gathering_complete().await; + let answer = pc + .local_description() + .context("missing local answer after gathering")?; + + let fmtp_values = find_attr_values(&answer.media_sections[0], "fmtp"); + let expected_fmtp = format!("{opus_payload_type} minptime=10;useinbandfec=1"); + assert!(fmtp_values.iter().any(|value| *value == expected_fmtp)); + assert_eq!( + pc.get_transceivers()[0].get_payload_map()[&opus_payload_type] + .fmtp + .as_deref(), + Some("minptime=10;useinbandfec=1") + ); + + let browser_state = browser.set_remote_description(&answer)?; + assert_eq!(browser_state.signaling_state, "stable"); + + browser.close()?; + pc.close(); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn browser_datachannel_message_roundtrip() -> Result<()> { + init_browser_test_runtime(); + let Some(browser) = BrowserPeer::launch()? else { + return Ok(()); + }; + browser.setup_peer(false, true)?; + + let offer = browser.create_offer()?; + let pc = PeerConnection::new(RtcConfiguration::default()); + pc.set_remote_description(offer).await?; + + let answer = pc.create_answer().await?; + pc.set_local_description(answer)?; + pc.wait_for_gathering_complete().await; + let answer = pc + .local_description() + .context("missing local answer after gathering")?; + browser.set_remote_description(&answer)?; + + pc.wait_for_connected().await?; + browser + .wait_connected() + .context("waiting for browser peer to reach connected")?; + + let data_channel = wait_for_incoming_data_channel(&pc).await?; + wait_for_rust_data_channel_open(&data_channel).await?; + browser + .wait_data_channel_open() + .context("waiting for browser data channel to open")?; + + pc.send_text(data_channel.id, "hello from rust").await?; + browser + .wait_for_message("hello from rust") + .context("waiting for browser to receive Rust data channel message")?; + + browser.send_data_channel_text("hello from chrome")?; + wait_for_rust_data_channel_message(&data_channel, "hello from chrome").await?; + + browser.close()?; + pc.close(); + Ok(()) +} From 228f5bf7ceffa3c0c4b28c4db1e3dde8495554aa Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 01:35:26 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E5=B9=B6=E6=89=93=E9=80=9A=20DTLS=20fingerpr?= =?UTF-8?q?int=20=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-06 config/certificate-plumbing Summary: - 支持从 PEM 证书链和私钥加载运行时 DTLS 证书 - 新增 PeerConnection::try_new,给错误证书配置提供明确失败路径 - 未配置证书时继续使用自签证书,并保持 SDP fingerprint 生成 Files: - src/peer_connection.rs - src/transports/dtls/mod.rs - tests/certificate_config.rs - tests/certificate_fingerprint_integration.rs Verification: - cargo fmt --all - cargo test --test certificate_config - cargo test --test certificate_fingerprint_integration - cargo test --test regression_baseline regression_security_entrypoints_exist Result: - pass Risk: - 当前运行时只支持单个证书配置 - 私钥加载路径当前要求 PKCS#8 PEM --- src/peer_connection.rs | 38 +++++++- src/transports/dtls/mod.rs | 71 +++++++++++++++ tests/certificate_config.rs | 93 ++++++++++++++++++++ tests/certificate_fingerprint_integration.rs | 69 +++++++++++++++ 4 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 tests/certificate_config.rs create mode 100644 tests/certificate_fingerprint_integration.rs diff --git a/src/peer_connection.rs b/src/peer_connection.rs index a71b64c..98dddbc 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -358,12 +358,44 @@ fn map_crypto_suite(suite: &str) -> RtcResult { } } +fn resolve_dtls_certificate(config: &RtcConfiguration) -> RtcResult> { + match config.certificates.as_slice() { + [] => dtls::generate_certificate() + .map(Arc::new) + .map_err(|e| RtcError::Internal(format!("failed to generate certificate: {}", e))), + [certificate] => { + let private_key_pem = certificate.private_key_pem.as_deref().ok_or_else(|| { + RtcError::InvalidConfiguration( + "certificate.private_key_pem is required when certificates are configured" + .into(), + ) + })?; + // The runtime negotiates a single DTLS identity today, so reject + // ambiguous certificate lists instead of silently ignoring them. + dtls::load_certificate_from_pem(&certificate.pem_chain, private_key_pem) + .map(Arc::new) + .map_err(|e| { + RtcError::InvalidConfiguration(format!( + "failed to load configured certificate: {}", + e + )) + }) + } + _ => Err(RtcError::InvalidConfiguration( + "multiple certificate configurations are not supported".into(), + )), + } +} + impl PeerConnection { pub fn new(config: RtcConfiguration) -> Self { + Self::try_new(config).expect("failed to create peer connection") + } + + pub fn try_new(config: RtcConfiguration) -> RtcResult { let is_rtp_mode = config.transport_mode == TransportMode::Rtp; let (ice_transport, ice_runner) = IceTransport::new(config.clone()); - let certificate = - Arc::new(dtls::generate_certificate().expect("failed to generate certificate")); + let certificate = resolve_dtls_certificate(&config)?; let dtls_fingerprint = dtls::fingerprint(&certificate); let (signaling_state_tx, signaling_state_rx) = watch::channel(SignalingState::Stable); @@ -454,7 +486,7 @@ impl PeerConnection { tokio::join!(gathering_loop, dtls_loop, ice_runner); }); } - pc + Ok(pc) } pub fn config(&self) -> &RtcConfiguration { diff --git a/src/transports/dtls/mod.rs b/src/transports/dtls/mod.rs index 0d7fad1..a3ff105 100644 --- a/src/transports/dtls/mod.rs +++ b/src/transports/dtls/mod.rs @@ -20,10 +20,13 @@ use p256::{PublicKey, ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoin use rand_core::OsRng; use rcgen::generate_simple_self_signed; use sha2::{Digest, Sha256}; +use std::io::Cursor; use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use x509_parser::certificate::X509Certificate; +use x509_parser::error::PEMError; +use x509_parser::pem::Pem; use x509_parser::prelude::FromDer; use x509_parser::public_key::PublicKey as X509PublicKey; @@ -47,6 +50,32 @@ pub fn generate_certificate() -> Result { }) } +pub fn load_certificate_from_pem( + pem_chain: &[String], + private_key_pem: &str, +) -> Result { + let mut certificate_chain = Vec::new(); + for pem_bundle in pem_chain { + certificate_chain.extend(parse_certificate_pem_bundle(pem_bundle)?); + } + + if certificate_chain.is_empty() { + return Err(anyhow::anyhow!( + "configured certificate chain did not contain any CERTIFICATE PEM blocks" + )); + } + + let signing_key = SigningKey::from_pkcs8_pem(private_key_pem) + .map_err(|e| anyhow::anyhow!("failed to parse PKCS#8 private key: {}", e))?; + ensure_certificate_matches_private_key(&certificate_chain[0], &signing_key)?; + + Ok(Certificate { + certificate: certificate_chain, + private_key: private_key_pem.to_string(), + dtls_signing_key: Some(Arc::new(signing_key)), + }) +} + pub fn fingerprint(cert: &Certificate) -> String { fingerprint_from_der(&cert.certificate[0]) } @@ -79,6 +108,48 @@ fn certificate_public_key(certificate_der: &[u8]) -> Result { } } +fn parse_certificate_pem_bundle(pem_bundle: &str) -> Result>> { + let mut cursor = Cursor::new(pem_bundle.as_bytes()); + let mut certificates = Vec::new(); + + loop { + match Pem::read(&mut cursor) { + Ok((pem, _)) => { + if pem.label == "CERTIFICATE" { + X509Certificate::from_der(&pem.contents).map_err(|e| { + anyhow::anyhow!("failed to parse certificate PEM block: {:?}", e) + })?; + certificates.push(pem.contents); + } + } + Err(PEMError::MissingHeader) => break, + Err(err) => { + return Err(anyhow::anyhow!( + "failed to parse certificate PEM bundle: {:?}", + err + )); + } + } + } + + Ok(certificates) +} + +fn ensure_certificate_matches_private_key( + certificate_der: &[u8], + signing_key: &SigningKey, +) -> Result<()> { + let certificate_key = certificate_public_key(certificate_der)?; + let certificate_bytes = certificate_key.to_encoded_point(false); + let signing_key_bytes = signing_key.verifying_key().to_encoded_point(false); + if certificate_bytes.as_bytes() != signing_key_bytes.as_bytes() { + return Err(anyhow::anyhow!( + "certificate public key does not match configured private key" + )); + } + Ok(()) +} + pub(crate) fn verify_server_key_exchange_signature( certificate_der: &[u8], client_random: &[u8], diff --git a/tests/certificate_config.rs b/tests/certificate_config.rs new file mode 100644 index 0000000..eb60563 --- /dev/null +++ b/tests/certificate_config.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use rustrtc::transports::dtls; +use rustrtc::{ + CertificateConfig, MediaKind, PeerConnection, RtcConfiguration, RtcConfigurationBuilder, + RtcError, TransceiverDirection, +}; + +fn certificate_config_from_dtls(cert: &dtls::Certificate) -> CertificateConfig { + CertificateConfig { + pem_chain: cert + .certificate + .iter() + .map(|der| der_to_pem("CERTIFICATE", der)) + .collect(), + private_key_pem: Some(cert.private_key.clone()), + } +} + +fn der_to_pem(label: &str, der: &[u8]) -> String { + let base64 = BASE64_STANDARD.encode(der); + let body = base64 + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n"); + format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") +} + +fn init_test_runtime() { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); +} + +#[tokio::test] +async fn pem_certificate_load_success() -> Result<()> { + init_test_runtime(); + + let certificate = dtls::generate_certificate()?; + let expected_fingerprint = dtls::fingerprint(&certificate); + let config = RtcConfigurationBuilder::new() + .certificate(certificate_config_from_dtls(&certificate)) + .build(); + + let pc = PeerConnection::try_new(config)?; + pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); + let offer = pc.create_offer().await?; + let fingerprint = offer + .dtls_fingerprint()? + .expect("offer should contain DTLS fingerprint"); + + assert_eq!(fingerprint.algorithm, "sha-256"); + assert_eq!(fingerprint.value, expected_fingerprint); + Ok(()) +} + +#[tokio::test] +async fn pem_key_mismatch_fails() -> Result<()> { + init_test_runtime(); + + let certificate = dtls::generate_certificate()?; + let wrong_key = dtls::generate_certificate()?; + let mut config = certificate_config_from_dtls(&certificate); + config.private_key_pem = Some(wrong_key.private_key.clone()); + + let err = + match PeerConnection::try_new(RtcConfigurationBuilder::new().certificate(config).build()) { + Ok(_) => panic!("mismatched certificate and key should be rejected"), + Err(err) => err, + }; + assert!( + matches!(err, RtcError::InvalidConfiguration(ref message) if message.contains("does not match")), + "unexpected error: {err}" + ); + Ok(()) +} + +#[tokio::test] +async fn default_self_signed_fallback_works() -> Result<()> { + init_test_runtime(); + + let pc = PeerConnection::try_new(RtcConfiguration::default())?; + pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); + let offer = pc.create_offer().await?; + let fingerprint = offer + .dtls_fingerprint()? + .expect("default offer should contain DTLS fingerprint"); + + assert_eq!(fingerprint.algorithm, "sha-256"); + assert!(!fingerprint.value.is_empty()); + Ok(()) +} diff --git a/tests/certificate_fingerprint_integration.rs b/tests/certificate_fingerprint_integration.rs new file mode 100644 index 0000000..4e8cf89 --- /dev/null +++ b/tests/certificate_fingerprint_integration.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use rustrtc::transports::dtls; +use rustrtc::{ + CertificateConfig, MediaKind, PeerConnection, RtcConfigurationBuilder, TransceiverDirection, +}; + +fn certificate_config_from_chain(chain: &[Vec], private_key_pem: &str) -> CertificateConfig { + CertificateConfig { + pem_chain: chain + .iter() + .map(|der| der_to_pem("CERTIFICATE", der)) + .collect(), + private_key_pem: Some(private_key_pem.to_string()), + } +} + +fn der_to_pem(label: &str, der: &[u8]) -> String { + let base64 = BASE64_STANDARD.encode(der); + let body = base64 + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n"); + format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") +} + +fn init_test_runtime() { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); +} + +#[tokio::test] +async fn configured_certificate_fingerprint_matches_generated_sdp() -> Result<()> { + init_test_runtime(); + + let leaf = dtls::generate_certificate()?; + let extra_chain_cert = dtls::generate_certificate()?; + let expected_fingerprint = dtls::fingerprint(&leaf); + + let mut chain = leaf.certificate.clone(); + chain.extend(extra_chain_cert.certificate.clone()); + let config = RtcConfigurationBuilder::new() + .certificate(certificate_config_from_chain(&chain, &leaf.private_key)) + .build(); + + let pc = PeerConnection::try_new(config)?; + pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); + let offer = pc.create_offer().await?; + pc.set_local_description(offer)?; + pc.wait_for_gathering_complete().await; + + let local_description = pc + .local_description() + .expect("local description should be stored after offer"); + let fingerprint = local_description + .dtls_fingerprint()? + .expect("generated SDP should contain DTLS fingerprint"); + + assert_eq!(fingerprint.algorithm, "sha-256"); + assert_eq!(fingerprint.value, expected_fingerprint); + assert!( + local_description + .to_sdp_string() + .contains(&format!("a=fingerprint:sha-256 {}", expected_fingerprint)) + ); + Ok(()) +} From 0c807c4f8c6a4b7302f48683af36e730102c81bf Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 02:14:42 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E5=AF=B9=E9=BD=90=20DataChannel=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=9C=89=E5=BA=8F=E8=AF=AD=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BA=92=E6=93=8D=E4=BD=9C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-07 interop/datachannel-default-ordering Summary: - 将 DataChannelConfig 默认值改为 ordered=true,和浏览器默认语义保持一致 - 抽出并测试 DCEP channel_type 选择逻辑,确保 ordered/unordered 与可靠性组合编码正确 - 新增跨栈测试验证默认 ordered reliable 和显式 unordered 两条路径 Files: - src/transports/datachannel.rs - src/transports/sctp.rs - tests/ordered_channel_test.rs - tests/datachannel_default_semantics.rs Verification: - cargo test --lib test_dcep_channel_type_matches_ordering_and_reliability - cargo test --test ordered_channel_test - cargo test --test datachannel_default_semantics - cargo test --test interop_datachannel Result: - pass Risk: - webrtc-rs 互操作测试需要在非沙箱环境运行,本地 ICE/UDP 受限时会出现假失败 --- src/transports/datachannel.rs | 17 +- src/transports/sctp.rs | 109 +++++++++--- tests/datachannel_default_semantics.rs | 235 +++++++++++++++++++++++++ tests/ordered_channel_test.rs | 24 +++ 4 files changed, 361 insertions(+), 24 deletions(-) create mode 100644 tests/datachannel_default_semantics.rs diff --git a/src/transports/datachannel.rs b/src/transports/datachannel.rs index df1f9c0..c05aa64 100644 --- a/src/transports/datachannel.rs +++ b/src/transports/datachannel.rs @@ -130,10 +130,11 @@ impl From for DataChannelState { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct DataChannelConfig { pub label: String, pub protocol: String, + // Match browser/WebRTC defaults: a channel without overrides is ordered and reliable. pub ordered: bool, pub max_retransmits: Option, pub max_packet_life_time: Option, @@ -141,6 +142,20 @@ pub struct DataChannelConfig { pub negotiated: Option, } +impl Default for DataChannelConfig { + fn default() -> Self { + Self { + label: String::new(), + protocol: String::new(), + ordered: true, + max_retransmits: None, + max_packet_life_time: None, + max_payload_size: None, + negotiated: None, + } + } +} + pub struct DataChannel { pub id: u16, pub label: String, diff --git a/src/transports/sctp.rs b/src/transports/sctp.rs index 60f0112..ead6c2b 100644 --- a/src/transports/sctp.rs +++ b/src/transports/sctp.rs @@ -134,6 +134,24 @@ fn ssn_gt(a: u16, b: u16) -> bool { (a.wrapping_sub(b) as i16) > 0 } +fn dcep_channel_type(dc: &DataChannel) -> u8 { + if dc.ordered { + if dc.max_retransmits.is_some() { + 0x01 // DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT + } else if dc.max_packet_life_time.is_some() { + 0x02 // DATA_CHANNEL_PARTIAL_RELIABLE_TIMED + } else { + 0x00 // DATA_CHANNEL_RELIABLE + } + } else if dc.max_retransmits.is_some() { + 0x81 // DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT_UNORDERED + } else if dc.max_packet_life_time.is_some() { + 0x82 // DATA_CHANNEL_PARTIAL_RELIABLE_TIMED_UNORDERED + } else { + 0x80 // DATA_CHANNEL_RELIABLE_UNORDERED + } +} + #[derive(Debug, Clone)] pub(crate) struct OutboundChunk { pub(crate) stream_id: u16, @@ -2567,20 +2585,17 @@ impl SctpInner { if !found { // Create new channel + let reliability_mode = open.channel_type & 0x03; let config = DataChannelConfig { label: open.label.clone(), protocol: open.protocol, ordered: (open.channel_type & 0x80) == 0, - max_retransmits: if (open.channel_type & 0x03) == 0x01 - || (open.channel_type & 0x03) == 0x81 - { + max_retransmits: if reliability_mode == 0x01 { Some(open.reliability_parameter as u16) } else { None }, - max_packet_life_time: if (open.channel_type & 0x03) == 0x02 - || (open.channel_type & 0x03) == 0x82 - { + max_packet_life_time: if reliability_mode == 0x02 { Some(open.reliability_parameter as u16) } else { None @@ -3261,23 +3276,7 @@ impl SctpInner { } pub async fn send_dcep_open(&self, dc: &DataChannel) -> Result<()> { - let channel_type = if dc.ordered { - if dc.max_retransmits.is_some() { - 0x01 // DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT - } else if dc.max_packet_life_time.is_some() { - 0x02 // DATA_CHANNEL_PARTIAL_RELIABLE_TIMED - } else { - 0x00 // DATA_CHANNEL_RELIABLE - } - } else { - if dc.max_retransmits.is_some() { - 0x81 // DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT_UNORDERED - } else if dc.max_packet_life_time.is_some() { - 0x82 // DATA_CHANNEL_PARTIAL_RELIABLE_TIMED_UNORDERED - } else { - 0x80 // DATA_CHANNEL_RELIABLE_UNORDERED - } - }; + let channel_type = dcep_channel_type(dc); let reliability_parameter = if let Some(r) = dc.max_retransmits { r as u32 @@ -3385,6 +3384,70 @@ mod tests { use std::collections::BTreeMap; use std::time::Duration; + #[test] + fn test_dcep_channel_type_matches_ordering_and_reliability() { + let reliable_ordered = DataChannel::new( + 0, + DataChannelConfig { + label: "ordered".into(), + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&reliable_ordered), 0x00); + + let reliable_unordered = DataChannel::new( + 1, + DataChannelConfig { + label: "unordered".into(), + ordered: false, + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&reliable_unordered), 0x80); + + let rexmit_ordered = DataChannel::new( + 2, + DataChannelConfig { + label: "rexmit-ordered".into(), + max_retransmits: Some(3), + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&rexmit_ordered), 0x01); + + let rexmit_unordered = DataChannel::new( + 3, + DataChannelConfig { + label: "rexmit-unordered".into(), + ordered: false, + max_retransmits: Some(3), + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&rexmit_unordered), 0x81); + + let timed_ordered = DataChannel::new( + 4, + DataChannelConfig { + label: "timed-ordered".into(), + max_packet_life_time: Some(50), + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&timed_ordered), 0x02); + + let timed_unordered = DataChannel::new( + 5, + DataChannelConfig { + label: "timed-unordered".into(), + ordered: false, + max_packet_life_time: Some(50), + ..Default::default() + }, + ); + assert_eq!(dcep_channel_type(&timed_unordered), 0x82); + } + #[test] fn test_rto_calculator() { let mut calc = RtoCalculator::new(1.0, 0.2, 60.0); diff --git a/tests/datachannel_default_semantics.rs b/tests/datachannel_default_semantics.rs new file mode 100644 index 0000000..daeb185 --- /dev/null +++ b/tests/datachannel_default_semantics.rs @@ -0,0 +1,235 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use rustrtc::transports::datachannel::{DataChannel, DataChannelConfig, DataChannelEvent}; +use rustrtc::{PeerConnection, RtcConfiguration, SdpType, SessionDescription}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use webrtc::api::APIBuilder; +use webrtc::api::interceptor_registry::register_default_interceptors; +use webrtc::api::media_engine::MediaEngine; +use webrtc::data_channel::RTCDataChannel; +use webrtc::data_channel::data_channel_message::DataChannelMessage; +use webrtc::data_channel::data_channel_state::RTCDataChannelState; +use webrtc::interceptor::registry::Registry; +use webrtc::peer_connection::RTCPeerConnection; +use webrtc::peer_connection::configuration::RTCConfiguration as WebrtcConfiguration; +use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; + +const CHANNEL_TIMEOUT: Duration = Duration::from_secs(10); + +fn init_test_runtime() { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + let _ = env_logger::builder().is_test(true).try_init(); +} + +async fn create_webrtc_peer() -> Result> { + let mut media_engine = MediaEngine::default(); + media_engine.register_default_codecs()?; + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media_engine)?; + let api = APIBuilder::new() + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build(); + + api.new_peer_connection(WebrtcConfiguration::default()) + .await + .map(Arc::new) + .context("failed to create webrtc-rs peer connection") +} + +async fn negotiate_rust_offer( + rust_pc: &Arc, + webrtc_pc: &Arc, +) -> Result<()> { + let offer = rust_pc.create_offer().await?; + rust_pc.set_local_description(offer)?; + rust_pc.wait_for_gathering_complete().await; + let offer = rust_pc + .local_description() + .context("missing Rust offer after gathering")?; + + let webrtc_offer = RTCSessionDescription::offer(offer.to_sdp_string())?; + webrtc_pc.set_remote_description(webrtc_offer).await?; + + let answer = webrtc_pc.create_answer(None).await?; + let mut gather_complete = webrtc_pc.gathering_complete_promise().await; + webrtc_pc.set_local_description(answer).await?; + let _ = gather_complete.recv().await; + + let answer = webrtc_pc + .local_description() + .await + .context("missing webrtc-rs local answer")?; + let rust_answer = SessionDescription::parse(SdpType::Answer, &answer.sdp)?; + rust_pc.set_remote_description(rust_answer).await?; + + rust_pc.wait_for_connected().await?; + Ok(()) +} + +async fn wait_for_remote_channel( + rx: &mut mpsc::Receiver>, + label: &str, +) -> Result> { + loop { + let channel = timeout(CHANNEL_TIMEOUT, rx.recv()) + .await + .context("timed out waiting for remote data channel")? + .ok_or_else(|| anyhow!("remote data channel stream closed"))?; + if channel.label() == label { + return Ok(channel); + } + } +} + +async fn wait_for_webrtc_channel_open(channel: &Arc) -> Result<()> { + let started = std::time::Instant::now(); + while channel.ready_state() != RTCDataChannelState::Open { + if started.elapsed() > CHANNEL_TIMEOUT { + return Err(anyhow!("timed out waiting for webrtc-rs channel to open")); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(()) +} + +async fn wait_for_rust_channel_open(channel: &Arc) -> Result<()> { + loop { + let event = timeout(CHANNEL_TIMEOUT, channel.recv()) + .await + .context("timed out waiting for Rust data channel open")? + .ok_or_else(|| anyhow!("Rust data channel closed before opening"))?; + match event { + DataChannelEvent::Open => return Ok(()), + DataChannelEvent::Close => { + return Err(anyhow!("Rust data channel closed before opening")); + } + DataChannelEvent::Message(_) => {} + } + } +} + +async fn wait_for_rust_message(channel: &Arc, expected: &str) -> Result<()> { + loop { + let event = timeout(CHANNEL_TIMEOUT, channel.recv()) + .await + .context("timed out waiting for Rust data channel message")? + .ok_or_else(|| anyhow!("Rust data channel closed before receiving a message"))?; + match event { + DataChannelEvent::Message(message) => { + if message.as_ref() == expected.as_bytes() { + return Ok(()); + } + } + DataChannelEvent::Close => { + return Err(anyhow!( + "Rust data channel closed before receiving a message" + )); + } + DataChannelEvent::Open => {} + } + } +} + +#[tokio::test] +async fn default_channel_is_ordered_reliable() -> Result<()> { + init_test_runtime(); + + let rust_pc = Arc::new(PeerConnection::new(RtcConfiguration::default())); + let rust_dc = rust_pc.create_data_channel("default-channel", None)?; + assert!(rust_dc.ordered); + assert_eq!(rust_dc.max_retransmits, None); + assert_eq!(rust_dc.max_packet_life_time, None); + + let webrtc_pc = create_webrtc_peer().await?; + let (remote_dc_tx, mut remote_dc_rx) = mpsc::channel(1); + webrtc_pc.on_data_channel(Box::new(move |channel: Arc| { + let remote_dc_tx = remote_dc_tx.clone(); + Box::pin(async move { + let _ = remote_dc_tx.send(channel).await; + }) + })); + + negotiate_rust_offer(&rust_pc, &webrtc_pc).await?; + + let remote_dc = wait_for_remote_channel(&mut remote_dc_rx, "default-channel").await?; + wait_for_rust_channel_open(&rust_dc).await?; + wait_for_webrtc_channel_open(&remote_dc).await?; + + assert!( + remote_dc.ordered(), + "webrtc-rs should observe an ordered channel" + ); + assert_eq!(remote_dc.max_retransmits(), None); + assert_eq!(remote_dc.max_packet_lifetime(), None); + + let (message_tx, mut message_rx) = mpsc::channel(1); + remote_dc.on_message(Box::new(move |message: DataChannelMessage| { + let message_tx = message_tx.clone(); + Box::pin(async move { + let _ = message_tx + .send(String::from_utf8_lossy(&message.data).to_string()) + .await; + }) + })); + + rust_pc.send_text(rust_dc.id, "ordered-default").await?; + let message = timeout(CHANNEL_TIMEOUT, message_rx.recv()) + .await + .context("timed out waiting for ordered message on webrtc-rs side")? + .ok_or_else(|| anyhow!("webrtc-rs message channel closed"))?; + assert_eq!(message, "ordered-default"); + + rust_pc.close(); + webrtc_pc.close().await?; + Ok(()) +} + +#[tokio::test] +async fn explicit_unordered_still_supported() -> Result<()> { + init_test_runtime(); + + let rust_pc = Arc::new(PeerConnection::new(RtcConfiguration::default())); + let rust_dc = rust_pc.create_data_channel( + "unordered-channel", + Some(DataChannelConfig { + ordered: false, + ..Default::default() + }), + )?; + assert!(!rust_dc.ordered); + assert_eq!(rust_dc.max_retransmits, None); + assert_eq!(rust_dc.max_packet_life_time, None); + + let webrtc_pc = create_webrtc_peer().await?; + let (remote_dc_tx, mut remote_dc_rx) = mpsc::channel(1); + webrtc_pc.on_data_channel(Box::new(move |channel: Arc| { + let remote_dc_tx = remote_dc_tx.clone(); + Box::pin(async move { + let _ = remote_dc_tx.send(channel).await; + }) + })); + + negotiate_rust_offer(&rust_pc, &webrtc_pc).await?; + + let remote_dc = wait_for_remote_channel(&mut remote_dc_rx, "unordered-channel").await?; + wait_for_rust_channel_open(&rust_dc).await?; + wait_for_webrtc_channel_open(&remote_dc).await?; + + assert!( + !remote_dc.ordered(), + "webrtc-rs should observe the explicit unordered override" + ); + assert_eq!(remote_dc.max_retransmits(), None); + assert_eq!(remote_dc.max_packet_lifetime(), None); + + remote_dc.send_text("unordered-reply").await?; + wait_for_rust_message(&rust_dc, "unordered-reply").await?; + + rust_pc.close(); + webrtc_pc.close().await?; + Ok(()) +} diff --git a/tests/ordered_channel_test.rs b/tests/ordered_channel_test.rs index 4e095d1..eea75a1 100644 --- a/tests/ordered_channel_test.rs +++ b/tests/ordered_channel_test.rs @@ -14,6 +14,30 @@ use webrtc::interceptor::registry::Registry; use webrtc::peer_connection::configuration::RTCConfiguration as WebrtcConfiguration; use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +#[tokio::test] +async fn default_create_data_channel_none_is_ordered() -> Result<()> { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + + let pc = PeerConnection::new(RtcConfiguration::default()); + let dc = pc.create_data_channel("default-ordered", None)?; + + assert!( + dc.ordered, + "default channel should match browser ordered semantics" + ); + assert!( + dc.max_retransmits.is_none(), + "default channel should remain fully reliable" + ); + assert!( + dc.max_packet_life_time.is_none(), + "default channel should not set a lifetime limit" + ); + + pc.close(); + Ok(()) +} + /// Test: ordered channels with negotiated mode /// This mimics the browser scenario where channels are ordered. /// Sends data from RustRTC to webrtc-rs via ordered channels. From ce0741907aaa44ef9aeea752881f4403b89e4260 Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 02:24:50 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E8=A1=A5=E5=85=85=20DataChannel=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=AF=AD=E4=B9=89=E7=9A=84=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - 新增浏览器侧接收 Rust 发起 DataChannel 的互操作测试 - 覆盖默认 ordered=true 与显式 ordered=false 两条真实浏览器路径 - 将新增浏览器用例接入 datachannel 回归分组 Files: - tests/browser_interop.rs - scripts/run_regression_group.sh Verification: - cargo fmt --all - cargo test --test browser_interop -- --nocapture - ./scripts/run_regression_group.sh datachannel Risk: - 浏览器互操作测试依赖本机 Chrome/Chromium 和本地 ICE/UDP 绑定,沙箱内可能无法运行 --- scripts/run_regression_group.sh | 2 + tests/browser_interop.rs | 214 +++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/scripts/run_regression_group.sh b/scripts/run_regression_group.sh index 6bedf31..a85f79b 100755 --- a/scripts/run_regression_group.sh +++ b/scripts/run_regression_group.sh @@ -33,6 +33,8 @@ run_group() { run_cmd cargo test --test ordered_channel_test run_cmd cargo test --test interop_datachannel run_cmd cargo test --test browser_interop browser_datachannel_message_roundtrip + run_cmd cargo test --test browser_interop rust_default_datachannel_is_ordered_in_browser + run_cmd cargo test --test browser_interop rust_explicit_unordered_datachannel_reaches_browser ;; network) run_cmd cargo test --test regression_baseline regression_network_entrypoints_exist diff --git a/tests/browser_interop.rs b/tests/browser_interop.rs index bb8508d..414be7e 100644 --- a/tests/browser_interop.rs +++ b/tests/browser_interop.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use headless_chrome::{Browser, LaunchOptionsBuilder, Tab}; use rustrtc::config::{ApplicationCapability, MediaCapabilities}; -use rustrtc::transports::datachannel::{DataChannel, DataChannelEvent}; +use rustrtc::transports::datachannel::{DataChannel, DataChannelConfig, DataChannelEvent}; use rustrtc::{ AudioCapability, MediaSection, PeerConnection, PeerConnectionEvent, RtcConfiguration, RtcConfigurationBuilder, SdpType, SessionDescription, @@ -35,6 +35,18 @@ struct BrowserPeerState { connection_state: String, } +#[derive(Debug, serde::Deserialize)] +struct BrowserDataChannelInfo { + label: String, + ordered: bool, + #[serde(rename = "maxRetransmits")] + max_retransmits: Option, + #[serde(rename = "maxPacketLifeTime")] + max_packet_life_time: Option, + #[serde(rename = "readyState")] + ready_state: String, +} + struct BrowserPeer { _browser: Browser, tab: Arc, @@ -99,6 +111,8 @@ impl BrowserPeer { pc, dc: null, messages: [], + remoteDc: null, + remoteMessages: [], }}; pc.onconnectionstatechange = () => {{ state.lastConnectionState = pc.connectionState; @@ -106,6 +120,12 @@ impl BrowserPeer { pc.onsignalingstatechange = () => {{ state.lastSignalingState = pc.signalingState; }}; + pc.ondatachannel = (event) => {{ + state.remoteDc = event.channel; + event.channel.onmessage = (messageEvent) => {{ + state.remoteMessages.push(String(messageEvent.data)); + }}; + }}; {audio_setup} {data_channel_setup} window.__rustrtc = state; @@ -151,6 +171,53 @@ impl BrowserPeer { .context("failed to parse browser offer SDP") } + fn create_answer(&self) -> Result { + let desc: BrowserDescription = self.eval_json( + r#"(async () => { + const pc = window.__rustrtc.pc; + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (pc.iceGatheringState !== "complete") { + await new Promise((resolve) => { + const onState = () => { + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", onState); + resolve(); + } + }; + pc.addEventListener("icegatheringstatechange", onState); + setTimeout(() => { + pc.removeEventListener("icegatheringstatechange", onState); + resolve(); + }, 5000); + }); + } + return { + type: pc.localDescription.type, + sdp: pc.localDescription.sdp, + signalingState: pc.signalingState, + }; + })()"#, + )?; + if desc.kind != "answer" { + bail!("expected browser answer, got {}", desc.kind); + } + assert_eq!(desc.signaling_state, "stable"); + SessionDescription::parse(SdpType::Answer, &desc.sdp) + .context("failed to parse browser answer SDP") + } + + fn answer_offer(&self, offer: &SessionDescription) -> Result { + let state = self.set_remote_description(offer)?; + if state.signaling_state != "have-remote-offer" { + bail!( + "expected browser to enter have-remote-offer, got {}", + state.signaling_state + ); + } + self.create_answer() + } + fn set_remote_description(&self, desc: &SessionDescription) -> Result { let kind = sdp_type_string(desc.sdp_type); let sdp = desc.to_sdp_string(); @@ -230,6 +297,69 @@ impl BrowserPeer { ) } + fn incoming_data_channel_info(&self) -> Result> { + self.eval_json( + r#"(async () => { + const dc = window.__rustrtc.remoteDc; + if (!dc) { + return null; + } + return { + label: dc.label, + ordered: dc.ordered, + maxRetransmits: dc.maxRetransmits, + maxPacketLifeTime: dc.maxPacketLifeTime, + readyState: dc.readyState, + }; + })()"#, + ) + } + + fn wait_for_incoming_data_channel_open(&self, label: &str) -> Result { + let expected_label = label.to_string(); + let started = std::time::Instant::now(); + loop { + if let Some(info) = self.incoming_data_channel_info()? + && info.label == expected_label + { + match info.ready_state.as_str() { + "open" => return Ok(info), + "closing" | "closed" => { + bail!("browser incoming data channel entered {}", info.ready_state) + } + _ => {} + } + } + if started.elapsed() > BROWSER_TIMEOUT { + bail!("timed out waiting for incoming browser data channel to open"); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn send_incoming_data_channel_text(&self, text: &str) -> Result<()> { + self.eval_json::(&format!( + r#"(async () => {{ + if (!window.__rustrtc.remoteDc) {{ + return false; + }} + window.__rustrtc.remoteDc.send({text}); + return true; + }})()"#, + text = serde_json::to_string(text)?, + ))? + .then_some(()) + .ok_or_else(|| anyhow!("browser does not have an incoming data channel yet")) + } + + fn wait_for_incoming_message(&self, expected: &str) -> Result<()> { + let expected = expected.to_string(); + self.wait_for_js_condition( + r#"(async () => window.__rustrtc.remoteMessages)()"#, + move |messages: &Vec| Ok(messages.iter().any(|msg| msg == &expected)), + ) + } + fn close(&self) -> Result<()> { self.eval_json::( r#"(async () => { @@ -429,6 +559,20 @@ async fn wait_for_rust_data_channel_message(dc: &Arc, expected: &st } } +async fn negotiate_rust_offer_to_browser(pc: &PeerConnection, browser: &BrowserPeer) -> Result<()> { + let offer = pc.create_offer().await?; + pc.set_local_description(offer)?; + pc.wait_for_gathering_complete().await; + let offer = pc + .local_description() + .context("missing local offer after gathering")?; + let answer = browser.answer_offer(&offer)?; + pc.set_remote_description(answer).await?; + pc.wait_for_connected().await?; + browser.wait_connected()?; + Ok(()) +} + fn init_browser_test_runtime() { rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); let _ = env_logger::builder().is_test(true).try_init(); @@ -570,3 +714,71 @@ async fn browser_datachannel_message_roundtrip() -> Result<()> { pc.close(); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn rust_default_datachannel_is_ordered_in_browser() -> Result<()> { + init_browser_test_runtime(); + let Some(browser) = BrowserPeer::launch()? else { + return Ok(()); + }; + browser.setup_peer(false, false)?; + + let pc = PeerConnection::new(RtcConfiguration::default()); + let data_channel = pc.create_data_channel("rust-default", None)?; + + negotiate_rust_offer_to_browser(&pc, &browser).await?; + wait_for_rust_data_channel_open(&data_channel).await?; + + let info = browser.wait_for_incoming_data_channel_open("rust-default")?; + assert!(info.ordered); + assert_eq!(info.max_retransmits, None); + assert_eq!(info.max_packet_life_time, None); + + pc.send_text(data_channel.id, "default-from-rust").await?; + browser.wait_for_incoming_message("default-from-rust")?; + + browser.send_incoming_data_channel_text("default-from-browser")?; + wait_for_rust_data_channel_message(&data_channel, "default-from-browser").await?; + + browser.close()?; + pc.close(); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn rust_explicit_unordered_datachannel_reaches_browser() -> Result<()> { + init_browser_test_runtime(); + let Some(browser) = BrowserPeer::launch()? else { + return Ok(()); + }; + browser.setup_peer(false, false)?; + + let pc = PeerConnection::new(RtcConfiguration::default()); + let data_channel = pc.create_data_channel( + "rust-unordered", + Some(DataChannelConfig { + ordered: false, + ..Default::default() + }), + )?; + + negotiate_rust_offer_to_browser(&pc, &browser).await?; + wait_for_rust_data_channel_open(&data_channel).await?; + + let info = browser.wait_for_incoming_data_channel_open("rust-unordered")?; + assert!(!info.ordered); + assert_eq!(info.max_retransmits, None); + assert_eq!(info.max_packet_life_time, None); + + pc.send_text(data_channel.id, "unordered-from-rust").await?; + browser.wait_for_incoming_message("unordered-from-rust")?; + + browser.send_incoming_data_channel_text("unordered-from-browser")?; + wait_for_rust_data_channel_message(&data_channel, "unordered-from-browser").await?; + + browser.close()?; + pc.close(); + Ok(()) +} From fdae4ac61db8189b0e60a5227bb6827662f0c66f Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 02:39:19 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=97=A0=E6=95=88?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B9=B6=E6=8F=90=E5=89=8D=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: ISSUE-08 config/remove-misleading-api-surface Summary: - 为 RtcConfiguration 增加集中运行时支持校验,避免配置被静默接受 - 将 bundle_policy 默认值收敛为 MaxBundle,并对 Balanced/MaxCompat 提前报错 - 对 TURN OAuth 凭据和不完整的 RTP 端口范围配置给出明确错误 - 为 PeerConnection 和 IceTransport 增加 try_new/try_build 校验入口 Files: - src/config.rs - src/peer_connection.rs - src/transports/ice/mod.rs - tests/config_support_matrix.rs Verification: - cargo fmt --all - cargo check --tests - cargo test --test config_support_matrix - cargo test --test certificate_config --test certificate_fingerprint_integration Result: - pass Risk: - 默认 bundle_policy 从 Balanced 收敛为 MaxBundle,会影响读取默认值的调用方,但与当前实际单传输行为一致 Follow-up: - ISSUE-09 继续处理 TURN TCP/TLS 与 candidate transport 语义 --- src/config.rs | 48 ++++++++++++++++++++++++++- src/peer_connection.rs | 19 ++++++----- src/transports/ice/mod.rs | 23 +++++++++++-- tests/config_support_matrix.rs | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 tests/config_support_matrix.rs diff --git a/src/config.rs b/src/config.rs index e4f6165..de2b2a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crate::errors::{RtcError, RtcResult}; use crate::media::depacketizer::{DefaultDepacketizerFactory, DepacketizerFactory}; use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Formatter}; @@ -63,9 +64,9 @@ pub enum IceTransportPolicy { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum BundlePolicy { - #[default] Balanced, MaxCompat, + #[default] MaxBundle, } @@ -334,6 +335,50 @@ impl Default for RtcConfiguration { } } +impl RtcConfiguration { + /// Reject unsupported knobs at construction time so callers do not end up + /// with a peer connection whose runtime behavior silently diverges. + pub fn validate_runtime_support(&self) -> RtcResult<()> { + for server in &self.ice_servers { + if server.credential_type == IceCredentialType::Oauth + && server + .urls + .iter() + .any(|url| url.starts_with("turn:") || url.starts_with("turns:")) + { + return Err(RtcError::InvalidConfiguration( + "IceCredentialType::Oauth is not supported for TURN; only password credentials are implemented" + .into(), + )); + } + } + + if self.bundle_policy != BundlePolicy::MaxBundle { + return Err(RtcError::InvalidConfiguration(format!( + "bundle_policy {:?} is not supported; the runtime currently only implements MaxBundle", + self.bundle_policy + ))); + } + + match (self.rtp_start_port, self.rtp_end_port) { + (Some(_), None) | (None, Some(_)) => { + return Err(RtcError::InvalidConfiguration( + "rtp port range requires both rtp_start_port and rtp_end_port".into(), + )); + } + (Some(start), Some(end)) if start > end => { + return Err(RtcError::InvalidConfiguration(format!( + "rtp port range start {} must be less than or equal to end {}", + start, end + ))); + } + _ => {} + } + + Ok(()) + } +} + pub struct RtcConfigurationBuilder { inner: RtcConfiguration, } @@ -512,6 +557,7 @@ mod tests { #[test] fn test_rtc_configuration_defaults() { let config = RtcConfiguration::default(); + assert_eq!(config.bundle_policy, BundlePolicy::MaxBundle); assert_eq!(config.ice_connection_timeout, Duration::from_secs(30)); assert_eq!(config.sctp_rto_initial, Duration::from_secs(3)); assert_eq!(config.sctp_rto_min, Duration::from_secs(1)); diff --git a/src/peer_connection.rs b/src/peer_connection.rs index 98dddbc..b742e43 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -13,8 +13,9 @@ use crate::transports::ice::{IceCandidate, IceGathererState, IceTransport, conn: use crate::transports::rtp::RtpTransport; use crate::transports::sctp::SctpTransport; use crate::{ - Attribute, AudioCapability, Direction, MediaKind, MediaSection, Origin, RtcConfiguration, - RtcError, RtcResult, SdpType, SessionDescription, TransportMode, VideoCapability, + Attribute, AudioCapability, BundlePolicy, Direction, MediaKind, MediaSection, Origin, + RtcConfiguration, RtcError, RtcResult, SdpType, SessionDescription, TransportMode, + VideoCapability, }; use base64::prelude::*; use std::collections::{HashMap, VecDeque}; @@ -393,8 +394,9 @@ impl PeerConnection { } pub fn try_new(config: RtcConfiguration) -> RtcResult { + config.validate_runtime_support()?; let is_rtp_mode = config.transport_mode == TransportMode::Rtp; - let (ice_transport, ice_runner) = IceTransport::new(config.clone()); + let (ice_transport, ice_runner) = IceTransport::try_new(config.clone())?; let certificate = resolve_dtls_certificate(&config)?; let dtls_fingerprint = dtls::fingerprint(&certificate); @@ -3609,11 +3611,12 @@ impl PeerConnectionInner { } if !desc.media_sections.is_empty() { - let should_bundle = match sdp_type { - SdpType::Offer => true, - SdpType::Answer => remote_offered_bundle, - _ => false, - }; + let should_bundle = matches!(self.config.bundle_policy, BundlePolicy::MaxBundle) + && match sdp_type { + SdpType::Offer => true, + SdpType::Answer => remote_offered_bundle, + _ => false, + }; let should_bundle = should_bundle && desc.media_sections.len() > 1; diff --git a/src/transports/ice/mod.rs b/src/transports/ice/mod.rs index 4d0d00c..0414de1 100644 --- a/src/transports/ice/mod.rs +++ b/src/transports/ice/mod.rs @@ -25,7 +25,7 @@ use self::stun::random_u32; use self::stun::{ StunAttribute, StunClass, StunDecoded, StunMessage, StunMethod, random_bytes, random_u64, }; -use crate::{IceServer, IceTransportPolicy, RtcConfiguration}; +use crate::{IceServer, IceTransportPolicy, RtcConfiguration, RtcResult}; pub(crate) const MAX_STUN_MESSAGE: usize = 1500; @@ -526,6 +526,17 @@ impl IceTransportRunner { impl IceTransport { pub fn new(config: RtcConfiguration) -> (Self, impl std::future::Future + Send) { + Self::try_new(config).expect("failed to create ICE transport") + } + + pub fn try_new( + config: RtcConfiguration, + ) -> RtcResult<(Self, impl std::future::Future + Send)> { + config.validate_runtime_support()?; + Ok(Self::build(config)) + } + + fn build(config: RtcConfiguration) -> (Self, impl std::future::Future + Send) { let (candidate_tx, _) = broadcast::channel(100); let (socket_tx, socket_rx) = tokio::sync::mpsc::unbounded_channel(); let gatherer = IceGatherer::new(config.clone(), candidate_tx.clone(), socket_tx); @@ -1921,14 +1932,20 @@ impl IceTransportBuilder { } pub fn build(self) -> (IceTransport, impl std::future::Future + Send) { + self.try_build().expect("failed to create ICE transport") + } + + pub fn try_build( + self, + ) -> RtcResult<(IceTransport, impl std::future::Future + Send)> { let mut config = self.config.clone(); config.ice_servers.extend(self.servers); - let (transport, runner) = IceTransport::new(config); + let (transport, runner) = IceTransport::try_new(config)?; transport.set_role(self.role); if let Err(err) = transport.start_gathering() { debug!("ICE gather failed: {}", err); } - (transport, runner) + Ok((transport, runner)) } } diff --git a/tests/config_support_matrix.rs b/tests/config_support_matrix.rs new file mode 100644 index 0000000..3a50c16 --- /dev/null +++ b/tests/config_support_matrix.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use rustrtc::{ + BundlePolicy, IceCredentialType, IceServer, IceTransport, PeerConnection, RtcConfiguration, + RtcError, +}; + +#[test] +fn oauth_credential_fails_early_with_clear_error() -> Result<()> { + let mut config = RtcConfiguration::default(); + config.ice_servers.push( + IceServer::new(vec!["turn:127.0.0.1:3478?transport=udp".to_string()]) + .with_credential("user", "token") + .credential_type(IceCredentialType::Oauth), + ); + + let err = match PeerConnection::try_new(config) { + Ok(_) => panic!("OAuth TURN config should fail early"), + Err(err) => err, + }; + assert!( + matches!(err, RtcError::InvalidConfiguration(ref message) if message.contains("Oauth") && message.contains("TURN")), + "unexpected error: {err:?}" + ); + Ok(()) +} + +#[test] +fn bundle_policy_behavior_is_explicit() -> Result<()> { + let config = RtcConfiguration::default(); + assert_eq!(config.bundle_policy, BundlePolicy::MaxBundle); + config.validate_runtime_support()?; + + let mut unsupported = RtcConfiguration::default(); + unsupported.bundle_policy = BundlePolicy::Balanced; + let err = match unsupported.validate_runtime_support() { + Ok(_) => panic!("unsupported bundle policy should fail before runtime"), + Err(err) => err, + }; + assert!( + matches!(err, RtcError::InvalidConfiguration(ref message) if message.contains("bundle_policy") && message.contains("MaxBundle")), + "unexpected error: {err:?}" + ); + Ok(()) +} + +#[test] +fn unsupported_config_is_not_silently_ignored() -> Result<()> { + let mut config = RtcConfiguration::default(); + config.rtp_start_port = Some(40000); + + let err = match IceTransport::try_new(config) { + Ok(_) => panic!("partial RTP port range should fail instead of being ignored"), + Err(err) => err, + }; + assert!( + matches!(err, RtcError::InvalidConfiguration(ref message) if message.contains("rtp_start_port") && message.contains("rtp_end_port")), + "unexpected error: {err:?}" + ); + Ok(()) +} From 5f2f4de5cc42693b006081ff234eb19753d1c89e Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 03:03:20 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9B=9E=E5=BD=92=E5=B9=B6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=20IPv6=20=E6=A0=A1=E9=AA=8C=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - 修正 interop_pion 示例对新版 codec 运行时模型的适配,恢复 e2e_interop 编译与回归 - 收敛同机 webrtc-rs 互操作测试到稳定的 IPv4 与 loopback 候选,消除临时 IPv6/link-local 接口带来的抖动 - 调整 RTP latching 测试以基于当前选中的 ICE pair 进行迁移断言,避免错误的本地地址假设 - 在 issue checklist 中新增独立的 IPv6 host candidate 校验事项,并明确主回归与 IPv6 专项验证的边界 Files: - docs/rustrtc-issue-task-checklist.md - examples/interop_pion.rs - tests/interop_webrtc.rs - tests/rtp_latching_test.rs Verification: - cargo fmt --all - cargo check --example interop_pion - cargo test --test e2e_interop -- --nocapture - cargo test --test interop_webrtc -- --nocapture - cargo test --test rtp_latching_test -- --nocapture - cargo test --tests Result: - pass Risk: - e2e_interop 的 Go 辅助示例仍缺少 go.sum 依赖,当前按测试内既有逻辑跳过 Go 构建失败场景 - IPv6 host candidate 语义尚未实现独立专项测试,已在 checklist 中单列 ISSUE-11 跟进 --- docs/rustrtc-issue-task-checklist.md | 102 +++++++++++++++++++++------ examples/interop_pion.rs | 23 ++---- tests/interop_webrtc.rs | 58 +++++++-------- tests/rtp_latching_test.rs | 32 ++++++--- 4 files changed, 136 insertions(+), 79 deletions(-) diff --git a/docs/rustrtc-issue-task-checklist.md b/docs/rustrtc-issue-task-checklist.md index 58a0397..9ce744b 100644 --- a/docs/rustrtc-issue-task-checklist.md +++ b/docs/rustrtc-issue-task-checklist.md @@ -52,6 +52,10 @@ 用途: - 验证跨栈语义而不是只测内部函数 +说明: +- 同机 `webrtc-rs` 回归优先使用稳定的 `IPv4 + loopback` 候选,避免开发机临时网卡、`link-local IPv6` 和接口作用域差异导致测试抖动 +- 若要验证真实 `IPv6` host candidate 行为,应单独放入专门的 `network/ipv6-*` 任务和测试入口,不与主回归路径混跑 + ### L3: 专门测试客户端 适用场景: @@ -500,7 +504,49 @@ Follow-up: - 不支持时有清晰自动验证 - 支持时有完整 TCP 建连回归 -### ISSUE-11 `media/default-video-path-alignment` +### ISSUE-11 `network/ipv6-host-candidate-validation` + +目标: +- 将“同机回归稳定性”与“真实 IPv6 host candidate 支持验证”分离 + +任务: +1. 明确 `tests/interop_webrtc.rs` 这类同机主回归默认只跑 `IPv4 + loopback` +2. 新增专门的 IPv6 测试入口,覆盖全局 IPv6 地址和 `link-local` 地址的行为差异 +3. 对 `link-local` candidate 的接口作用域、可达性和失败模式做显式约束 +4. 若当前暂不支持可路由 IPv6 host candidate,需要在文档和日志中明确声明 + +自动测试方案: +- 扩展: + - `tests/interop_webrtc.rs` + - 保持主路径稳定回归,只使用 `IPv4 + loopback` +- 新增: + - `tests/interop_webrtc_ipv6.rs` + - `global_ipv6_host_candidate_connects_when_available` + - `link_local_ipv6_candidate_is_scope_aware` + - `unsupported_ipv6_mode_fails_with_clear_reason` + - `tests/ice_ipv6_candidate_filtering.rs` + - `same_host_regression_ignores_transient_link_local_interfaces` + - `ipv6_candidate_logging_is_explicit` + +专门客户端需求: +- 建议新增 `tests/clients/ipv6_peer/` +- 功能: + - 枚举本机可用 IPv6 接口并区分 `global` / `link-local` + - 构造带接口作用域的 candidate 或验证被明确拒绝 +- 说明: + - 浏览器或 `webrtc-rs` 默认 gather 结果会受开发机网络环境影响,不适合直接充当全部 IPv6 语义验证器 + +建议命令: +- `cargo test --test interop_webrtc` +- `cargo test --test interop_webrtc_ipv6` +- `cargo test --test ice_ipv6_candidate_filtering` + +完成门禁: +- 主回归路径不再被临时 IPv6/link-local 候选干扰 +- 若支持 IPv6,则有单独自动测试验证可达性和接口作用域 +- 若暂不支持 IPv6 host candidate,则有清晰失败语义和自动测试覆盖 + +### ISSUE-12 `media/default-video-path-alignment` 目标: - 对齐默认视频 codec 收发链路 @@ -529,7 +575,7 @@ Follow-up: 完成门禁: - 默认视频能力不再出现“协商成功但媒体解不开”的假成功 -### ISSUE-12 `cc/remb-twcc-closure` +### ISSUE-13 `cc/remb-twcc-closure` 目标: - 让 REMB/TWCC 从报文层实现推进到控制闭环 @@ -562,7 +608,7 @@ Follow-up: 完成门禁: - 反馈到控制行为形成可测闭环 -### ISSUE-13 `media/vp9-support` +### ISSUE-14 `media/vp9-support` 目标: - 增加 VP9 协商和收发链路 @@ -596,7 +642,7 @@ Follow-up: 完成门禁: - `VP9 only` 和 `VP8 + VP9 fallback` 可自动验证 -### ISSUE-14 `media/h265-support` +### ISSUE-15 `media/h265-support` 目标: - 增加 H.265 显式启用、协商、收发和回退 @@ -634,7 +680,7 @@ Follow-up: - 显式启用才广告 H.265 - `H264 + H.265 fallback` 可自动验证 -### ISSUE-15 `stats/transport-and-datachannel` +### ISSUE-16 `stats/transport-and-datachannel` 目标: - 让 stats 类型与实际产出一致 @@ -665,7 +711,7 @@ Follow-up: 完成门禁: - `StatsKind` 暴露的关键类型均有真实产出 -### ISSUE-16 `ops/security-observability` +### ISSUE-17 `ops/security-observability` 目标: - 为安全和异常路径增加可观测性 @@ -693,7 +739,7 @@ Follow-up: 完成门禁: - 关键异常路径都能被自动触发并观测到 -### ISSUE-17 `docs/implementation-scope-sync` +### ISSUE-18 `docs/implementation-scope-sync` 目标: - 保持文档、配置和测试事实一致 @@ -759,8 +805,8 @@ Follow-up: - 精确发送 REMB/TWCC/PLI/FIR/NACK 反馈 覆盖任务: -- ISSUE-12 -- ISSUE-15 +- ISSUE-13 +- ISSUE-16 建议实现: - Rust @@ -773,9 +819,9 @@ Follow-up: 覆盖任务: - ISSUE-05 -- ISSUE-11 -- ISSUE-13 +- ISSUE-12 - ISSUE-14 +- ISSUE-15 建议实现: - Go @@ -791,12 +837,26 @@ Follow-up: - ISSUE-04 - ISSUE-07 - ISSUE-09 -- ISSUE-15 +- ISSUE-16 建议实现: - Rust - 直接复用当前 `tests/interop_*.rs` 里的公共逻辑 +### 4.6 `tests/clients/ipv6_peer/` + +用途: +- 验证 `IPv6` host candidate 的 gather、作用域和可达性 +- 将同机主回归稳定性和 `IPv6` 语义测试拆开 + +覆盖任务: +- ISSUE-11 + +建议实现: +- Rust +- 优先复用仓内 ICE candidate 结构,显式枚举 `global` 与 `link-local` 接口 +- 对 `link-local` 地址带接口作用域或明确拒绝,避免隐式依赖操作系统默认行为 + ## 5. 建议的 CI 分组 ### CI-1 `check` @@ -808,7 +868,7 @@ Follow-up: - ISSUE-02 - ISSUE-03 -- ISSUE-16 +- ISSUE-17 ### CI-3 `signaling-and-datachannel` @@ -820,22 +880,23 @@ Follow-up: - ISSUE-09 - ISSUE-10 +- ISSUE-11 ### CI-5 `media-core` - ISSUE-05 -- ISSUE-11 - ISSUE-12 +- ISSUE-13 ### CI-6 `media-extended` -- ISSUE-13 - ISSUE-14 +- ISSUE-15 ### CI-7 `stats-and-docs` -- ISSUE-15 -- ISSUE-17 +- ISSUE-16 +- ISSUE-18 说明: - `media-extended` 可先作为非阻断 job,等 VP9/H.265 落地后转为阻断 @@ -857,11 +918,12 @@ Follow-up: 10. ISSUE-10 11. ISSUE-11 12. ISSUE-12 -13. ISSUE-15 -14. ISSUE-16 +13. ISSUE-16 +14. ISSUE-17 15. ISSUE-13 16. ISSUE-14 -17. ISSUE-17 +17. ISSUE-15 +18. ISSUE-18 说明: - `stats` 和 `observability` 可以在媒体扩展前完成 diff --git a/examples/interop_pion.rs b/examples/interop_pion.rs index 1604b27..78fe6c9 100644 --- a/examples/interop_pion.rs +++ b/examples/interop_pion.rs @@ -49,14 +49,9 @@ async fn handle_offer(Json(payload): Json) -> impl IntoResponse { info!("Received offer"); let mut config = RtcConfiguration::default(); - // Enable VP8 let mut caps = rustrtc::config::MediaCapabilities::default(); - caps.video = vec![rustrtc::config::VideoCapability { - payload_type: 96, - codec_name: "VP8".to_string(), - clock_rate: 90000, - rtcp_fbs: vec!["nack".to_string(), "pli".to_string()], - }]; + // Keep the example aligned with the runtime's default VP8 negotiation model. + caps.video = vec![rustrtc::config::VideoCapability::default()]; config.media_capabilities = Some(caps); let pc = PeerConnection::new(config); @@ -120,12 +115,8 @@ async fn handle_offer(Json(payload): Json) -> impl IntoResponse { async fn run_client(addr_str: &str) { let mut config = RtcConfiguration::default(); let mut caps = rustrtc::config::MediaCapabilities::default(); - caps.video = vec![rustrtc::config::VideoCapability { - payload_type: 96, - codec_name: "VP8".to_string(), - clock_rate: 90000, - rtcp_fbs: vec!["nack".to_string(), "pli".to_string()], - }]; + // Keep the example aligned with the runtime's default VP8 negotiation model. + caps.video = vec![rustrtc::config::VideoCapability::default()]; config.media_capabilities = Some(caps); let pc = PeerConnection::new(config); @@ -174,11 +165,7 @@ async fn run_client(addr_str: &str) { let (source, track, _) = rustrtc::media::sample_track(rustrtc::media::MediaKind::Video, 96); let sender = rustrtc::peer_connection::RtpSender::builder(track, 12345) .stream_id("stream".to_string()) - .params(rustrtc::RtpCodecParameters { - payload_type: 96, - clock_rate: 90000, - channels: 0, - }) + .params(rustrtc::RtpCodecParameters::default()) .build(); let transceiver = pc.add_transceiver( diff --git a/tests/interop_webrtc.rs b/tests/interop_webrtc.rs index c04ee3c..338be71 100644 --- a/tests/interop_webrtc.rs +++ b/tests/interop_webrtc.rs @@ -7,11 +7,33 @@ use tokio::time::timeout; use webrtc::api::APIBuilder; use webrtc::api::interceptor_registry::register_default_interceptors; use webrtc::api::media_engine::MediaEngine; +use webrtc::api::setting_engine::SettingEngine; use webrtc::interceptor::registry::Registry; use webrtc::peer_connection::configuration::RTCConfiguration as WebrtcConfiguration; use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +fn build_webrtc_api() -> Result { + let mut m = MediaEngine::default(); + m.register_default_codecs()?; + + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut m)?; + + let mut setting_engine = SettingEngine::default(); + // These same-host interop tests are sensitive to transient IPv6/link-local + // interfaces on the developer machine. Constrain the webrtc-rs side to + // stable IPv4 candidates and allow loopback for local self-connectivity. + setting_engine.set_ip_filter(Box::new(|ip| ip.is_ipv4())); + setting_engine.set_include_loopback_candidate(true); + + Ok(APIBuilder::new() + .with_setting_engine(setting_engine) + .with_media_engine(m) + .with_interceptor_registry(registry) + .build()) +} + #[tokio::test] async fn interop_ice_dtls_handshake() -> Result<()> { rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); @@ -25,14 +47,7 @@ async fn interop_ice_dtls_handshake() -> Result<()> { rust_pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; @@ -132,14 +147,7 @@ async fn interop_vp8_echo() -> Result<()> { transceiver.set_sender(Some(sender)); // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; @@ -319,14 +327,7 @@ async fn interop_vp8_echo_with_pli() -> Result<()> { transceiver.set_sender(Some(sender)); // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = Arc::new(api.new_peer_connection(webrtc_config).await?); @@ -489,14 +490,7 @@ async fn interop_ice_close_triggers_pc_close() -> Result<()> { rust_pc.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv); // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; diff --git a/tests/rtp_latching_test.rs b/tests/rtp_latching_test.rs index f25a586..4bfc688 100644 --- a/tests/rtp_latching_test.rs +++ b/tests/rtp_latching_test.rs @@ -181,15 +181,14 @@ async fn test_rtp_latching() -> Result<()> { }; println!("Remote 2 (Migrated) at {}", addr2); - // Retrieve PC's listening address - // We assume the first media section's port is binding - let local_desc = pc.local_description().unwrap(); - let pc_port = local_desc.media_sections[0].port; - if pc_port == 0 { - panic!("PC port is 0, gathering failed?"); - } - // Since PC is bound to 0.0.0.0, it should be reachable on all local IPs - let pc_addr: SocketAddr = SocketAddr::new(ip1, pc_port); + // Send the latching STUN probe to the currently selected local candidate + // instead of assuming a specific interface address from the test harness. + let selected_pair = pc + .ice_transport() + .get_selected_pair() + .await + .expect("selected ICE pair should exist after RTP mode connects"); + let pc_addr = selected_pair.local.address; println!("PC listening at {}", pc_addr); // Send STUN Binding Request from socket2 to PC to trigger latching @@ -200,6 +199,21 @@ async fn test_rtp_latching() -> Result<()> { println!("Sending STUN Binding Request from {} to {}", addr2, pc_addr); socket2.send_to(&req_bytes, pc_addr).await?; + let wait_for_latch = async { + loop { + if let Some(pair) = pc.ice_transport().get_selected_pair().await + && pair.remote.address == addr2 + { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + + tokio::time::timeout(Duration::from_secs(2), wait_for_latch) + .await + .expect("timed out waiting for ICE latching update")?; + // 6. Verify PC sends to addr2 println!("Waiting for packets on addr2..."); let timeout = Duration::from_secs(3); From 055628f698015d252f19f583e904799c54ee7fd5 Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 12:31:39 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E5=AE=8C=E6=88=90=20TURN=20TCP/TLS=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B6=E6=94=B6=E6=95=9B=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - 完成 ISSUE-09,补齐 TURN over TCP/TLS、TURN STUN 属性解析和 relay candidate 语义回归 - 收敛同机 webrtc-rs、RTP 直连和 Rust/Go e2e 的不稳定测试,补上本地 TURN helper 与浏览器回归覆盖 - 统一 rustls CryptoProvider 到 aws_lc_rs,并修正 examples 对当前 codec 运行时模型的结构体初始化 Files: - src/config.rs src/transports/ice/{mod.rs,stun.rs,tests.rs,turn.rs} - tests/{interop_turn_tcp.rs,interop_turn_tls.rs,turn_candidate_semantics.rs,e2e_interop.rs,interop_datachannel.rs,interop_simulcast.rs,interop_webrtc.rs,datachannel_default_semantics.rs,rtp_latching_test.rs,rtp_mode_test.rs,browser_interop.rs} tests/clients/{local_turn_server/mod.rs,turn_interop_case.rs} - scripts/run_regression_group.sh docs/rustrtc-issue-task-checklist.md examples/interop_pion_go/go.sum - examples/{interop_pion.rs,rtp_bench_sut.rs,rtp_reinvite_demo.rs,rustrtc_sfu.rs,echo_server.rs,benchmark.rs,rtp_play.rs,latency_optimization.rs,audio_saver.rs,datachannel_chat.rs,datachannel_stress.rs,dtls_srtp_bench.rs,sctp_benchmark.rs} Verification: - cargo test --tests - ./scripts/run_regression_group.sh all - cargo check --tests --examples - cargo test --test e2e_interop -- --nocapture Risk: - 同机互操作测试已显式约束 IPv4 和 loopback 以保证稳定性,不覆盖 IPv6/link-local 主回归路径 - TURN/TLS 自签名证书验证绕过仅在 allow_insecure_turn_tls 测试配置下启用 --- Cargo.toml | 4 +- docs/rustrtc-issue-task-checklist.md | 13 +- examples/audio_saver.rs | 3 +- examples/benchmark.rs | 6 +- examples/datachannel_chat.rs | 3 +- examples/datachannel_stress.rs | 3 +- examples/dtls_srtp_bench.rs | 3 +- examples/echo_server.rs | 10 +- examples/interop_pion.rs | 3 +- examples/interop_pion_go/go.sum | 192 ++++++ examples/latency_optimization.rs | 6 +- examples/rtp_bench_sut.rs | 8 + examples/rtp_play.rs | 3 + examples/rtp_reinvite_demo.rs | 9 +- examples/rustrtc_sfu.rs | 10 +- examples/sctp_benchmark.rs | 3 +- scripts/run_regression_group.sh | 3 + src/config.rs | 8 + src/transports/dtls/interop_tests.rs | 3 +- src/transports/ice/mod.rs | 46 +- src/transports/ice/stun.rs | 22 + src/transports/ice/tests.rs | 33 +- src/transports/ice/turn.rs | 182 +++++- tests/browser_interop.rs | 3 +- tests/certificate_config.rs | 3 +- tests/certificate_fingerprint_integration.rs | 3 +- tests/clients/local_turn_server/mod.rs | 605 +++++++++++++++++++ tests/clients/turn_interop_case.rs | 124 ++++ tests/datachannel_default_semantics.rs | 11 +- tests/e2e_interop.rs | 35 +- tests/interop_datachannel.rs | 57 +- tests/interop_simulcast.rs | 60 +- tests/interop_turn_tcp.rs | 19 + tests/interop_turn_tls.rs | 18 + tests/interop_webrtc.rs | 3 +- tests/interop_webrtc_datachannel_stress.rs | 3 +- tests/multichannel_stress.rs | 3 +- tests/ordered_channel_test.rs | 12 +- tests/rtp_latching_test.rs | 98 +-- tests/rtp_mode_test.rs | 31 +- tests/turn_candidate_semantics.rs | 67 ++ 41 files changed, 1541 insertions(+), 190 deletions(-) create mode 100644 examples/interop_pion_go/go.sum create mode 100644 tests/clients/local_turn_server/mod.rs create mode 100644 tests/clients/turn_interop_case.rs create mode 100644 tests/interop_turn_tcp.rs create mode 100644 tests/interop_turn_tls.rs create mode 100644 tests/turn_candidate_semantics.rs diff --git a/Cargo.toml b/Cargo.toml index 6ae21fb..8ecaf1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ base64 = "0.22.1" network-interface = "2" rand = "0.9.2" x509-parser = "0.18.1" +rustls = { version = "0.23.36" } +tokio-rustls = "0.26.1" +webpki-roots = "0.26.8" [dev-dependencies] axum = { version = "0.8", features = ["multipart"] } @@ -59,7 +62,6 @@ env_logger = "0.11.8" dtls = "0.13.0" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } indicatif = "0.18.3" -rustls = { version = "0.23.36" } serial_test = "3" headless_chrome = "1" diff --git a/docs/rustrtc-issue-task-checklist.md b/docs/rustrtc-issue-task-checklist.md index 9ce744b..f8692f3 100644 --- a/docs/rustrtc-issue-task-checklist.md +++ b/docs/rustrtc-issue-task-checklist.md @@ -469,24 +469,15 @@ Follow-up: ### ISSUE-10 `network/ice-tcp-decision` 目标: -- 明确是否实现 ICE-TCP,并让该结论可自动验证 +- 明确实现 ICE-TCP,并让该结论可自动验证 -任务 A: 如果决定暂不实现 -1. 在配置、日志和文档中明确声明不支持 -2. 对 TCP candidate 输入提前报错或安全忽略 - -任务 B: 如果决定实现 1. 增加 `tcptype` 2. 扩展 SDP 解析与生成 3. 增加 TCP host candidate gather 4. 增加 TCP connectivity check 自动测试方案: -- 若暂不实现: - - `tests/ice_tcp_not_supported.rs` - - `tcp_candidate_rejected_with_clear_error` - - `tcp_candidate_does_not_trigger_invalid_udp_logic` -- 若实现: +- 实现: - `tests/ice_tcp_connectivity.rs` - `tests/ice_tcp_sdp_roundtrip.rs` diff --git a/examples/audio_saver.rs b/examples/audio_saver.rs index ab466c8..9ad7282 100644 --- a/examples/audio_saver.rs +++ b/examples/audio_saver.rs @@ -17,7 +17,8 @@ use tracing::{info, warn}; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter("debug,rustrtc=debug") .init(); diff --git a/examples/benchmark.rs b/examples/benchmark.rs index 261c583..fa7953c 100644 --- a/examples/benchmark.rs +++ b/examples/benchmark.rs @@ -70,7 +70,8 @@ impl BenchResult { #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); @@ -486,8 +487,11 @@ async fn run_rustrtc(count: usize) -> (f64, u64, u64) { let (_source, track, _) = sample_track(MediaKind::Audio, 100); let params = rustrtc::RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc1.add_track(track, params).unwrap(); diff --git a/examples/datachannel_chat.rs b/examples/datachannel_chat.rs index 3f07724..17311df 100644 --- a/examples/datachannel_chat.rs +++ b/examples/datachannel_chat.rs @@ -29,7 +29,8 @@ struct InternalMessage { #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter("debug,rustrtc=debug") .init(); diff --git a/examples/datachannel_stress.rs b/examples/datachannel_stress.rs index 748babf..62a82c0 100644 --- a/examples/datachannel_stress.rs +++ b/examples/datachannel_stress.rs @@ -20,7 +20,8 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let app = Router::new() .route("/", get(index)) .route("/offer", post(offer)) diff --git a/examples/dtls_srtp_bench.rs b/examples/dtls_srtp_bench.rs index b9d7c9c..67a3af2 100644 --- a/examples/dtls_srtp_bench.rs +++ b/examples/dtls_srtp_bench.rs @@ -12,7 +12,8 @@ use tokio::sync::watch; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let args: Vec = std::env::args().collect(); let mode = args.get(1).map(|s| s.as_str()).unwrap_or("all"); diff --git a/examples/echo_server.rs b/examples/echo_server.rs index 980c1bd..d57e5a4 100644 --- a/examples/echo_server.rs +++ b/examples/echo_server.rs @@ -27,7 +27,8 @@ use webrtc::media::io::ivf_reader::IVFReader; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter("debug,rustrtc=debug") .init(); @@ -126,6 +127,7 @@ async fn handle_rustrtc_offer(payload: OfferRequest) -> Json { payload_type: vp8_pt, codec_name: "VP8".to_string(), clock_rate: 90000, + fmtp: None, rtcp_fbs: vec!["nack pli".to_string(), "transport-cc".to_string()], }]; config.media_capabilities = Some(caps); @@ -247,8 +249,11 @@ async fn start_echo(pc: PeerConnection, vp8_pt: u8) { .stream_id("stream".to_string()) .params(rustrtc::RtpCodecParameters { payload_type: vp8_pt, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }) .build(); @@ -426,8 +431,11 @@ async fn start_video_playback(pc: PeerConnection, vp8_pt: u8) { .stream_id("stream".to_string()) .params(rustrtc::RtpCodecParameters { payload_type: vp8_pt, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }) .build(); diff --git a/examples/interop_pion.rs b/examples/interop_pion.rs index 78fe6c9..598cbef 100644 --- a/examples/interop_pion.rs +++ b/examples/interop_pion.rs @@ -10,7 +10,8 @@ use tracing::info; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter("info,rustrtc=debug") .init(); diff --git a/examples/interop_pion_go/go.sum b/examples/interop_pion_go/go.sum new file mode 100644 index 0000000..683e3f8 --- /dev/null +++ b/examples/interop_pion_go/go.sum @@ -0,0 +1,192 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8= +github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/ice/v2 v2.3.11 h1:rZjVmUwyT55cmN8ySMpL7rsS8KYsJERsrxJLLxpKhdw= +github.com/pion/ice/v2 v2.3.11/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E= +github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= +github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.8 h1:HhicWIg7OX5PVilyBO6plhMetInbzkVJAhbdJiAeVaI= +github.com/pion/mdns v0.0.8/go.mod h1:hYE72WX8WDveIhg7fmXgMKivD3Puklk0Ymzog0lSyaI= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= +github.com/pion/rtcp v1.2.12 h1:bKWiX93XKgDZENEXCijvHRU/wRifm6JV5DGcH6twtSM= +github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= +github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0= +github.com/pion/sctp v1.8.8 h1:5EdnnKI4gpyR1a1TwbiS/wxEgcUWBHsc7ILAjARJB+U= +github.com/pion/sctp v1.8.8/go.mod h1:igF9nZBrjh5AtmKc7U30jXltsFHicFCXSmWA2GWRaWs= +github.com/pion/sctp v1.9.3/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw= +github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw= +github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo= +github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= +github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc= +github.com/pion/transport/v2 v2.2.3 h1:XcOE3/x41HOSKbl1BfyY1TF1dERx7lVvlMCbXU7kfvA= +github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA= +github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/webrtc/v3 v3.2.24 h1:MiFL5DMo2bDaaIFWr0DDpwiV/L4EGbLZb+xoRvfEo1Y= +github.com/pion/webrtc/v3 v3.2.24/go.mod h1:1CaT2fcZzZ6VZA+O1i9yK2DU4EOcXVvSbWG9pr5jefs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/latency_optimization.rs b/examples/latency_optimization.rs index 8ff9269..e3795fd 100644 --- a/examples/latency_optimization.rs +++ b/examples/latency_optimization.rs @@ -6,7 +6,8 @@ use std::time::{Duration, Instant}; #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); // You can enable tracing to debug if needed // tracing_subscriber::fmt::init(); @@ -63,8 +64,11 @@ async fn run_single_iteration() -> Option { let (_source, track, _) = sample_track(MediaKind::Audio, 100); let params = rustrtc::RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: None, + rtcp_fbs: Vec::new(), }; let _ = pc1.add_track(track, params); diff --git a/examples/rtp_bench_sut.rs b/examples/rtp_bench_sut.rs index 7971930..ad86e30 100644 --- a/examples/rtp_bench_sut.rs +++ b/examples/rtp_bench_sut.rs @@ -52,8 +52,15 @@ async fn start_forwarding(pc: PeerConnection, pt: u8, echo_addr: SocketAddr) { let sender = rustrtc::peer_connection::RtpSender::builder(outgoing_track, ssrc) .params(rustrtc::RtpCodecParameters { payload_type: pt, + codec_name: if pt == 0 { + "PCMU".to_string() + } else { + "VP8".to_string() + }, clock_rate, channels: if pt == 0 { 1 } else { 0 }, + fmtp: None, + rtcp_fbs: Vec::new(), }) .build(); transceiver.set_sender(Some(sender)); @@ -161,6 +168,7 @@ async fn offer(Json(payload): Json) -> impl IntoResponse { payload_type: 96, codec_name: "VP8".to_string(), clock_rate: 90000, + fmtp: None, rtcp_fbs: vec![], }]; config.media_capabilities = Some(caps); diff --git a/examples/rtp_play.rs b/examples/rtp_play.rs index 213ed6b..d484ca5 100644 --- a/examples/rtp_play.rs +++ b/examples/rtp_play.rs @@ -36,8 +36,11 @@ async fn main() { let (sample_source, track, _) = rustrtc::media::sample_track(MediaKind::Video, 100); let params = rustrtc::RtpCodecParameters { payload_type: 96, + codec_name: "VP8".to_string(), clock_rate: 90000, channels: 0, + fmtp: None, + rtcp_fbs: Vec::new(), }; pc.add_track(track, params).expect("failed to add track"); diff --git a/examples/rtp_reinvite_demo.rs b/examples/rtp_reinvite_demo.rs index 705dc23..af8de8a 100644 --- a/examples/rtp_reinvite_demo.rs +++ b/examples/rtp_reinvite_demo.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; #[tokio::main] async fn main() -> Result<(), Box> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); // Initialize logging tracing_subscriber::fmt::init(); @@ -34,8 +35,11 @@ async fn main() -> Result<(), Box> { 111, peer_connection::RtpCodecParameters { payload_type: 111, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: None, + rtcp_fbs: Vec::new(), }, ); transceiver.update_payload_map(initial_payload_map)?; @@ -70,8 +74,11 @@ async fn main() -> Result<(), Box> { 120, // Changed from 111 peer_connection::RtpCodecParameters { payload_type: 120, + codec_name: "opus".to_string(), clock_rate: 48000, channels: 2, + fmtp: None, + rtcp_fbs: Vec::new(), }, ); diff --git a/examples/rustrtc_sfu.rs b/examples/rustrtc_sfu.rs index e4eb39f..e2651af 100644 --- a/examples/rustrtc_sfu.rs +++ b/examples/rustrtc_sfu.rs @@ -31,7 +31,8 @@ struct AppState { #[tokio::main] async fn main() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt() .with_env_filter("debug,rustrtc=debug") .init(); @@ -375,8 +376,15 @@ async fn setup_new_peer(peer: Arc, room: Arc) { let params = RtpCodecParameters { payload_type, + codec_name: if kind == MediaKind::Video { + "VP8".to_string() + } else { + "opus".to_string() + }, clock_rate: clock_rate as u32, channels, + fmtp: None, + rtcp_fbs: Vec::new(), }; let track_info = Arc::new(TrackInfo { diff --git a/examples/sctp_benchmark.rs b/examples/sctp_benchmark.rs index 41c0480..dbfb914 100644 --- a/examples/sctp_benchmark.rs +++ b/examples/sctp_benchmark.rs @@ -9,7 +9,8 @@ use tokio::sync::Notify; #[tokio::main] async fn main() -> anyhow::Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); tracing_subscriber::fmt().with_env_filter("info").init(); let args: Vec = std::env::args().collect(); diff --git a/scripts/run_regression_group.sh b/scripts/run_regression_group.sh index a85f79b..4025ece 100755 --- a/scripts/run_regression_group.sh +++ b/scripts/run_regression_group.sh @@ -39,6 +39,9 @@ run_group() { network) run_cmd cargo test --test regression_baseline regression_network_entrypoints_exist run_cmd cargo test --test interop_turn + run_cmd cargo test --test interop_turn_tcp + run_cmd cargo test --test interop_turn_tls + run_cmd cargo test --test turn_candidate_semantics ;; media) run_cmd cargo test --test regression_baseline regression_media_entrypoints_exist diff --git a/src/config.rs b/src/config.rs index de2b2a6..2d1e0f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -273,6 +273,8 @@ pub struct RtcConfiguration { pub external_ip: Option, pub bind_ip: Option, pub disable_ipv6: bool, + #[serde(default)] + pub allow_insecure_turn_tls: bool, pub ssrc_start: u32, pub stun_timeout: std::time::Duration, /// Timeout for the ICE nomination binding check (USE-CANDIDATE). @@ -312,6 +314,7 @@ impl Default for RtcConfiguration { external_ip: None, bind_ip: None, disable_ipv6: false, + allow_insecure_turn_tls: false, ssrc_start: 10000, stun_timeout: std::time::Duration::from_secs(5), nomination_timeout: std::time::Duration::from_secs(10), @@ -421,6 +424,11 @@ impl RtcConfigurationBuilder { self } + pub fn allow_insecure_turn_tls(mut self, allow: bool) -> Self { + self.inner.allow_insecure_turn_tls = allow; + self + } + pub fn rtcp_mux_policy(mut self, policy: RtcpMuxPolicy) -> Self { self.inner.rtcp_mux_policy = policy; self diff --git a/src/transports/dtls/interop_tests.rs b/src/transports/dtls/interop_tests.rs index 969f7eb..cbc2b33 100644 --- a/src/transports/dtls/interop_tests.rs +++ b/src/transports/dtls/interop_tests.rs @@ -16,7 +16,8 @@ use webrtc_util::conn::Listener; #[tokio::test] #[ignore] async fn test_interop_rustrtc_client_webrtc_server() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) diff --git a/src/transports/ice/mod.rs b/src/transports/ice/mod.rs index 0414de1..00fca48 100644 --- a/src/transports/ice/mod.rs +++ b/src/transports/ice/mod.rs @@ -2185,16 +2185,16 @@ impl IceGatherer { async fn probe_stun(&self, uri: &IceServerUri) -> Result> { let addr = uri.resolve(self.config.disable_ipv6).await?; - let socket = match uri.transport { - IceTransportProtocol::Udp => { - self.bind_socket(IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0))) - .await? - } - IceTransportProtocol::Tcp => { - self.bind_socket(IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0))) - .await? - } - }; + if uri.transport == IceTransportProtocol::Tcp { + bail!( + "STUN over {} is not supported without ICE-TCP; refusing the previous UDP fallback", + if uri.secure { "TLS" } else { "TCP" } + ); + } + + let socket = self + .bind_socket(IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0))) + .await?; let local_addr = socket.local_addr()?; let tx_id = random_bytes::<12>(); let message = StunMessage::binding_request(tx_id, Some("rustrtc")); @@ -2221,7 +2221,12 @@ impl IceGatherer { server: &IceServer, ) -> Result> { let credentials = TurnCredentials::from_server(server)?; - let client = TurnClient::connect(uri, self.config.disable_ipv6).await?; + let client = TurnClient::connect( + uri, + self.config.disable_ipv6, + self.config.allow_insecure_turn_tls, + ) + .await?; let allocation = client.allocate(credentials).await?; let relayed_addr = allocation.relayed_address; @@ -2237,7 +2242,10 @@ impl IceGatherer { Ok(Some(IceCandidate::relay( relayed_addr, 1, - allocation.transport.as_str(), + // TURN control traffic may ride over UDP/TCP/TLS, but the relayed + // candidate still advertises the peer-facing transport requested in + // Allocate. Today we only request UDP relays. + "udp", ))) } @@ -2266,6 +2274,7 @@ pub(crate) struct IceServerUri { host: String, port: u16, transport: IceTransportProtocol, + secure: bool, } impl IceServerUri { @@ -2300,6 +2309,9 @@ impl IceServerUri { if scheme.starts_with("stun") && query.contains("transport") { bail!("stun URI must not include transport parameter"); } + if scheme == "turns" && transport != IceTransportProtocol::Tcp { + bail!("turns URI must use TCP transport"); + } let kind = match scheme { "stun" | "stuns" => IceUriKind::Stun, "turn" | "turns" => IceUriKind::Turn, @@ -2310,6 +2322,7 @@ impl IceServerUri { host, port, transport, + secure: matches!(scheme, "stuns" | "turns"), }) } @@ -2343,15 +2356,6 @@ pub(crate) enum IceTransportProtocol { Tcp, } -impl IceTransportProtocol { - fn as_str(&self) -> &'static str { - match self { - IceTransportProtocol::Udp => "udp", - IceTransportProtocol::Tcp => "tcp", - } - } -} - fn default_port_for_scheme(scheme: &str) -> Result { Ok(match scheme { "stun" | "turn" => 3478, diff --git a/src/transports/ice/stun.rs b/src/transports/ice/stun.rs index 401b608..c3d1c0b 100644 --- a/src/transports/ice/stun.rs +++ b/src/transports/ice/stun.rs @@ -82,6 +82,7 @@ pub enum StunAttribute { Username(String), Realm(String), Nonce(String), + ErrorCode(u16), Software(String), RequestedTransport(u8), Lifetime(u32), @@ -91,6 +92,7 @@ pub enum StunAttribute { UseCandidate, XorPeerAddress(SocketAddr), XorMappedAddress(SocketAddr), + XorRelayedAddress(SocketAddr), ChannelNumber(u16), Data(Vec), } @@ -107,6 +109,7 @@ pub struct StunDecoded { pub realm: Option, pub nonce: Option, pub data: Option>, + pub channel_number: Option, pub use_candidate: bool, } @@ -175,6 +178,7 @@ fn append_attribute(buffer: &mut Vec, attr: &StunAttribute, tx_id: &[u8; 12] StunAttribute::Username(value) => append_string_attr(buffer, 0x0006, value), StunAttribute::Realm(value) => append_string_attr(buffer, 0x0014, value), StunAttribute::Nonce(value) => append_string_attr(buffer, 0x0015, value), + StunAttribute::ErrorCode(code) => append_error_code(buffer, *code), StunAttribute::Software(value) => append_string_attr(buffer, 0x8022, value), StunAttribute::RequestedTransport(v) => { buffer.extend_from_slice(&0x0019u16.to_be_bytes()); @@ -214,6 +218,10 @@ fn append_attribute(buffer: &mut Vec, attr: &StunAttribute, tx_id: &[u8; 12] append_xor_address(buffer, 0x0020, addr, tx_id); return; } + StunAttribute::XorRelayedAddress(addr) => { + append_xor_address(buffer, 0x0016, addr, tx_id); + return; + } StunAttribute::ChannelNumber(value) => { buffer.extend_from_slice(&0x000Cu16.to_be_bytes()); buffer.extend_from_slice(&4u16.to_be_bytes()); @@ -229,6 +237,13 @@ fn append_string_attr(buffer: &mut Vec, typ: u16, value: &str) { append_raw_attribute(buffer, typ, value.as_bytes()); } +fn append_error_code(buffer: &mut Vec, code: u16) { + let class = (code / 100) as u8; + let number = (code % 100) as u8; + let value = [0u8, 0u8, class, number]; + append_raw_attribute(buffer, 0x0009, &value); +} + fn append_raw_attribute(buffer: &mut Vec, typ: u16, value: &[u8]) { buffer.extend_from_slice(&typ.to_be_bytes()); buffer.extend_from_slice(&(value.len() as u16).to_be_bytes()); @@ -322,6 +337,7 @@ fn decode_stun_message(bytes: &[u8]) -> Result { let mut realm = None; let mut nonce = None; let mut data = None; + let mut channel_number = None; let mut use_candidate = false; while offset + 4 <= bytes.len() { let typ = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]); @@ -366,6 +382,11 @@ fn decode_stun_message(bytes: &[u8]) -> Result { 0x0013 => { data = Some(value.to_vec()); } + 0x000C => { + if value.len() >= 2 { + channel_number = Some(u16::from_be_bytes([value[0], value[1]])); + } + } 0x0025 => { use_candidate = true; } @@ -385,6 +406,7 @@ fn decode_stun_message(bytes: &[u8]) -> Result { realm, nonce, data, + channel_number, use_candidate, }) } diff --git a/src/transports/ice/tests.rs b/src/transports/ice/tests.rs index a9924fa..01dbc16 100644 --- a/src/transports/ice/tests.rs +++ b/src/transports/ice/tests.rs @@ -26,6 +26,37 @@ fn parse_turn_uri() { assert_eq!(uri.port, 3478); assert_eq!(uri.transport, IceTransportProtocol::Tcp); assert_eq!(uri.kind, IceUriKind::Turn); + assert!(!uri.secure); +} + +#[test] +fn parse_turns_uri_defaults_to_secure_tcp() { + let uri = IceServerUri::parse("turns:example.com").unwrap(); + assert_eq!(uri.host, "example.com"); + assert_eq!(uri.port, 5349); + assert_eq!(uri.transport, IceTransportProtocol::Tcp); + assert_eq!(uri.kind, IceUriKind::Turn); + assert!(uri.secure); +} + +#[tokio::test] +async fn stuns_probe_rejects_tcp_fallback() -> Result<()> { + let (tx, _) = broadcast::channel(100); + let (socket_tx, _) = tokio::sync::mpsc::unbounded_channel(); + let gatherer = IceGatherer::new(RtcConfiguration::default(), tx, socket_tx); + let uri = IceServerUri::parse("stuns:127.0.0.1:5349").unwrap(); + + let err = gatherer + .probe_stun(&uri) + .await + .expect_err("stuns probe should fail until ICE-TCP exists"); + + assert!( + err.to_string() + .contains("refusing the previous UDP fallback"), + "unexpected error: {err}" + ); + Ok(()) } #[tokio::test] @@ -123,7 +154,7 @@ async fn turn_client_can_create_permission() -> Result<()> { let uri = IceServerUri::parse(&turn_server.turn_url())?; let server = IceServer::new(vec![turn_server.turn_url()]).with_credential(TEST_USERNAME, TEST_PASSWORD); - let client = TurnClient::connect(&uri, false).await?; + let client = TurnClient::connect(&uri, false, false).await?; let creds = TurnCredentials::from_server(&server)?; client.allocate(creds).await?; let peer: SocketAddr = "127.0.0.1:5000".parse().unwrap(); diff --git a/src/transports/ice/turn.rs b/src/transports/ice/turn.rs index b82bbce..d64f0b4 100644 --- a/src/transports/ice/turn.rs +++ b/src/transports/ice/turn.rs @@ -1,13 +1,19 @@ use anyhow::{Result, anyhow, bail}; use md5::{Digest as Md5Digest, Md5}; use std::collections::HashMap; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; use tokio::sync::Mutex; use tokio::time::timeout; +use tokio_rustls::TlsConnector; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ + ClientConfig, DigitallySignedStruct, Error as RustlsError, RootCertStore, SignatureScheme, +}; use super::stun::{StunAttribute, StunClass, StunMessage, StunMethod, random_bytes}; use super::{IceServerUri, IceTransportProtocol, MAX_STUN_MESSAGE}; @@ -39,7 +45,6 @@ impl TurnCredentials { } } -#[derive(Debug)] pub struct TurnClient { transport: TurnTransport, auth: Mutex>, @@ -48,6 +53,14 @@ pub struct TurnClient { next_channel: Mutex, } +impl std::fmt::Debug for TurnClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TurnClient") + .field("protocol", &self.transport.protocol()) + .finish_non_exhaustive() + } +} + #[derive(Clone, Debug)] #[cfg_attr(not(test), allow(dead_code))] struct TurnAuthState { @@ -85,7 +98,11 @@ impl TurnAuthState { } impl TurnClient { - pub(crate) async fn connect(uri: &IceServerUri, disable_ipv6: bool) -> Result { + pub(crate) async fn connect( + uri: &IceServerUri, + disable_ipv6: bool, + allow_insecure_tls: bool, + ) -> Result { let addr = uri.resolve(disable_ipv6).await?; let transport = match uri.transport { IceTransportProtocol::Udp => { @@ -97,8 +114,13 @@ impl TurnClient { } IceTransportProtocol::Tcp => { let stream = TcpStream::connect(addr).await?; - let (read, write) = stream.into_split(); - TurnTransport::Tcp { + let (read, write) = if uri.secure { + split_tls_stream(stream, &uri.host, allow_insecure_tls).await? + } else { + split_stream(stream) + }; + TurnTransport::Stream { + protocol: IceTransportProtocol::Tcp, read: Arc::new(Mutex::new(read)), write: Arc::new(Mutex::new(write)), } @@ -164,7 +186,6 @@ impl TurnClient { } return Ok(TurnAllocation { relayed_address: relayed, - transport: self.transport.protocol(), }); } bail!("TURN success without relayed address"); @@ -282,7 +303,7 @@ impl TurnClient { TurnTransport::Udp { socket, server } => { socket.send_to(data, *server).await?; } - TurnTransport::Tcp { write, .. } => { + TurnTransport::Stream { write, .. } => { let mut frame = Vec::with_capacity(2 + data.len()); frame.extend_from_slice(&(data.len() as u16).to_be_bytes()); frame.extend_from_slice(data); @@ -298,20 +319,23 @@ impl TurnClient { let (len, _) = timeout(DEFAULT_STUN_TIMEOUT, socket.recv_from(buf)).await??; Ok(len) } - TurnTransport::Tcp { read, .. } => { - let mut header = [0u8; 2]; - let mut stream = read.lock().await; - stream.read_exact(&mut header).await?; - let len = u16::from_be_bytes(header) as usize; - let mut offset = 0; - while offset < len { - let read = stream.read(&mut buf[offset..len]).await?; - if read == 0 { - bail!("TURN TCP stream closed"); + TurnTransport::Stream { read, .. } => { + timeout(DEFAULT_STUN_TIMEOUT, async { + let mut header = [0u8; 2]; + let mut stream = read.lock().await; + stream.read_exact(&mut header).await?; + let len = u16::from_be_bytes(header) as usize; + let mut offset = 0; + while offset < len { + let read = stream.read(&mut buf[offset..len]).await?; + if read == 0 { + bail!("TURN TCP stream closed"); + } + offset += read; } - offset += read; - } - Ok(len) + Ok(len) + }) + .await? } } } @@ -482,30 +506,132 @@ struct TurnNonce { #[derive(Clone)] pub(crate) struct TurnAllocation { pub relayed_address: SocketAddr, - pub transport: IceTransportProtocol, } impl TurnTransport { fn protocol(&self) -> IceTransportProtocol { match self { TurnTransport::Udp { .. } => IceTransportProtocol::Udp, - TurnTransport::Tcp { .. } => IceTransportProtocol::Tcp, + TurnTransport::Stream { protocol, .. } => *protocol, } } } -#[derive(Debug, Clone)] +#[derive(Clone)] enum TurnTransport { Udp { socket: Arc, server: SocketAddr, }, - Tcp { - read: Arc>, - write: Arc>, + Stream { + protocol: IceTransportProtocol, + read: Arc>, + write: Arc>, }, } +type BoxedReader = Box; +type BoxedWriter = Box; + +fn split_stream(stream: S) -> (BoxedReader, BoxedWriter) +where + S: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let (read, write) = tokio::io::split(stream); + (Box::new(read), Box::new(write)) +} + +async fn split_tls_stream( + stream: TcpStream, + host: &str, + allow_insecure_tls: bool, +) -> Result<(BoxedReader, BoxedWriter)> { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); + + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let mut config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + if allow_insecure_tls { + // This is intentionally opt-in and only used by local regression tests + // that spin up a self-signed TURN/TLS server inside the test process. + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification)); + } + + let server_name = server_name_from_host(host)?; + let tls_stream = TlsConnector::from(Arc::new(config)) + .connect(server_name, stream) + .await?; + Ok(split_stream(tls_stream)) +} + +fn server_name_from_host(host: &str) -> Result> { + if let Ok(ip) = host.parse::() { + return Ok(ServerName::IpAddress(ip.into())); + } + + ServerName::try_from(host.to_string()) + .map_err(|_| anyhow!("invalid TURN TLS server name {}", host)) +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA1, + SignatureScheme::ECDSA_SHA1_Legacy, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} + fn long_term_key(username: &str, realm: &str, password: &str) -> Vec { let input = format!("{}:{}:{}", username, realm, password); md5_digest(input.as_bytes()).to_vec() diff --git a/tests/browser_interop.rs b/tests/browser_interop.rs index 414be7e..b4c095e 100644 --- a/tests/browser_interop.rs +++ b/tests/browser_interop.rs @@ -574,7 +574,8 @@ async fn negotiate_rust_offer_to_browser(pc: &PeerConnection, browser: &BrowserP } fn init_browser_test_runtime() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); } diff --git a/tests/certificate_config.rs b/tests/certificate_config.rs index eb60563..c7ed395 100644 --- a/tests/certificate_config.rs +++ b/tests/certificate_config.rs @@ -30,7 +30,8 @@ fn der_to_pem(label: &str, der: &[u8]) -> String { } fn init_test_runtime() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); } #[tokio::test] diff --git a/tests/certificate_fingerprint_integration.rs b/tests/certificate_fingerprint_integration.rs index 4e8cf89..030304a 100644 --- a/tests/certificate_fingerprint_integration.rs +++ b/tests/certificate_fingerprint_integration.rs @@ -28,7 +28,8 @@ fn der_to_pem(label: &str, der: &[u8]) -> String { } fn init_test_runtime() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); } #[tokio::test] diff --git a/tests/clients/local_turn_server/mod.rs b/tests/clients/local_turn_server/mod.rs new file mode 100644 index 0000000..b6f15c8 --- /dev/null +++ b/tests/clients/local_turn_server/mod.rs @@ -0,0 +1,605 @@ +use anyhow::{Result, anyhow}; +use rcgen::generate_simple_self_signed; +use rustls::ServerConfig; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustrtc::transports::ice::stun::{ + StunAttribute, StunClass, StunDecoded, StunMessage, StunMethod, random_bytes, +}; +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, UdpSocket}; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +const TEST_TURN_REALM: &str = "rustrtc.test.turn"; +const TEST_TURN_NONCE: &str = "rustrtc-test-nonce"; +const TEST_TURN_LIFETIME: u32 = 600; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ControlProtocol { + Udp, + Tcp, + Tls, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ControlId { + Udp(SocketAddr), + Stream { + peer: SocketAddr, + protocol: ControlProtocol, + }, +} + +#[derive(Clone)] +enum ControlPath { + Udp { + socket: Arc, + peer: SocketAddr, + }, + Stream { + tx: mpsc::UnboundedSender>, + peer: SocketAddr, + protocol: ControlProtocol, + }, +} + +impl ControlPath { + fn id(&self) -> ControlId { + match self { + Self::Udp { peer, .. } => ControlId::Udp(*peer), + Self::Stream { peer, protocol, .. } => ControlId::Stream { + peer: *peer, + protocol: *protocol, + }, + } + } + + fn peer_addr(&self) -> SocketAddr { + match self { + Self::Udp { peer, .. } | Self::Stream { peer, .. } => *peer, + } + } + + async fn send_payload(&self, payload: &[u8]) -> Result<()> { + match self { + Self::Udp { socket, peer } => { + socket.send_to(payload, *peer).await?; + } + Self::Stream { tx, .. } => { + tx.send(payload.to_vec()) + .map_err(|_| anyhow!("TURN control stream closed"))?; + } + } + Ok(()) + } +} + +struct Allocation { + control: ControlPath, + relay_socket: Arc, + permissions: Mutex>, + channels_by_peer: Mutex>, + peers_by_channel: Mutex>, +} + +impl Allocation { + async fn allow_peer(&self, peer: SocketAddr) { + self.permissions.lock().await.insert(peer); + } + + async fn bind_channel(&self, channel: u16, peer: SocketAddr) { + self.channels_by_peer.lock().await.insert(peer, channel); + self.peers_by_channel.lock().await.insert(channel, peer); + } + + async fn peer_for_channel(&self, channel: u16) -> Option { + self.peers_by_channel.lock().await.get(&channel).copied() + } + + async fn channel_for_peer(&self, peer: SocketAddr) -> Option { + self.channels_by_peer.lock().await.get(&peer).copied() + } +} + +struct SharedState { + allocations: Mutex>>, + tasks: Mutex>>, +} + +impl SharedState { + fn new() -> Arc { + Arc::new(Self { + allocations: Mutex::new(HashMap::new()), + tasks: Mutex::new(Vec::new()), + }) + } + + async fn add_task(self: &Arc, handle: JoinHandle<()>) { + self.tasks.lock().await.push(handle); + } + + async fn allocation(&self, control: &ControlPath) -> Option> { + self.allocations.lock().await.get(&control.id()).cloned() + } + + async fn insert_allocation(&self, control: &ControlPath, allocation: Arc) { + self.allocations + .lock() + .await + .insert(control.id(), allocation); + } +} + +#[allow(dead_code)] +pub struct LocalTurnServer { + shared: Arc, + udp_addr: SocketAddr, + tcp_addr: SocketAddr, + tls_addr: SocketAddr, +} + +impl LocalTurnServer { + #[allow(dead_code)] + pub async fn start() -> Result { + rustls::crypto::CryptoProvider::install_default( + rustls::crypto::aws_lc_rs::default_provider(), + ) + .ok(); + + let shared = SharedState::new(); + + let udp_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + let udp_addr = udp_socket.local_addr()?; + shared + .add_task(tokio::spawn(run_udp_listener( + udp_socket.clone(), + shared.clone(), + ))) + .await; + + let tcp_listener = TcpListener::bind("127.0.0.1:0").await?; + let tcp_addr = tcp_listener.local_addr()?; + shared + .add_task(tokio::spawn(run_tcp_listener( + tcp_listener, + ControlProtocol::Tcp, + shared.clone(), + ))) + .await; + + let tls_listener = TcpListener::bind("127.0.0.1:0").await?; + let tls_addr = tls_listener.local_addr()?; + let acceptor = TlsAcceptor::from(Arc::new(build_tls_server_config()?)); + shared + .add_task(tokio::spawn(run_tls_listener( + tls_listener, + acceptor, + shared.clone(), + ))) + .await; + + Ok(Self { + shared, + udp_addr, + tcp_addr, + tls_addr, + }) + } + + #[allow(dead_code)] + pub fn turn_url(&self) -> String { + format!("turn:{}", self.udp_addr) + } + + #[allow(dead_code)] + pub fn turn_tcp_url(&self) -> String { + format!("turn:{}?transport=tcp", self.tcp_addr) + } + + #[allow(dead_code)] + pub fn turns_url(&self) -> String { + format!("turns:{}", self.tls_addr) + } + + #[allow(dead_code)] + pub async fn stop(self) { + let mut tasks = self.shared.tasks.lock().await; + for handle in tasks.drain(..) { + handle.abort(); + } + } +} + +async fn run_udp_listener(socket: Arc, shared: Arc) { + let mut buf = [0u8; 2048]; + loop { + let (len, peer) = match socket.recv_from(&mut buf).await { + Ok(value) => value, + Err(_) => break, + }; + let payload = buf[..len].to_vec(); + let control = ControlPath::Udp { + socket: socket.clone(), + peer, + }; + if let Err(err) = handle_control_packet(payload, control, shared.clone()).await { + eprintln!("local TURN UDP handler error: {err}"); + } + } +} + +async fn run_tcp_listener( + listener: TcpListener, + protocol: ControlProtocol, + shared: Arc, +) { + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(value) => value, + Err(_) => break, + }; + let shared_clone = shared.clone(); + let handle = tokio::spawn(async move { + if let Err(err) = run_stream_session(stream, peer_addr, protocol, shared_clone).await { + eprintln!("local TURN stream handler error: {err}"); + } + }); + shared.add_task(handle).await; + } +} + +async fn run_tls_listener(listener: TcpListener, acceptor: TlsAcceptor, shared: Arc) { + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(value) => value, + Err(_) => break, + }; + let acceptor = acceptor.clone(); + let shared_clone = shared.clone(); + let handle = tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + if let Err(err) = run_stream_session( + tls_stream, + peer_addr, + ControlProtocol::Tls, + shared_clone, + ) + .await + { + eprintln!("local TURN TLS handler error: {err}"); + } + } + Err(err) => { + eprintln!("local TURN TLS accept error: {err}"); + } + } + }); + shared.add_task(handle).await; + } +} + +async fn run_stream_session( + stream: S, + peer_addr: SocketAddr, + protocol: ControlProtocol, + shared: Arc, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let (mut read, mut write) = tokio::io::split(stream); + let (tx, mut rx) = mpsc::unbounded_channel::>(); + let writer = tokio::spawn(async move { + while let Some(payload) = rx.recv().await { + let len = (payload.len() as u16).to_be_bytes(); + if write.write_all(&len).await.is_err() { + break; + } + if write.write_all(&payload).await.is_err() { + break; + } + } + }); + shared.add_task(writer).await; + + let control = ControlPath::Stream { + tx, + peer: peer_addr, + protocol, + }; + let mut header = [0u8; 2]; + loop { + read.read_exact(&mut header).await?; + let len = u16::from_be_bytes(header) as usize; + let mut payload = vec![0u8; len]; + read.read_exact(&mut payload).await?; + handle_control_packet(payload, control.clone(), shared.clone()).await?; + } +} + +async fn handle_control_packet( + payload: Vec, + control: ControlPath, + shared: Arc, +) -> Result<()> { + if let Some((channel, data)) = decode_channel_data(&payload) { + if let Some(allocation) = shared.allocation(&control).await + && let Some(peer) = allocation.peer_for_channel(channel).await + { + allocation.relay_socket.send_to(&data, peer).await?; + } + return Ok(()); + } + + let message = StunMessage::decode(&payload)?; + match (message.class, message.method) { + (StunClass::Request, StunMethod::Binding) => { + send_stun_message( + &control, + StunMessage::binding_success_response(message.transaction_id, control.peer_addr()), + ) + .await?; + } + (StunClass::Request, StunMethod::Allocate) => { + handle_allocate(message, control, shared).await?; + } + (StunClass::Request, StunMethod::CreatePermission) => { + handle_create_permission(message, control, shared).await?; + } + (StunClass::Request, StunMethod::ChannelBind) => { + handle_channel_bind(message, control, shared).await?; + } + (StunClass::Request, StunMethod::Refresh) => { + handle_refresh(message, control).await?; + } + (StunClass::Indication, StunMethod::Send) => { + handle_send_indication(message, control, shared).await?; + } + _ => {} + } + Ok(()) +} + +async fn handle_allocate( + message: StunDecoded, + control: ControlPath, + shared: Arc, +) -> Result<()> { + if !is_authorized(&message) { + send_stun_message( + &control, + error_response(StunMethod::Allocate, message.transaction_id, 401), + ) + .await?; + return Ok(()); + } + + let allocation = if let Some(existing) = shared.allocation(&control).await { + existing + } else { + let relay_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + let allocation = Arc::new(Allocation { + control: control.clone(), + relay_socket: relay_socket.clone(), + permissions: Mutex::new(HashSet::new()), + channels_by_peer: Mutex::new(HashMap::new()), + peers_by_channel: Mutex::new(HashMap::new()), + }); + let relay_task = tokio::spawn(run_relay_listener(allocation.clone())); + shared.add_task(relay_task).await; + shared.insert_allocation(&control, allocation.clone()).await; + allocation + }; + + let relayed_addr = allocation.relay_socket.local_addr()?; + send_stun_message( + &control, + StunMessage { + class: StunClass::SuccessResponse, + method: StunMethod::Allocate, + transaction_id: message.transaction_id, + attributes: vec![ + StunAttribute::XorRelayedAddress(relayed_addr), + StunAttribute::XorMappedAddress(control.peer_addr()), + StunAttribute::Lifetime(TEST_TURN_LIFETIME), + ], + }, + ) + .await?; + Ok(()) +} + +async fn handle_create_permission( + message: StunDecoded, + control: ControlPath, + shared: Arc, +) -> Result<()> { + if !is_authorized(&message) { + send_stun_message( + &control, + error_response(StunMethod::CreatePermission, message.transaction_id, 401), + ) + .await?; + return Ok(()); + } + + if let Some(allocation) = shared.allocation(&control).await + && let Some(peer) = message.xor_peer_address + { + allocation.allow_peer(peer).await; + } + + send_stun_message( + &control, + success_response(StunMethod::CreatePermission, message.transaction_id), + ) + .await +} + +async fn handle_channel_bind( + message: StunDecoded, + control: ControlPath, + shared: Arc, +) -> Result<()> { + if !is_authorized(&message) { + send_stun_message( + &control, + error_response(StunMethod::ChannelBind, message.transaction_id, 401), + ) + .await?; + return Ok(()); + } + + if let Some(allocation) = shared.allocation(&control).await + && let (Some(peer), Some(channel)) = (message.xor_peer_address, message.channel_number) + { + allocation.bind_channel(channel, peer).await; + } + + send_stun_message( + &control, + success_response(StunMethod::ChannelBind, message.transaction_id), + ) + .await +} + +async fn handle_refresh(message: StunDecoded, control: ControlPath) -> Result<()> { + if !is_authorized(&message) { + send_stun_message( + &control, + error_response(StunMethod::Refresh, message.transaction_id, 401), + ) + .await?; + return Ok(()); + } + + send_stun_message( + &control, + StunMessage { + class: StunClass::SuccessResponse, + method: StunMethod::Refresh, + transaction_id: message.transaction_id, + attributes: vec![StunAttribute::Lifetime(TEST_TURN_LIFETIME)], + }, + ) + .await +} + +async fn handle_send_indication( + message: StunDecoded, + control: ControlPath, + shared: Arc, +) -> Result<()> { + let Some(allocation) = shared.allocation(&control).await else { + return Ok(()); + }; + let Some(peer) = message.xor_peer_address else { + return Ok(()); + }; + let Some(data) = message.data else { + return Ok(()); + }; + + allocation.relay_socket.send_to(&data, peer).await?; + Ok(()) +} + +async fn run_relay_listener(allocation: Arc) { + let mut buf = [0u8; 2048]; + loop { + let (len, peer) = match allocation.relay_socket.recv_from(&mut buf).await { + Ok(value) => value, + Err(_) => break, + }; + let payload = &buf[..len]; + let outbound = if let Some(channel) = allocation.channel_for_peer(peer).await { + encode_channel_data(channel, payload) + } else { + StunMessage { + class: StunClass::Indication, + method: StunMethod::Data, + transaction_id: random_bytes::<12>(), + attributes: vec![ + StunAttribute::XorPeerAddress(peer), + StunAttribute::Data(payload.to_vec()), + ], + } + .encode(None, true) + .expect("encode data indication") + }; + + if allocation.control.send_payload(&outbound).await.is_err() { + break; + } + } +} + +fn is_authorized(message: &StunDecoded) -> bool { + message.realm.as_deref() == Some(TEST_TURN_REALM) + && message.nonce.as_deref() == Some(TEST_TURN_NONCE) +} + +fn success_response(method: StunMethod, transaction_id: [u8; 12]) -> StunMessage { + StunMessage { + class: StunClass::SuccessResponse, + method, + transaction_id, + attributes: Vec::new(), + } +} + +fn error_response(method: StunMethod, transaction_id: [u8; 12], code: u16) -> StunMessage { + StunMessage { + class: StunClass::ErrorResponse, + method, + transaction_id, + attributes: vec![ + StunAttribute::ErrorCode(code), + StunAttribute::Realm(TEST_TURN_REALM.to_string()), + StunAttribute::Nonce(TEST_TURN_NONCE.to_string()), + ], + } +} + +async fn send_stun_message(control: &ControlPath, message: StunMessage) -> Result<()> { + let bytes = message.encode(None, true)?; + control.send_payload(&bytes).await +} + +fn decode_channel_data(payload: &[u8]) -> Option<(u16, Vec)> { + if payload.len() < 4 { + return None; + } + let channel = u16::from_be_bytes([payload[0], payload[1]]); + if !(0x4000..=0x7FFF).contains(&channel) { + return None; + } + let len = u16::from_be_bytes([payload[2], payload[3]]) as usize; + if payload.len() < 4 + len { + return None; + } + Some((channel, payload[4..4 + len].to_vec())) +} + +fn encode_channel_data(channel: u16, payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(4 + payload.len()); + packet.extend_from_slice(&channel.to_be_bytes()); + packet.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + packet.extend_from_slice(payload); + packet +} + +fn build_tls_server_config() -> Result { + let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?; + let certs: Vec> = vec![cert.cert.der().clone()]; + let key = PrivateKeyDer::from(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + Ok(ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?) +} diff --git a/tests/clients/turn_interop_case.rs b/tests/clients/turn_interop_case.rs new file mode 100644 index 0000000..351e9a8 --- /dev/null +++ b/tests/clients/turn_interop_case.rs @@ -0,0 +1,124 @@ +use anyhow::{Result, anyhow}; +use rustrtc::{ + DataChannelEvent, IceCandidateType, IceServer, IceTransportPolicy, PeerConnection, + PeerConnectionEvent, RtcConfiguration, +}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const TEST_USERNAME: &str = "turn-user"; +const TEST_PASSWORD: &str = "turn-password"; + +pub async fn run_turn_datachannel_roundtrip( + turn_url: String, + allow_insecure_turn_tls: bool, +) -> Result<()> { + let pc1 = PeerConnection::new(build_turn_config(turn_url.clone(), allow_insecure_turn_tls)); + let pc2 = PeerConnection::new(build_turn_config(turn_url, allow_insecure_turn_tls)); + + let dc1 = pc1.create_data_channel("turn-roundtrip", None)?; + + let offer = pc1.create_offer().await?; + pc1.set_local_description(offer.clone())?; + pc1.wait_for_gathering_complete().await; + let offer = pc1.local_description().unwrap(); + + pc2.set_remote_description(offer).await?; + let answer = pc2.create_answer().await?; + pc2.set_local_description(answer.clone())?; + pc2.wait_for_gathering_complete().await; + let answer = pc2.local_description().unwrap(); + + pc1.set_remote_description(answer).await?; + + tokio::try_join!(pc1.wait_for_connected(), pc2.wait_for_connected())?; + + assert_selected_relay_pair(&pc1).await?; + assert_selected_relay_pair(&pc2).await?; + + wait_for_open(&dc1).await?; + let dc2 = wait_for_incoming_channel(&pc2).await?; + wait_for_open(&dc2).await?; + + pc1.send_data(dc1.id, b"hello over local turn").await?; + expect_message(&dc2, b"hello over local turn").await?; + + pc2.send_data(dc2.id, b"hello back over local turn").await?; + expect_message(&dc1, b"hello back over local turn").await?; + + pc1.close(); + pc2.close(); + Ok(()) +} + +fn build_turn_config(turn_url: String, allow_insecure_turn_tls: bool) -> RtcConfiguration { + let mut config = RtcConfiguration::default(); + config.ice_transport_policy = IceTransportPolicy::Relay; + config.allow_insecure_turn_tls = allow_insecure_turn_tls; + config + .ice_servers + .push(IceServer::new(vec![turn_url]).with_credential(TEST_USERNAME, TEST_PASSWORD)); + config +} + +async fn assert_selected_relay_pair(pc: &PeerConnection) -> Result<()> { + let pair = pc + .ice_transport() + .get_selected_pair() + .await + .ok_or_else(|| anyhow!("selected ICE pair missing"))?; + if pair.local.typ != IceCandidateType::Relay { + return Err(anyhow!( + "expected relay candidate, got {:?} {}", + pair.local.typ, + pair.local.address + )); + } + Ok(()) +} + +async fn wait_for_open(dc: &rustrtc::transports::sctp::DataChannel) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(event)) = tokio::time::timeout(Duration::from_millis(200), dc.recv()).await + && matches!(event, DataChannelEvent::Open) + { + return Ok(()); + } + } + Err(anyhow!("timed out waiting for data channel open")) +} + +async fn wait_for_incoming_channel( + pc: &PeerConnection, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(event)) = tokio::time::timeout(Duration::from_millis(200), pc.recv()).await + && let PeerConnectionEvent::DataChannel(dc) = event + { + return Ok(dc); + } + } + Err(anyhow!("timed out waiting for incoming data channel")) +} + +async fn expect_message( + dc: &rustrtc::transports::sctp::DataChannel, + expected: &[u8], +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(event)) = tokio::time::timeout(Duration::from_millis(200), dc.recv()).await + && let DataChannelEvent::Message(message) = event + { + if message.as_ref() == expected { + return Ok(()); + } + } + } + Err(anyhow!( + "timed out waiting for expected data channel payload {:?}", + expected + )) +} diff --git a/tests/datachannel_default_semantics.rs b/tests/datachannel_default_semantics.rs index daeb185..6553db6 100644 --- a/tests/datachannel_default_semantics.rs +++ b/tests/datachannel_default_semantics.rs @@ -9,6 +9,7 @@ use tokio::time::timeout; use webrtc::api::APIBuilder; use webrtc::api::interceptor_registry::register_default_interceptors; use webrtc::api::media_engine::MediaEngine; +use webrtc::api::setting_engine::SettingEngine; use webrtc::data_channel::RTCDataChannel; use webrtc::data_channel::data_channel_message::DataChannelMessage; use webrtc::data_channel::data_channel_state::RTCDataChannelState; @@ -20,7 +21,8 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; const CHANNEL_TIMEOUT: Duration = Duration::from_secs(10); fn init_test_runtime() { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); } @@ -29,7 +31,14 @@ async fn create_webrtc_peer() -> Result> { media_engine.register_default_codecs()?; let mut registry = Registry::new(); registry = register_default_interceptors(registry, &mut media_engine)?; + let mut setting_engine = SettingEngine::default(); + // These same-host interop tests should stay focused on DataChannel semantics, + // not on whatever transient IPv6/link-local interfaces happen to exist on + // the developer machine that day. + setting_engine.set_ip_filter(Box::new(|ip| ip.is_ipv4())); + setting_engine.set_include_loopback_candidate(true); let api = APIBuilder::new() + .with_setting_engine(setting_engine) .with_media_engine(media_engine) .with_interceptor_registry(registry) .build(); diff --git a/tests/e2e_interop.rs b/tests/e2e_interop.rs index 78fcfb7..736e9a0 100644 --- a/tests/e2e_interop.rs +++ b/tests/e2e_interop.rs @@ -1,6 +1,22 @@ -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; + +fn wait_for_child_with_timeout( + child: &mut Child, + timeout: Duration, +) -> std::io::Result> { + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if start.elapsed() >= timeout { + return Ok(None); + } + thread::sleep(Duration::from_millis(100)); + } +} #[test] fn test_rust_server_go_client() { @@ -119,13 +135,20 @@ fn test_go_server_rust_client() { // 3. Start Rust Client let mut client = Command::new("./target/e2e/debug/examples/interop_pion") .args(&["client", "127.0.0.1:3001"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .expect("Failed to start Rust client"); - // 4. Wait for client to finish (it should exit 0 after 5 pings) - let status = client.wait().expect("Failed to wait for Rust client"); + // 4. Wait for client to finish (it should exit 0 after 5 pings). + // Avoid piping the Rust client's logs into an unread buffer, which can + // block the child process once debug logging becomes noisy. + let status = wait_for_child_with_timeout(&mut client, Duration::from_secs(20)) + .expect("Failed to wait for Rust client") + .unwrap_or_else(|| { + let _ = client.kill(); + panic!("Rust client timed out"); + }); // Kill server let _ = server.kill(); diff --git a/tests/interop_datachannel.rs b/tests/interop_datachannel.rs index adf0aa5..463d898 100644 --- a/tests/interop_datachannel.rs +++ b/tests/interop_datachannel.rs @@ -7,13 +7,35 @@ use tokio::time::timeout; use webrtc::api::APIBuilder; use webrtc::api::interceptor_registry::register_default_interceptors; use webrtc::api::media_engine::MediaEngine; +use webrtc::api::setting_engine::SettingEngine; use webrtc::interceptor::registry::Registry; use webrtc::peer_connection::configuration::RTCConfiguration as WebrtcConfiguration; use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +fn build_webrtc_api() -> Result { + let mut media_engine = MediaEngine::default(); + media_engine.register_default_codecs()?; + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media_engine)?; + + let mut setting_engine = SettingEngine::default(); + // Same-host interop tests become flaky when webrtc-rs enumerates transient + // IPv6/link-local interfaces from the developer machine. Keep the harness + // focused on stable IPv4 plus loopback candidates. + setting_engine.set_ip_filter(Box::new(|ip| ip.is_ipv4())); + setting_engine.set_include_loopback_candidate(true); + + Ok(APIBuilder::new() + .with_setting_engine(setting_engine) + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build()) +} + #[tokio::test] async fn interop_datachannel_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); // 1. Create RustRTC PeerConnection (Offerer) @@ -30,14 +52,7 @@ async fn interop_datachannel_test() -> Result<()> { )?; // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; @@ -173,7 +188,8 @@ async fn interop_datachannel_test() -> Result<()> { #[tokio::test] async fn interop_datachannel_dcep_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); @@ -185,14 +201,7 @@ async fn interop_datachannel_dcep_test() -> Result<()> { let rust_dc = rust_pc.create_data_channel("dcep-channel", None)?; // 2. Create WebRTC PeerConnection (Answerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; @@ -287,7 +296,8 @@ async fn interop_datachannel_dcep_test() -> Result<()> { #[tokio::test] async fn interop_datachannel_incoming_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); // 1. Create RustRTC PeerConnection (Answerer) @@ -295,14 +305,7 @@ async fn interop_datachannel_incoming_test() -> Result<()> { let rust_pc = PeerConnection::new(rust_config); // 2. Create WebRTC PeerConnection (Offerer) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - let mut registry = Registry::new(); - registry = register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) - .with_interceptor_registry(registry) - .build(); + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; diff --git a/tests/interop_simulcast.rs b/tests/interop_simulcast.rs index 751b39d..2c8efc0 100644 --- a/tests/interop_simulcast.rs +++ b/tests/interop_simulcast.rs @@ -7,6 +7,7 @@ use std::time::Duration; use tokio::time::timeout; use webrtc::api::APIBuilder; use webrtc::api::media_engine::MediaEngine; +use webrtc::api::setting_engine::SettingEngine; use webrtc::interceptor::registry::Registry; use webrtc::peer_connection::configuration::RTCConfiguration as WebrtcConfiguration; use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; @@ -20,49 +21,62 @@ use webrtc::rtp_transceiver::rtp_transceiver_direction::RTCRtpTransceiverDirecti use webrtc::track::track_local::TrackLocalWriter; use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP; -#[tokio::test] -async fn test_simulcast_ingest_and_switch() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); - - let _ = env_logger::builder().is_test(true).try_init(); - - // 1. Create RustRTC PeerConnection (SFU) - let rust_config = RtcConfiguration::default(); - let rust_pc = PeerConnection::new(rust_config); - - // Add a transceiver to receive Video (Simulcast) - let transceiver = rust_pc.add_transceiver(MediaKind::Video, TransceiverDirection::RecvOnly); - - // 2. Create WebRTC PeerConnection (Client) - let mut m = MediaEngine::default(); - m.register_default_codecs()?; - m.register_header_extension( +fn build_webrtc_api() -> Result { + let mut media_engine = MediaEngine::default(); + media_engine.register_default_codecs()?; + media_engine.register_header_extension( RTCRtpHeaderExtensionCapability { uri: "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id".to_owned(), }, RTPCodecType::Video, Some(RTCRtpTransceiverDirection::Sendrecv), )?; - m.register_header_extension( + media_engine.register_header_extension( RTCRtpHeaderExtensionCapability { uri: "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id".to_owned(), }, RTPCodecType::Video, Some(RTCRtpTransceiverDirection::Sendrecv), )?; - m.register_header_extension( + media_engine.register_header_extension( RTCRtpHeaderExtensionCapability { uri: "urn:ietf:params:rtp-hdrext:sdes:mid".to_owned(), }, RTPCodecType::Video, Some(RTCRtpTransceiverDirection::Sendrecv), )?; + let registry = Registry::new(); - // registry = webrtc::api::interceptor_registry::register_default_interceptors(registry, &mut m)?; - let api = APIBuilder::new() - .with_media_engine(m) + let mut setting_engine = SettingEngine::default(); + // Same-host browser/webrtc-rs interop gets flaky when transient IPv6 or + // link-local interfaces are mixed into candidate gathering. Keep this test + // focused on simulcast behavior over stable IPv4 plus loopback paths. + setting_engine.set_ip_filter(Box::new(|ip| ip.is_ipv4())); + setting_engine.set_include_loopback_candidate(true); + + Ok(APIBuilder::new() + .with_setting_engine(setting_engine) + .with_media_engine(media_engine) .with_interceptor_registry(registry) - .build(); + .build()) +} + +#[tokio::test] +async fn test_simulcast_ingest_and_switch() -> Result<()> { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); + + let _ = env_logger::builder().is_test(true).try_init(); + + // 1. Create RustRTC PeerConnection (SFU) + let rust_config = RtcConfiguration::default(); + let rust_pc = PeerConnection::new(rust_config); + + // Add a transceiver to receive Video (Simulcast) + let transceiver = rust_pc.add_transceiver(MediaKind::Video, TransceiverDirection::RecvOnly); + + // 2. Create WebRTC PeerConnection (Client) + let api = build_webrtc_api()?; let webrtc_config = WebrtcConfiguration::default(); let webrtc_pc = api.new_peer_connection(webrtc_config).await?; diff --git a/tests/interop_turn_tcp.rs b/tests/interop_turn_tcp.rs new file mode 100644 index 0000000..aedf17f --- /dev/null +++ b/tests/interop_turn_tcp.rs @@ -0,0 +1,19 @@ +use anyhow::Result; + +#[path = "clients/local_turn_server/mod.rs"] +mod local_turn_server; +#[path = "clients/turn_interop_case.rs"] +mod turn_interop_case; + +use local_turn_server::LocalTurnServer; + +#[tokio::test] +async fn interop_turn_tcp_datachannel_test() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let server = LocalTurnServer::start().await?; + let result = + turn_interop_case::run_turn_datachannel_roundtrip(server.turn_tcp_url(), false).await; + server.stop().await; + result +} diff --git a/tests/interop_turn_tls.rs b/tests/interop_turn_tls.rs new file mode 100644 index 0000000..f9111fa --- /dev/null +++ b/tests/interop_turn_tls.rs @@ -0,0 +1,18 @@ +use anyhow::Result; + +#[path = "clients/local_turn_server/mod.rs"] +mod local_turn_server; +#[path = "clients/turn_interop_case.rs"] +mod turn_interop_case; + +use local_turn_server::LocalTurnServer; + +#[tokio::test] +async fn interop_turn_tls_datachannel_test() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let server = LocalTurnServer::start().await?; + let result = turn_interop_case::run_turn_datachannel_roundtrip(server.turns_url(), true).await; + server.stop().await; + result +} diff --git a/tests/interop_webrtc.rs b/tests/interop_webrtc.rs index 338be71..f10774a 100644 --- a/tests/interop_webrtc.rs +++ b/tests/interop_webrtc.rs @@ -36,7 +36,8 @@ fn build_webrtc_api() -> Result { #[tokio::test] async fn interop_ice_dtls_handshake() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); // 1. Create RustRTC PeerConnection (Offerer) diff --git a/tests/interop_webrtc_datachannel_stress.rs b/tests/interop_webrtc_datachannel_stress.rs index 660616a..d3bba1d 100644 --- a/tests/interop_webrtc_datachannel_stress.rs +++ b/tests/interop_webrtc_datachannel_stress.rs @@ -14,7 +14,8 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; #[tokio::test] async fn interop_datachannel_stress_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let _ = env_logger::builder().is_test(true).try_init(); // --- WebRTC Setup (Client/Offerer) --- diff --git a/tests/multichannel_stress.rs b/tests/multichannel_stress.rs index 1383235..18f746a 100644 --- a/tests/multichannel_stress.rs +++ b/tests/multichannel_stress.rs @@ -16,7 +16,8 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; #[tokio::test] async fn multichannel_stress_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); const NUM_CHANNELS: usize = 2; const CHUNK_COUNT: usize = 256; diff --git a/tests/ordered_channel_test.rs b/tests/ordered_channel_test.rs index eea75a1..045fde8 100644 --- a/tests/ordered_channel_test.rs +++ b/tests/ordered_channel_test.rs @@ -16,7 +16,8 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; #[tokio::test] async fn default_create_data_channel_none_is_ordered() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); let pc = PeerConnection::new(RtcConfiguration::default()); let dc = pc.create_data_channel("default-ordered", None)?; @@ -43,7 +44,8 @@ async fn default_create_data_channel_none_is_ordered() -> Result<()> { /// Sends data from RustRTC to webrtc-rs via ordered channels. #[tokio::test] async fn ordered_negotiated_channel_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); const NUM_CHANNELS: usize = 2; const CHUNK_COUNT: usize = 50; @@ -283,7 +285,8 @@ async fn ordered_negotiated_channel_test() -> Result<()> { /// This mimics the browser stress test scenario exactly. #[tokio::test] async fn dcep_ordered_channel_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); const CHUNK_COUNT: usize = 20; const CHUNK_SIZE: usize = 1000; @@ -464,7 +467,8 @@ async fn dcep_ordered_channel_test() -> Result<()> { /// sends "pong" back, then sends bulk data. #[tokio::test] async fn dcep_ordered_bidirectional_test() -> Result<()> { - rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()).ok(); + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .ok(); const CHUNK_COUNT: usize = 20; const CHUNK_SIZE: usize = 1000; diff --git a/tests/rtp_latching_test.rs b/tests/rtp_latching_test.rs index 4bfc688..92de20f 100644 --- a/tests/rtp_latching_test.rs +++ b/tests/rtp_latching_test.rs @@ -4,23 +4,55 @@ use rustrtc::media::frame::{MediaSample, VideoFrame}; use rustrtc::transports::ice::stun::StunMessage; use rustrtc::{PeerConnection, RtcConfiguration, RtpCodecParameters, SdpType, TransportMode}; use std::collections::HashSet; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use tokio::net::UdpSocket; -#[tokio::test] -async fn test_rtp_latching() -> Result<()> { - let _ = env_logger::builder().is_test(true).try_init(); +async fn can_exchange_udp(ip_a: IpAddr, ip_b: IpAddr) -> Result { + let socket_a = match UdpSocket::bind(SocketAddr::new(ip_a, 0)).await { + Ok(socket) => socket, + Err(_) => return Ok(false), + }; + let socket_b = match UdpSocket::bind(SocketAddr::new(ip_b, 0)).await { + Ok(socket) => socket, + Err(_) => return Ok(false), + }; + let addr_a = socket_a.local_addr()?; + + socket_b.send_to(b"PING", addr_a).await?; + + let mut buf = [0u8; 10]; + let Ok(Ok((_, src_b))) = + tokio::time::timeout(Duration::from_millis(100), socket_a.recv_from(&mut buf)).await + else { + return Ok(false); + }; + + socket_a.send_to(b"PONG", src_b).await?; + Ok( + tokio::time::timeout(Duration::from_millis(100), socket_b.recv_from(&mut buf)) + .await + .is_ok(), + ) +} + +async fn select_test_ip_pair() -> Result> { + let loopback_a = IpAddr::V4(Ipv4Addr::LOCALHOST); + let loopback_b = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); + + // Prefer two deterministic loopback aliases so the latching test is not + // coupled to whichever LAN or transient interface the host happens to have. + if can_exchange_udp(loopback_a, loopback_b).await? { + return Ok(Some((loopback_a, loopback_b))); + } - // 0. Find distinct local IPs let interfaces = NetworkInterface::show().unwrap(); let mut ips = HashSet::new(); for itf in interfaces { for addr in itf.addr { let ip = addr.ip(); if ip.is_ipv4() && !ip.is_multicast() && !ip.is_unspecified() { - // Try to bind to it to see if it's usable if std::net::UdpSocket::bind(SocketAddr::new(ip, 0)).is_ok() { ips.insert(ip); } @@ -29,16 +61,6 @@ async fn test_rtp_latching() -> Result<()> { } let ipv4_ips: Vec<_> = ips.into_iter().collect(); - if ipv4_ips.len() < 2 { - println!( - "Skipping test_rtp_latching: Need at least 2 distinct local IPv4s, found {:?}", - ipv4_ips - ); - return Ok(()); - } - - // Try to find a pair of IPs that can talk to each other - let mut selected_pair = None; for i in 0..ipv4_ips.len() { for j in 0..ipv4_ips.len() { if i == j { @@ -46,38 +68,26 @@ async fn test_rtp_latching() -> Result<()> { } let ip_a = ipv4_ips[i]; let ip_b = ipv4_ips[j]; - - // Verify connectivity from B to A - let socket_a = UdpSocket::bind(SocketAddr::new(ip_a, 0)).await?; - let socket_b = UdpSocket::bind(SocketAddr::new(ip_b, 0)).await?; - let addr_a = socket_a.local_addr()?; - - socket_b.send_to(b"PING", addr_a).await?; - - let mut buf = [0u8; 10]; - if let Ok(Ok((_, src_b))) = - tokio::time::timeout(Duration::from_millis(100), socket_a.recv_from(&mut buf)).await - { - // Verify return path A -> B - socket_a.send_to(b"PONG", src_b).await?; - if let Ok(Ok(_)) = - tokio::time::timeout(Duration::from_millis(100), socket_b.recv_from(&mut buf)) - .await - { - selected_pair = Some((ip_a, ip_b)); - break; - } + if can_exchange_udp(ip_a, ip_b).await? { + return Ok(Some((ip_a, ip_b))); } } - if selected_pair.is_some() { - break; - } } - let (ip1, ip2) = if let Some(pair) = selected_pair { + Ok(None) +} + +#[tokio::test] +async fn test_rtp_latching() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + // 0. Find a pair of local IPs that can exchange UDP reliably. + let (ip1, ip2) = if let Some(pair) = select_test_ip_pair().await? { pair } else { - println!("Skipping test_rtp_latching: No two local IPs can reach each other via UDP."); + println!( + "Skipping test_rtp_latching: No usable local IP pair can reach each other via UDP." + ); return Ok(()); }; @@ -87,7 +97,9 @@ async fn test_rtp_latching() -> Result<()> { let mut config = RtcConfiguration::default(); config.transport_mode = TransportMode::Rtp; config.enable_latching = true; - config.bind_ip = Some("0.0.0.0".to_string()); + // Bind to the initial destination IP so the selected local candidate stays + // on the same routing domain that the latching probe will use. + config.bind_ip = Some(ip1.to_string()); config.rtp_start_port = Some(40000); config.rtp_end_port = Some(40100); let pc = PeerConnection::new(config); diff --git a/tests/rtp_mode_test.rs b/tests/rtp_mode_test.rs index 90cb665..da1122d 100644 --- a/tests/rtp_mode_test.rs +++ b/tests/rtp_mode_test.rs @@ -2,8 +2,8 @@ use anyhow::Result; use rustrtc::media::MediaStreamTrack; use rustrtc::media::frame::{MediaSample, VideoFrame}; use rustrtc::{ - MediaKind, PeerConnection, RtcConfiguration, RtpCodecParameters, TransceiverDirection, - TransportMode, + MediaKind, PeerConnection, PeerConnectionEvent, RtcConfiguration, RtpCodecParameters, + TransceiverDirection, TransportMode, }; use std::sync::Arc; use std::time::Duration; @@ -15,11 +15,13 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { // PC1: Publisher (RTP Mode) let mut config1 = RtcConfiguration::default(); config1.transport_mode = TransportMode::Rtp; + config1.bind_ip = Some("127.0.0.1".to_string()); let pc1 = PeerConnection::new(config1); // PC2: Receiver (RTP Mode) let mut config2 = RtcConfiguration::default(); config2.transport_mode = TransportMode::Rtp; + config2.bind_ip = Some("127.0.0.1".to_string()); let pc2 = PeerConnection::new(config2); // PC1 adds a track @@ -39,6 +41,16 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { // PC2 adds a transceiver to receive pc2.add_transceiver(MediaKind::Video, TransceiverDirection::RecvOnly); + let (track_tx, mut track_rx) = tokio::sync::mpsc::unbounded_channel(); + let pc2_clone = pc2.clone(); + tokio::spawn(async move { + while let Some(event) = pc2_clone.recv().await { + if let PeerConnectionEvent::Track(transceiver) = event { + let _ = track_tx.send(transceiver); + } + } + }); + // Exchange SDP // 1. PC1 Create Offer // Trigger gathering @@ -79,7 +91,7 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { // Start sending data from PC1 let source_clone = source.clone(); - let _send_task = tokio::spawn(async move { + let send_task = tokio::spawn(async move { let mut seq = 0; // Send enough packets to ensure reception for _ in 0..100 { @@ -99,9 +111,14 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { } }); - // Check if PC2 receives data - let transceivers = pc2.get_transceivers(); - let receiver = transceivers[0].receiver().unwrap(); + // RTP mode only exposes a stable remote track after the receiver latches the + // first incoming SSRC and emits the Track event. + let transceiver = tokio::time::timeout(Duration::from_secs(5), track_rx.recv()) + .await? + .expect("PC2 should emit a Track event after RTP starts flowing"); + let receiver = transceiver + .receiver() + .expect("track event should include receiver"); let track_remote = receiver.track(); // Read a few packets @@ -136,6 +153,8 @@ async fn test_rtp_mode_peer_connection() -> Result<()> { Err(_) => 0, // Timeout }; + send_task.abort(); + println!("Received {} packets", received_count); assert!(received_count >= 10, "Should receive at least 10 packets"); diff --git a/tests/turn_candidate_semantics.rs b/tests/turn_candidate_semantics.rs new file mode 100644 index 0000000..b46b1a3 --- /dev/null +++ b/tests/turn_candidate_semantics.rs @@ -0,0 +1,67 @@ +use anyhow::{Result, anyhow}; +use rustrtc::{ + IceCandidateType, IceGathererState, IceServer, IceTransportPolicy, RtcConfiguration, +}; +use std::time::{Duration, Instant}; + +#[path = "clients/local_turn_server/mod.rs"] +mod local_turn_server; + +use local_turn_server::LocalTurnServer; + +const TEST_USERNAME: &str = "turn-user"; +const TEST_PASSWORD: &str = "turn-password"; + +#[tokio::test] +async fn relay_candidate_from_turn_tcp_still_advertises_udp() -> Result<()> { + let server = LocalTurnServer::start().await?; + let result = assert_relay_candidate_transport(server.turn_tcp_url(), false).await; + server.stop().await; + result +} + +#[tokio::test] +async fn relay_candidate_from_turns_still_advertises_udp() -> Result<()> { + let server = LocalTurnServer::start().await?; + let result = assert_relay_candidate_transport(server.turns_url(), true).await; + server.stop().await; + result +} + +async fn assert_relay_candidate_transport( + url: String, + allow_insecure_turn_tls: bool, +) -> Result<()> { + let mut config = RtcConfiguration::default(); + config.ice_transport_policy = IceTransportPolicy::Relay; + config.allow_insecure_turn_tls = allow_insecure_turn_tls; + config + .ice_servers + .push(IceServer::new(vec![url]).with_credential(TEST_USERNAME, TEST_PASSWORD)); + + let (transport, runner) = rustrtc::transports::ice::IceTransportBuilder::new(config).build(); + let task = tokio::spawn(runner); + wait_for_gather_complete(&transport).await?; + + let relay = transport + .local_candidates() + .into_iter() + .find(|candidate| candidate.typ == IceCandidateType::Relay) + .ok_or_else(|| anyhow!("relay candidate missing"))?; + + assert_eq!(relay.transport, "udp"); + + task.abort(); + Ok(()) +} + +async fn wait_for_gather_complete(transport: &rustrtc::IceTransport) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if transport.gather_state() == IceGathererState::Complete { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(anyhow!("timed out waiting for ICE gathering to complete")) +} From 9f0ad8e8cff9eb43a92debeb6b5d36396c81f0d3 Mon Sep 17 00:00:00 2001 From: VIctor Date: Sat, 14 Mar 2026 12:57:32 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E9=80=82=E9=85=8D=20upstream=20main=20?= =?UTF-8?q?=E7=9A=84=E6=8C=87=E7=BA=B9=E6=A0=A1=E9=AA=8C=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - 修正 WebRTC rollback 在 remote description 校验中被误要求 DTLS fingerprint 的问题 - 为手写 WebRTC SDP 测试补齐 fingerprint,适配 upstream main 的更严格校验 - 串行化 Rust/Go e2e 场景并等待服务端真正监听,收敛全量回归中的超时 - 收紧 RTP latching 地址对选择,避免 loopback/LAN 混合地址导致的假失败 Files: - src/peer_connection.rs - tests/codec_negotiation_integration.rs - tests/codec_runtime_model.rs - tests/e2e_interop.rs - tests/rtp_latching_test.rs - tests/rtp_reinvite_comprehensive_test.rs - tests/rtp_reinvite_test.rs - tests/signaling_pranswer_rollback.rs Verification: - cargo test --test e2e_interop -- --nocapture - cargo test --test rtp_reinvite_comprehensive_test -- --nocapture - cargo test --test rtp_latching_test -- --nocapture - cargo test --tests -- --format=terse Risk: - rtp_latching 在缺少同域本地地址对的主机上会跳过测试,不再强行验证不可稳定复现的迁移路径 - e2e_interop 仍依赖本机 Go/浏览器/本地端口环境,但已经去掉并发互扰和固定 sleep 带来的主要不稳定性 --- src/peer_connection.rs | 4 ++- tests/codec_negotiation_integration.rs | 6 ++++ tests/codec_runtime_model.rs | 6 ++++ tests/e2e_interop.rs | 43 +++++++++++++++++++++--- tests/rtp_latching_test.rs | 9 ++++- tests/rtp_reinvite_comprehensive_test.rs | 6 ++++ tests/rtp_reinvite_test.rs | 6 ++++ tests/signaling_pranswer_rollback.rs | 6 ++++ 8 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/peer_connection.rs b/src/peer_connection.rs index b742e43..64e4572 100644 --- a/src/peer_connection.rs +++ b/src/peer_connection.rs @@ -922,7 +922,9 @@ impl PeerConnection { pub async fn set_remote_description(&self, desc: SessionDescription) -> RtcResult<()> { self.inner.validate_sdp_type(&desc.sdp_type)?; - let remote_dtls_fingerprint = if self.config().transport_mode == TransportMode::WebRtc { + let remote_dtls_fingerprint = if self.config().transport_mode == TransportMode::WebRtc + && desc.sdp_type != SdpType::Rollback + { match desc.dtls_fingerprint() { Ok(Some(fingerprint)) if fingerprint.algorithm == "sha-256" => { Some(fingerprint.value) diff --git a/tests/codec_negotiation_integration.rs b/tests/codec_negotiation_integration.rs index 2a692d2..c848be8 100644 --- a/tests/codec_negotiation_integration.rs +++ b/tests/codec_negotiation_integration.rs @@ -5,6 +5,8 @@ use rustrtc::sdp::{ }; use rustrtc::*; +const TEST_FINGERPRINT: &str = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + fn create_offer_with_codec( kind: MediaKind, mid: &str, @@ -14,6 +16,10 @@ fn create_offer_with_codec( ) -> SessionDescription { let mut desc = SessionDescription::new(SdpType::Offer); desc.session = SessionSection::default(); + desc.session.attributes.push(Attribute::new( + "fingerprint", + Some(TEST_FINGERPRINT.to_string()), + )); let mut section = MediaSection::new(kind, mid); section.direction = Direction::SendRecv; diff --git a/tests/codec_runtime_model.rs b/tests/codec_runtime_model.rs index 1e7ee19..9db9773 100644 --- a/tests/codec_runtime_model.rs +++ b/tests/codec_runtime_model.rs @@ -5,6 +5,8 @@ use rustrtc::sdp::{ }; use rustrtc::*; +const TEST_FINGERPRINT: &str = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + fn create_codec_offer( kind: MediaKind, mid: &str, @@ -15,6 +17,10 @@ fn create_codec_offer( ) -> SessionDescription { let mut desc = SessionDescription::new(SdpType::Offer); desc.session = SessionSection::default(); + desc.session.attributes.push(Attribute::new( + "fingerprint", + Some(TEST_FINGERPRINT.to_string()), + )); let mut section = MediaSection::new(kind, mid); section.direction = Direction::SendRecv; diff --git a/tests/e2e_interop.rs b/tests/e2e_interop.rs index 736e9a0..c0a6737 100644 --- a/tests/e2e_interop.rs +++ b/tests/e2e_interop.rs @@ -1,4 +1,6 @@ +use std::net::{SocketAddr, TcpStream}; use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -18,8 +20,33 @@ fn wait_for_child_with_timeout( } } +fn interop_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn wait_for_tcp_listener(addr: &str, timeout: Duration) -> std::io::Result<()> { + let addr: SocketAddr = addr + .parse() + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid address"))?; + let start = Instant::now(); + loop { + match TcpStream::connect_timeout(&addr, Duration::from_millis(200)) { + Ok(_) => return Ok(()), + Err(err) if start.elapsed() >= timeout => return Err(err), + Err(_) => thread::sleep(Duration::from_millis(100)), + } + } +} + #[test] fn test_rust_server_go_client() { + // These tests share a build dir, a Go binary path, and fixed localhost ports. + // Serialize them so one scenario cannot starve or interfere with the other. + let _guard = interop_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // 0. Build Rust example (use separate target dir to avoid lock contention) let status = Command::new("cargo") .args(&[ @@ -55,8 +82,8 @@ fn test_rust_server_go_client() { .spawn() .expect("Failed to start Rust server"); - // Give server time to start - thread::sleep(Duration::from_secs(5)); + wait_for_tcp_listener("127.0.0.1:3000", Duration::from_secs(10)) + .expect("Rust server did not start listening in time"); // 3. Start Go Client let client = Command::new("./examples/interop_pion_go/interop_pion_go") @@ -94,6 +121,12 @@ fn test_rust_server_go_client() { #[test] fn test_go_server_rust_client() { + // These tests share a build dir, a Go binary path, and fixed localhost ports. + // Serialize them so one scenario cannot starve or interfere with the other. + let _guard = interop_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // 0. Build Rust example let status = Command::new("cargo") .args(&[ @@ -129,8 +162,8 @@ fn test_go_server_rust_client() { .spawn() .expect("Failed to start Go server"); - // Give server time to start - thread::sleep(Duration::from_secs(2)); + wait_for_tcp_listener("127.0.0.1:3001", Duration::from_secs(10)) + .expect("Go server did not start listening in time"); // 3. Start Rust Client let mut client = Command::new("./target/e2e/debug/examples/interop_pion") @@ -143,7 +176,7 @@ fn test_go_server_rust_client() { // 4. Wait for client to finish (it should exit 0 after 5 pings). // Avoid piping the Rust client's logs into an unread buffer, which can // block the child process once debug logging becomes noisy. - let status = wait_for_child_with_timeout(&mut client, Duration::from_secs(20)) + let status = wait_for_child_with_timeout(&mut client, Duration::from_secs(40)) .expect("Failed to wait for Rust client") .unwrap_or_else(|| { let _ = client.kill(); diff --git a/tests/rtp_latching_test.rs b/tests/rtp_latching_test.rs index 92de20f..64ad51b 100644 --- a/tests/rtp_latching_test.rs +++ b/tests/rtp_latching_test.rs @@ -37,6 +37,13 @@ async fn can_exchange_udp(ip_a: IpAddr, ip_b: IpAddr) -> Result { ) } +fn is_compatible_latching_pair(ip_a: IpAddr, ip_b: IpAddr) -> bool { + // Latching reuses the selected local candidate. Mixed loopback/LAN pairs can + // pass a raw UDP probe but still fail to deliver the migration STUN packet + // to the bound local candidate on some hosts. + ip_a.is_loopback() == ip_b.is_loopback() +} + async fn select_test_ip_pair() -> Result> { let loopback_a = IpAddr::V4(Ipv4Addr::LOCALHOST); let loopback_b = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); @@ -68,7 +75,7 @@ async fn select_test_ip_pair() -> Result> { } let ip_a = ipv4_ips[i]; let ip_b = ipv4_ips[j]; - if can_exchange_udp(ip_a, ip_b).await? { + if is_compatible_latching_pair(ip_a, ip_b) && can_exchange_udp(ip_a, ip_b).await? { return Ok(Some((ip_a, ip_b))); } } diff --git a/tests/rtp_reinvite_comprehensive_test.rs b/tests/rtp_reinvite_comprehensive_test.rs index e74ca1f..e155608 100644 --- a/tests/rtp_reinvite_comprehensive_test.rs +++ b/tests/rtp_reinvite_comprehensive_test.rs @@ -5,10 +5,16 @@ use rustrtc::sdp::{ /// Tests cover: Offerer/Answerer timing, SSRC changes, Direction changes, parameter validation use rustrtc::*; +const TEST_FINGERPRINT: &str = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + /// Helper to create a minimal valid SDP fn create_minimal_sdp(sdp_type: SdpType, mid: &str, direction: Direction) -> SessionDescription { let mut desc = SessionDescription::new(sdp_type); desc.session = SessionSection::default(); + desc.session.attributes.push(Attribute::new( + "fingerprint", + Some(TEST_FINGERPRINT.to_string()), + )); let mut section = MediaSection::new(MediaKind::Audio, mid); section.direction = direction; diff --git a/tests/rtp_reinvite_test.rs b/tests/rtp_reinvite_test.rs index 28ac662..5746e70 100644 --- a/tests/rtp_reinvite_test.rs +++ b/tests/rtp_reinvite_test.rs @@ -4,6 +4,8 @@ use rustrtc::sdp::{ use rustrtc::*; use std::collections::HashMap; +const TEST_FINGERPRINT: &str = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + fn create_audio_sdp( sdp_type: SdpType, mid: &str, @@ -13,6 +15,10 @@ fn create_audio_sdp( ) -> SessionDescription { let mut desc = SessionDescription::new(sdp_type); desc.session = SessionSection::default(); + desc.session.attributes.push(Attribute::new( + "fingerprint", + Some(TEST_FINGERPRINT.to_string()), + )); let mut section = MediaSection::new(MediaKind::Audio, mid); section.direction = direction; diff --git a/tests/signaling_pranswer_rollback.rs b/tests/signaling_pranswer_rollback.rs index f7dab66..87d1c1a 100644 --- a/tests/signaling_pranswer_rollback.rs +++ b/tests/signaling_pranswer_rollback.rs @@ -4,6 +4,8 @@ use rustrtc::sdp::{ }; use rustrtc::*; +const TEST_FINGERPRINT: &str = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + fn create_minimal_sdp( sdp_type: SdpType, mid: &str, @@ -13,6 +15,10 @@ fn create_minimal_sdp( ) -> SessionDescription { let mut desc = SessionDescription::new(sdp_type); desc.session = SessionSection::default(); + desc.session.attributes.push(Attribute::new( + "fingerprint", + Some(TEST_FINGERPRINT.to_string()), + )); let mut section = MediaSection::new(MediaKind::Audio, mid); section.direction = direction;