feat(common): 新增 P2P 打洞信令协议(无中继)
- signaling.rs: 信令消息契约 + NAT 画像/打洞前景诊断 + 证书指纹 - 明确不含 relay/TURN:打洞失败必须给出可执行原因(PunchFailReason::user_hint) - 时间对齐不做时钟同步:服务器按两侧 RTT 下发不同 start_in_ms - 保活按 binding_timeout_s 折半(keepalive_secs)
This commit is contained in:
parent
9fda49d9c9
commit
bb392507bd
@ -1,5 +1,8 @@
|
|||||||
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
|
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
|
||||||
|
|
||||||
|
/// P2P 打洞信令协议(信令通道专用,不含中继;三端共享契约)
|
||||||
|
pub mod signaling;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|||||||
757
common/src/signaling.rs
Normal file
757
common/src/signaling.rs
Normal file
@ -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<Candidate>) -> Vec<Candidate> {
|
||||||
|
let mut out: Vec<Candidate> = 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<u16>,
|
||||||
|
/// 是否支持 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<Self> {
|
||||||
|
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<Candidate>,
|
||||||
|
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<Candidate>,
|
||||||
|
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<Candidate>,
|
||||||
|
},
|
||||||
|
/// 打洞结果确认(用于 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<ClientMsg> {
|
||||||
|
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<ServerMsg> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user