From bb392507bd9da3bc8a2212b9577d0524d1d4c576 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 23 Oct 2026 17:30:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(common):=20=E6=96=B0=E5=A2=9E=20P2P=20?= =?UTF-8?q?=E6=89=93=E6=B4=9E=E4=BF=A1=E4=BB=A4=E5=8D=8F=E8=AE=AE=EF=BC=88?= =?UTF-8?q?=E6=97=A0=E4=B8=AD=E7=BB=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signaling.rs: 信令消息契约 + NAT 画像/打洞前景诊断 + 证书指纹 - 明确不含 relay/TURN:打洞失败必须给出可执行原因(PunchFailReason::user_hint) - 时间对齐不做时钟同步:服务器按两侧 RTT 下发不同 start_in_ms - 保活按 binding_timeout_s 折半(keepalive_secs) --- common/src/lib.rs | 3 + common/src/signaling.rs | 757 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 760 insertions(+) create mode 100644 common/src/signaling.rs diff --git a/common/src/lib.rs b/common/src/lib.rs index a5a39f9..8a32db2 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,5 +1,8 @@ //! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。 +/// P2P 打洞信令协议(信令通道专用,不含中继;三端共享契约) +pub mod signaling; + use anyhow::Result; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/common/src/signaling.rs b/common/src/signaling.rs new file mode 100644 index 0000000..b6899b4 --- /dev/null +++ b/common/src/signaling.rs @@ -0,0 +1,757 @@ +//! P2P 打洞信令协议(`common/` 契约,agent / viewer / 信令服务三端共享)。 +//! +//! ## 职责边界(重要,改动前必读) +//! - **信令服务器只做两件事**:① 把双方的外部视角(公网映射地址 + NAT 行为)拼起来回推给对端; +//! ② 下发同步打洞计划,让双方在同一绝对时刻开始发包。建连完成后信令与本次会话再无关系, +//! 不承载任何媒体流量。 +//! - **本协议不含中继**(无 relay / TURN 消息)。打洞失败就是失败,因此必须把原因讲清楚 +//! (见 [`PunchFailReason::user_hint`]),不允许让用户停在"连接中"。 +//! - 会话建立后走 QUIC:**不要**再用 viewer 现有的 `SkipVerify`,改用 +//! [`EndpointInfo::cert_fingerprint`] 做公钥钉扎。 +//! +//! ## 线格式 +//! 类型只依赖 `serde`,不绑定具体编码。建议信令走 WSS/443(最容易穿过企业防火墙), +//! 编码用 JSON 便于抓包排查;本模块单元测试用 bincode 做往返校验,两者不冲突。 +//! +//! ## 一次会话的时序 +//! ```text +//! agent 信令服务器 viewer +//! |-- Register(info) ------->|<------- Register(info) -----| +//! |<-- Registered(observed) -|------ Registered(observed) ->| +//! |<-- PeerReady(peer info) -|------ PeerReady(peer info) -->| +//! |<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->| +//! | (双方同时向对端候选列表发包,多轮) | +//! |<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>| +//! |--- PunchReport(Connected) ->|<---- PunchReport ----------| +//! ``` + +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; + +// --------------------------------------------------------------------------- +// 常量 +// --------------------------------------------------------------------------- + +/// 信令协议版本。双方不一致时服务器应拒绝并提示升级,不要尝试兼容旧版。 +pub const SIGNALING_VERSION: u16 = 1; + +/// 打洞用的 UDP 端口,与 agent 当前监听端口一致。 +/// 固定端口对两件事有直接好处:NAT 端口预测、以及用户手动做端口转发。 +pub const PUNCH_UDP_PORT: u16 = 47800; + +/// 信令默认端口。走 WSS/443 是因为企业防火墙通常只放行 80/443。 +pub const SIGNALING_DEFAULT_PORT: u16 = 443; + +/// 单次打洞的默认轮数。 +pub const PUNCH_DEFAULT_ROUNDS: u8 = 3; + +/// 打洞轮间隔(毫秒)。太短来不及让 NAT 建立映射,太长用户等不及。 +pub const PUNCH_ROUND_INTERVAL_MS: u16 = 200; + +/// 候选地址数量上限(含预测端口)。超出即截断,防止滥用与报文膨胀。 +pub const MAX_CANDIDATES: usize = 32; + +/// NAT 绑定保活周期的下限/上限(秒)。 +pub const KEEPALIVE_MIN_S: u16 = 5; +pub const KEEPALIVE_MAX_S: u16 = 60; + +/// 未测出 NAT 绑定超时时的保活默认值(秒)。家用 NAT 的 UDP 映射普遍 30s 左右超时。 +pub const KEEPALIVE_DEFAULT_S: u16 = 15; + +// --------------------------------------------------------------------------- +// 基础类型 +// --------------------------------------------------------------------------- + +/// 端点角色。打洞成功后 agent 作为 QUIC server,viewer 主动 connect。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Role { + /// 被控端 + Agent, + /// 主控端 + Viewer, +} + +impl Role { + pub fn peer(self) -> Role { + match self { + Role::Agent => Role::Viewer, + Role::Viewer => Role::Agent, + } + } +} + +/// 候选地址的来源,决定打洞优先级。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CandidateKind { + /// 本机网卡地址(同局域网直连用,最省事) + Host, + /// 经 UPnP / NAT-PMP / PCP 主动申请的端口映射(把对称 NAT 变成可直连) + Upnp, + /// 信令服务器观察到的公网映射(等价于一次 STUN binding,server-reflexive) + Reflexive, + /// 按 NAT 端口分配规律预测出的端口 + Predicted, +} + +/// 一个候选地址。IPv6 有公网地址时基本等同直连,因此固定排在 IPv4 之前。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Candidate { + pub addr: SocketAddr, + pub kind: CandidateKind, +} + +impl Candidate { + pub fn new(addr: SocketAddr, kind: CandidateKind) -> Self { + Self { addr, kind } + } + + pub fn is_ipv6(&self) -> bool { + self.addr.is_ipv6() + } + + /// 打分越高越先打。IPv6(+100)> UPnP 显式映射 > 本机地址 > 公网反射 > 端口预测。 + pub fn score(&self) -> u16 { + let base = match self.kind { + CandidateKind::Upnp => 90, + CandidateKind::Host => 80, + CandidateKind::Reflexive => 60, + CandidateKind::Predicted => 40, + }; + if self.is_ipv6() { + base + 100 + } else { + base + } + } +} + +/// 按优先级从高到低排序(`sort_by` 是稳定排序,同分保持调用方给出的顺序)。 +pub fn sort_candidates(v: &mut [Candidate]) { + v.sort_by(|a, b| b.score().cmp(&a.score())); +} + +/// 清洗对端送来的候选列表:丢弃端口 0、按地址去重、截断到 [`MAX_CANDIDATES`]。 +/// +/// 对端数据一律视为不可信输入;打洞阶段每个候选都会产生实际发包,必须设上限。 +pub fn sanitize_candidates(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len().min(MAX_CANDIDATES)); + for c in v { + if c.addr.port() == 0 { + continue; + } + if out.iter().any(|e| e.addr == c.addr) { + continue; + } + if out.len() == MAX_CANDIDATES { + break; + } + out.push(c); + } + out +} + +// --------------------------------------------------------------------------- +// NAT 画像与诊断 +// --------------------------------------------------------------------------- + +/// RFC 4787 §4.1 映射行为:同一个内网 socket 的公网端口是否随目标变化。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MappingBehavior { + /// 端点无关(full cone / restricted cone):打洞最理想 + EndpointIndependent, + /// 地址相关 + AddressDependent, + /// 地址+端口相关(即"对称 NAT") + AddressAndPortDependent, + Unknown, +} + +/// RFC 4787 §5.1 过滤行为:外部包能否进得来。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FilteringBehavior { + EndpointIndependent, + AddressDependent, + AddressAndPortDependent, + Unknown, +} + +/// 公网端口分配规律,决定端口预测是否可用。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortAllocation { + /// 保持内网端口 + Preserved, + /// 递增分配(可预测,端口预测值得一试) + Sequential, + /// 随机(端口预测无效) + Random, + Unknown, +} + +/// NAT 行为画像。信令握手阶段顺带测出,用于:① 决定打洞策略;② 失败时给用户解释。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct NatProfile { + pub mapping: MappingBehavior, + pub filtering: FilteringBehavior, + pub allocation: PortAllocation, + /// NAT 绑定超时(秒)。未测出为 `None`,此时用 [`KEEPALIVE_DEFAULT_S`]。 + pub binding_timeout_s: Option, + /// 是否支持 hairpinning(同一 NAT 内的两端可互连) + pub hairpinning: bool, +} + +impl Default for NatProfile { + fn default() -> Self { + Self { + mapping: MappingBehavior::Unknown, + filtering: FilteringBehavior::Unknown, + allocation: PortAllocation::Unknown, + binding_timeout_s: None, + hairpinning: false, + } + } +} + +/// 打洞前景评估,直接用于 UI 提示与"是否值得继续重试"的决策。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PunchOutlook { + /// 锥形 NAT 或公网 IPv6,基本必成 + Likely, + /// 需要多轮重试 / 端口预测,可能成 + Possible, + /// 对称 NAT 且端口不可预测,通常打不通 + Unlikely, + /// 运营商级 NAT(CGNAT),无中继方案下无解 —— 不必浪费多轮打洞 + Hopeless, +} + +/// 运营商级 NAT 判定:RFC 6598 共享地址空间 `100.64.0.0/10`。 +/// +/// 命中即无中继不可达,应立刻给出"换网络"提示,而不是让用户等完所有打洞轮次。 +pub fn is_cgnat(observed: IpAddr) -> bool { + match observed { + IpAddr::V4(v4) => { + let o = v4.octets(); + o[0] == 100 && (64..=127).contains(&o[1]) + } + IpAddr::V6(_) => false, + } +} + +/// 综合 NAT 画像与观察到的公网地址,评估打洞前景。 +/// +/// `observed_public` 是信令服务器看到的本端公网映射(IPv6 时即本机全局地址)。 +pub fn punch_outlook(nat: &NatProfile, observed_public: IpAddr) -> PunchOutlook { + // IPv6 没有 NAT 可言,只要双方都有公网 v6 就是真直连。 + if observed_public.is_ipv6() { + return PunchOutlook::Likely; + } + if is_cgnat(observed_public) { + return PunchOutlook::Hopeless; + } + match nat.mapping { + MappingBehavior::EndpointIndependent => PunchOutlook::Likely, + MappingBehavior::AddressDependent => PunchOutlook::Possible, + MappingBehavior::AddressAndPortDependent => match nat.allocation { + // 对称 NAT 但端口可预测 → 端口预测 + 多轮重试仍有机会 + PortAllocation::Sequential | PortAllocation::Preserved => PunchOutlook::Possible, + PortAllocation::Random | PortAllocation::Unknown => PunchOutlook::Unlikely, + }, + // 测不出来就按"可能"处理,先试再说 + MappingBehavior::Unknown => PunchOutlook::Possible, + } +} + +/// 双方协商保活周期(秒):取双方绑定超时的较小值再折半,并夹在 +/// [`KEEPALIVE_MIN_S`]..=[`KEEPALIVE_MAX_S`] 之间。 +/// +/// 洞是靠出站包维持的,超时后会静默关闭 —— 表现为"连上了一会儿就断"。 +pub fn keepalive_secs(mine: &NatProfile, theirs: &NatProfile) -> u16 { + let t = match (mine.binding_timeout_s, theirs.binding_timeout_s) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => KEEPALIVE_DEFAULT_S, + }; + (t / 2).clamp(KEEPALIVE_MIN_S, KEEPALIVE_MAX_S) +} + +/// 服务器侧计算某个客户端的打洞启动延迟(相对 PunchPlan 送达时刻)。 +/// +/// 两侧 RTT 不同:若下发同一个延迟,RTT 大的一方会晚发包,洞就打不上。 +/// 以双方最大 RTT 为基准、减去自身 RTT 的一半,即可在**不做时钟同步**的前提下 +/// 让两侧的绝对开打时刻对齐(误差约等于单程抖动)。 +/// +/// ```text +/// start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2 +/// ``` +pub fn punch_start_delay_ms(own_rtt_ms: u16, max_rtt_ms: u16, margin_ms: u16) -> u16 { + let base = max_rtt_ms as u32 / 2 + margin_ms as u32; + let own_half = own_rtt_ms as u32 / 2; + base.saturating_sub(own_half).min(u16::MAX as u32) as u16 +} + +// --------------------------------------------------------------------------- +// 证书指纹(替代 viewer 现有的 SkipVerify) +// --------------------------------------------------------------------------- + +/// 自签证书公钥指纹(SHA-256 的 32 字节裸值)。 +/// +/// agent 生成自签证书后把指纹经信令发布,viewer 用它对 QUIC 连接做**公钥钉扎**, +/// 从而去掉 `viewer/src/main.rs` 里的 `SkipVerify`(公网环境不可接受)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertFingerprint(pub [u8; 32]); + +impl CertFingerprint { + /// 小写十六进制(64 字符) + pub fn to_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02x}")).collect() + } + + /// 解析 64 字符十六进制;长度不符或含非十六进制字符返回 `None`。 + pub fn from_hex(s: &str) -> Option { + let s = s.trim(); + if s.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = (chunk[0] as char).to_digit(16)?; + let lo = (chunk[1] as char).to_digit(16)?; + out[i] = (hi * 16 + lo) as u8; + } + Some(Self(out)) + } +} + +impl std::fmt::Display for CertFingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_hex()) + } +} + +// --------------------------------------------------------------------------- +// 端点信息与消息 +// --------------------------------------------------------------------------- + +/// 连通性相关的能力标志。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Capabilities { + /// 已成功获得 UPnP / NAT-PMP / PCP 端口映射 + pub upnp_mapped: bool, + /// 本机具备可用 IPv6 出口 + pub ipv6_available: bool, +} + +/// 一个端点的完整连通性描述,随 Register / PeerReady 交换。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointInfo { + pub role: Role, + /// 本端打洞用的 UDP 监听端口(通常等于 [`PUNCH_UDP_PORT`]) + pub port: u16, + /// 候选地址,发送前请用 [`sort_candidates`] 排好序 + pub candidates: Vec, + pub nat: NatProfile, + /// QUIC 证书公钥指纹,供对端钉扎 + pub cert_fingerprint: CertFingerprint, + pub caps: Capabilities, +} + +/// 打洞失败原因。**无中继方案的用户体验下限就在这里** —— 每种原因都要能翻译成 +/// 用户能照做的一句话,见 [`PunchFailReason::user_hint`]。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PunchFailReason { + /// 所有候选都无回包(最常见:双方都是地址+端口相关型 NAT) + NoCandidateReachable, + /// 双方均为对称 NAT 且端口不可预测 + SymmetricNat, + /// 运营商级 NAT(100.64/10) + Cgnat, + /// 本机 UDP 出站被策略封锁(企业防火墙常见) + UdpBlocked, + /// 对端提前退出 + PeerAborted, + /// 超时未收到任何回包 + Timeout, +} + +impl PunchFailReason { + /// 面向用户的中文处置建议。不要吞掉这个信息 —— 用户看不到原因就只会认为"这软件连不上"。 + pub fn user_hint(self) -> &'static str { + match self { + PunchFailReason::NoCandidateReachable => { + "双方网络都无法直接互通。请在被控端路由器开启 UPnP 后重试,或双方改用 IPv6 网络。" + } + PunchFailReason::SymmetricNat => { + "当前网络是对称型 NAT,无法直连。请在被控端路由器开启 UPnP,或更换网络后重试。" + } + PunchFailReason::Cgnat => { + "当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络,或换一条能拿到公网地址的线路。" + } + PunchFailReason::UdpBlocked => { + "当前网络的防火墙拦截了 UDP 出站。请更换网络(如切换手机热点)后重试。" + } + PunchFailReason::PeerAborted => "对端已取消本次连接。", + PunchFailReason::Timeout => "连接超时,可能是网络波动。请重试一次。", + } + } +} + +/// 打洞结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PunchOutcome { + /// 打通:`peer` 为真正生效的对端地址,`rtt_ms` 为实测往返 + Connected { peer: SocketAddr, rtt_ms: u16 }, + Failed { reason: PunchFailReason }, +} + +impl PunchOutcome { + pub fn is_connected(&self) -> bool { + matches!(self, PunchOutcome::Connected { .. }) + } + + /// 已连通返回 `None`,失败返回给用户看的建议。 + pub fn user_hint(&self) -> Option<&'static str> { + match self { + PunchOutcome::Connected { .. } => None, + PunchOutcome::Failed { reason } => Some(reason.user_hint()), + } + } +} + +/// 信令层错误码。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SignalError { + /// [`SIGNALING_VERSION`] 不匹配(客户端需升级) + VersionMismatch, + /// 验证码错误或已过期 + BadAuth, + SessionNotFound, + PeerOffline, + /// 候选地址数量超限 + TooManyCandidates, + RateLimited, + Internal, +} + +/// 客户端 → 信令服务器。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientMsg { + /// 注册并等待对端。`auth` 必须是一次性验证码(不是 MVP 里的固定 123456)。 + Register { + version: u16, + /// 会话/设备标识;服务器据此配对两端 + session: String, + auth: String, + info: EndpointInfo, + }, + /// 候选变化补报:UPnP 映射稍后才成功、或网卡地址变化时用。 + UpdateCandidates { + session: String, + candidates: Vec, + nat: NatProfile, + }, + /// 打洞结果回报。服务器可据此记录有效路径、统计成功率。 + PunchReport { + session: String, + plan_id: u32, + outcome: PunchOutcome, + }, + /// 保活:维持信令长连接,同时刷新本端 NAT 绑定。 + Keepalive { session: String }, + Bye { + session: String, + reason: String, + }, +} + +/// 信令服务器 → 客户端。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServerMsg { + /// 注册成功。`observed` 是服务器看到的本端公网映射,等价于一次 STUN binding; + /// `rtt_ms` 供打洞计划做时间对齐(见 [`punch_start_delay_ms`])。 + Registered { + session: String, + observed: SocketAddr, + /// 对端是否已在场。`false` 时应等待 [`ServerMsg::PeerReady`]。 + peer_present: bool, + rtt_ms: u16, + }, + /// 对端已就绪,附带其候选地址、NAT 画像与证书指纹。 + PeerReady { + peer: EndpointInfo, + peer_observed: SocketAddr, + /// 打洞前景,可直接显示给用户("很可能直连成功" / "当前网络可能无法直连") + outlook: PunchOutlook, + }, + /// 同步打洞计划。 + /// + /// `start_in_ms` 是**相对本消息**的延迟,服务器对两侧下发不同的值以使双方在同一 + /// 绝对时刻开打 —— 因此本协议不要求两端时钟同步。 + PunchPlan { + plan_id: u32, + start_in_ms: u16, + rounds: u8, + round_interval_ms: u16, + /// 对端候选(已排序、已清洗);本端需对其中每个地址发包 + peer_candidates: Vec, + }, + /// 打洞结果确认(用于 UI 显示"直连成功,RTT xx ms")。 + PunchAck { plan_id: u32, outcome: PunchOutcome }, + Error { code: SignalError, message: String }, +} + +// --------------------------------------------------------------------------- +// 测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn fp(byte: u8) -> CertFingerprint { + CertFingerprint([byte; 32]) + } + + fn info(role: Role) -> EndpointInfo { + let mut candidates = vec![ + Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive), + Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host), + Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp), + ]; + sort_candidates(&mut candidates); + EndpointInfo { + role, + port: PUNCH_UDP_PORT, + candidates, + nat: NatProfile { + mapping: MappingBehavior::AddressDependent, + filtering: FilteringBehavior::AddressAndPortDependent, + allocation: PortAllocation::Sequential, + binding_timeout_s: Some(30), + hairpinning: false, + }, + cert_fingerprint: fp(0xab), + caps: Capabilities { + upnp_mapped: true, + ipv6_available: true, + }, + } + } + + fn client_msgs() -> Vec { + vec![ + ClientMsg::Register { + version: SIGNALING_VERSION, + session: "dev-1".into(), + auth: "0f3a91".into(), + info: info(Role::Agent), + }, + ClientMsg::UpdateCandidates { + session: "dev-1".into(), + candidates: vec![Candidate::new( + "5.6.7.8:47800".parse().unwrap(), + CandidateKind::Predicted, + )], + nat: NatProfile::default(), + }, + ClientMsg::PunchReport { + session: "dev-1".into(), + plan_id: 7, + outcome: PunchOutcome::Connected { + peer: "9.9.9.9:47800".parse().unwrap(), + rtt_ms: 42, + }, + }, + ClientMsg::PunchReport { + session: "dev-1".into(), + plan_id: 8, + outcome: PunchOutcome::Failed { + reason: PunchFailReason::Cgnat, + }, + }, + ClientMsg::Keepalive { + session: "dev-1".into(), + }, + ClientMsg::Bye { + session: "dev-1".into(), + reason: "user quit".into(), + }, + ] + } + + fn server_msgs() -> Vec { + vec![ + ServerMsg::Registered { + session: "dev-1".into(), + observed: "203.0.113.9:51234".parse().unwrap(), + peer_present: true, + rtt_ms: 38, + }, + ServerMsg::PeerReady { + peer: info(Role::Viewer), + peer_observed: "203.0.113.10:51235".parse().unwrap(), + outlook: PunchOutlook::Likely, + }, + ServerMsg::PunchPlan { + plan_id: 9, + start_in_ms: 130, + rounds: PUNCH_DEFAULT_ROUNDS, + round_interval_ms: PUNCH_ROUND_INTERVAL_MS, + peer_candidates: info(Role::Viewer).candidates, + }, + ServerMsg::PunchAck { + plan_id: 9, + outcome: PunchOutcome::Failed { + reason: PunchFailReason::SymmetricNat, + }, + }, + ServerMsg::Error { + code: SignalError::VersionMismatch, + message: "server expects v1".into(), + }, + ] + } + + #[test] + fn bincode_roundtrip_client_msgs() { + for m in client_msgs() { + let bytes = bincode::serialize(&m).expect("serialize"); + let back: ClientMsg = bincode::deserialize(&bytes).expect("deserialize"); + assert_eq!(m, back); + } + } + + #[test] + fn bincode_roundtrip_server_msgs() { + for m in server_msgs() { + let bytes = bincode::serialize(&m).expect("serialize"); + let back: ServerMsg = bincode::deserialize(&bytes).expect("deserialize"); + assert_eq!(m, back); + } + } + + #[test] + fn candidates_sorted_ipv6_first_then_upnp() { + let mut v = vec![ + Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive), + Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host), + Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp), + ]; + sort_candidates(&mut v); + assert!(v[0].is_ipv6(), "IPv6 候选必须排第一"); + assert_eq!(v[1].kind, CandidateKind::Upnp); + assert_eq!(v[2].kind, CandidateKind::Reflexive); + } + + #[test] + fn sanitize_dedups_and_bounds() { + let dup = Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Host); + let zero_port = Candidate::new("1.2.3.4:0".parse().unwrap(), CandidateKind::Host); + let mut v = vec![dup, dup, zero_port]; + v.extend((0..MAX_CANDIDATES + 5).map(|i| { + Candidate::new( + format!("10.0.{}.{}:47800", i / 250, i % 250).parse().unwrap(), + CandidateKind::Predicted, + ) + })); + let out = sanitize_candidates(v); + assert_eq!(out.len(), MAX_CANDIDATES); + assert!(out.iter().all(|c| c.addr.port() != 0)); + let mut addrs: Vec<_> = out.iter().map(|c| c.addr).collect(); + addrs.sort(); + addrs.dedup(); + assert_eq!(addrs.len(), out.len()); + } + + #[test] + fn outlook_covers_ipv6_cgnat_and_symmetric() { + let v6: IpAddr = "2001:db8::1".parse().unwrap(); + let public: IpAddr = "203.0.113.9".parse().unwrap(); + let cgnat: IpAddr = "100.64.1.1".parse().unwrap(); + + let mut nat = NatProfile::default(); + assert_eq!(punch_outlook(&nat, v6), PunchOutlook::Likely); + assert_eq!(punch_outlook(&nat, cgnat), PunchOutlook::Hopeless); + assert!(is_cgnat("100.127.255.254".parse().unwrap())); + assert!(!is_cgnat("100.128.0.1".parse().unwrap())); + + nat.mapping = MappingBehavior::EndpointIndependent; + assert_eq!(punch_outlook(&nat, public), PunchOutlook::Likely); + + nat.mapping = MappingBehavior::AddressAndPortDependent; + nat.allocation = PortAllocation::Sequential; + assert_eq!(punch_outlook(&nat, public), PunchOutlook::Possible); + + nat.allocation = PortAllocation::Random; + assert_eq!(punch_outlook(&nat, public), PunchOutlook::Unlikely); + } + + #[test] + fn keepalive_halves_binding_timeout_and_clamps() { + let a = NatProfile { + binding_timeout_s: Some(30), + ..Default::default() + }; + let b = NatProfile { + binding_timeout_s: Some(20), + ..Default::default() + }; + assert_eq!(keepalive_secs(&a, &b), 10); + assert_eq!(keepalive_secs(&NatProfile::default(), &NatProfile::default()), 7); + let short = NatProfile { + binding_timeout_s: Some(4), + ..Default::default() + }; + assert_eq!(keepalive_secs(&short, &short), KEEPALIVE_MIN_S); + let long = NatProfile { + binding_timeout_s: Some(600), + ..Default::default() + }; + assert_eq!(keepalive_secs(&long, &long), KEEPALIVE_MAX_S); + } + + #[test] + fn punch_start_delay_aligns_absolute_time() { + // RTT 大的一方应该更早出发,使两侧绝对开打时刻一致。 + let slow = punch_start_delay_ms(200, 200, 100); + let fast = punch_start_delay_ms(20, 200, 100); + assert_eq!(slow, 100); + assert_eq!(fast, 190); + assert!(fast > slow); + } + + #[test] + fn cert_fingerprint_hex_roundtrip() { + let f = fp(0x0f); + let hex = f.to_hex(); + assert_eq!(hex.len(), 64); + assert_eq!(CertFingerprint::from_hex(&hex), Some(f)); + assert_eq!(CertFingerprint::from_hex(&hex.to_uppercase()), Some(f)); + assert_eq!(CertFingerprint::from_hex("00"), None); + assert_eq!(CertFingerprint::from_hex(&"z".repeat(64)), None); + } + + #[test] + fn fail_reasons_all_have_user_hints() { + let all = [ + PunchFailReason::NoCandidateReachable, + PunchFailReason::SymmetricNat, + PunchFailReason::Cgnat, + PunchFailReason::UdpBlocked, + PunchFailReason::PeerAborted, + PunchFailReason::Timeout, + ]; + for r in all { + assert!(!r.user_hint().is_empty()); + let o = PunchOutcome::Failed { reason: r }; + assert!(!o.is_connected()); + assert!(o.user_hint().is_some()); + } + let ok = PunchOutcome::Connected { + peer: "1.2.3.4:47800".parse().unwrap(), + rtt_ms: 30, + }; + assert!(ok.is_connected()); + assert!(ok.user_hint().is_none()); + } +}