//! 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()); } }