Compare commits
No commits in common. "feature/common-punch-signaling" and "main" have entirely different histories.
feature/co
...
main
@ -27,4 +27,4 @@ agent.exe --port 47800 --code 123456
|
||||
viewer.exe --addr <被控机IP> --port 47800 --code 123456
|
||||
```
|
||||
|
||||
详见《构建运行手册.md》、《Git协作规范.md》与《打洞与信令设计.md》。
|
||||
详见《构建运行手册.md》与《Git协作规范.md》。
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
|
||||
|
||||
/// P2P 打洞信令协议(信令通道专用,不含中继;三端共享契约)
|
||||
pub mod signaling;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@ -1,757 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@ -1,365 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UDP 打洞探针 —— 两台不同网络的机器各跑一个,用来摸清你们线路的底牌。
|
||||
|
||||
回答四个问题:
|
||||
1. 这条线路的 NAT 能不能打洞?
|
||||
2. 要几轮才能打通?(轮数越少说明 NAT 越宽松)
|
||||
3. 对方的源端口有没有被 NAT 改写?(判断是不是对称 NAT)
|
||||
4. 有没有公网 IPv6?IPv6 直连通不通?
|
||||
|
||||
用法
|
||||
----
|
||||
A 机(假设公网 1.2.3.4):
|
||||
py punch_probe.py --peer 2.3.4.5:47800
|
||||
|
||||
B 机(假设公网 2.3.4.5):
|
||||
py punch_probe.py --peer 1.2.3.4:47800
|
||||
|
||||
IPv6(双方都有公网 v6 时):
|
||||
py punch_probe.py --peer [2408:8207:1234::1]:47800
|
||||
|
||||
先查自己的公网映射(走 STUN,不需要对方):
|
||||
py punch_probe.py --stun
|
||||
|
||||
被动监听(不发包,只看能不能收到对方打进来的包):
|
||||
py punch_probe.py
|
||||
|
||||
说明
|
||||
----
|
||||
- 两台机器必须**同时**启动,脚本靠「双方同时向对方发包」来穿透 NAT。
|
||||
- 先各自查公网 IP(浏览器搜 "my ip" 或 --stun),再互相填。
|
||||
- 只依赖 Python 标准库。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAGIC = b"PUNCHv1|"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 地址解析
|
||||
|
||||
def parse_addr(s):
|
||||
"""解析 'ip:port' 或 '[v6]:port' -> (host, port, family)"""
|
||||
s = s.strip()
|
||||
if s.startswith("["):
|
||||
end = s.index("]")
|
||||
host = s[1:end]
|
||||
port = int(s[end + 2:])
|
||||
return host, port, socket.AF_INET6
|
||||
host, _, p = s.rpartition(":")
|
||||
return host, int(p), socket.AF_INET
|
||||
|
||||
|
||||
def fmt_addr(sockaddr):
|
||||
host, port = sockaddr[0], sockaddr[1]
|
||||
if ":" in host:
|
||||
return "[%s]:%d" % (host, port)
|
||||
return "%s:%d" % (host, port)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- STUN
|
||||
|
||||
def stun_mapped(server="stun.l.google.com", port=19302, timeout=3.0, sock=None):
|
||||
"""最小 STUN Binding 请求,返回 (ip, port);失败返回 None。
|
||||
|
||||
sock 不为 None 时复用它(用来测「同一个本地端口对另一个目标」的映射端口,
|
||||
这是判断 NAT 是不是对称型的关键)。
|
||||
"""
|
||||
txid = b"\x5a" * 12
|
||||
req = struct.pack(">HHI12s", 0x0001, 0, 0x2112A442, txid)
|
||||
try:
|
||||
infos = socket.getaddrinfo(server, port, 0, socket.SOCK_DGRAM)
|
||||
except OSError:
|
||||
return None
|
||||
fam, sockaddr = infos[0][0], infos[0][4]
|
||||
own = sock is None
|
||||
s = socket.socket(fam, socket.SOCK_DGRAM) if own else sock
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.sendto(req, sockaddr)
|
||||
data, _ = s.recvfrom(2048)
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
if own:
|
||||
s.close()
|
||||
|
||||
if len(data) < 20:
|
||||
return None
|
||||
off = 20
|
||||
while off + 4 <= len(data):
|
||||
atype, alen = struct.unpack(">HH", data[off:off + 4])
|
||||
val = data[off + 4:off + 4 + alen]
|
||||
if atype == 0x0020 and len(val) >= 8: # XOR-MAPPED-ADDRESS
|
||||
famv = val[1]
|
||||
xport = struct.unpack(">H", val[2:4])[0] ^ 0x2112
|
||||
if famv == 0x01 and len(val) >= 8:
|
||||
ip = bytes(a ^ b for a, b in zip(val[4:8], b"\x21\x12\xA4\x42"))
|
||||
return socket.inet_ntoa(ip), xport
|
||||
if famv == 0x02 and len(val) >= 20:
|
||||
mask = b"\x21\x12\xA4\x42" + txid
|
||||
ip = bytes(a ^ b for a, b in zip(val[4:20], mask))
|
||||
return socket.inet_ntop(socket.AF_INET6, ip), xport
|
||||
off += 4 + alen + ((4 - alen % 4) % 4)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 信令
|
||||
|
||||
def discover_peer(sock, server, room, timeout=60.0):
|
||||
"""注册到房间,等服务器回推对方的公网地址。
|
||||
|
||||
注意:这里**复用同一个 socket**——服务器看到的源地址,就是 NAT 为
|
||||
「本机 → 服务器」这条流分配的映射。对锥形 NAT 而言,它与「本机 → 对端」
|
||||
的映射相同,所以拿它去打洞是有效的;对对称 NAT 则不同,打不通属预期。
|
||||
"""
|
||||
host, port = server[0], server[1]
|
||||
deadline = time.time() + timeout
|
||||
last_send = 0.0
|
||||
while time.time() < deadline:
|
||||
now = time.time()
|
||||
if now - last_send > 1.0:
|
||||
try:
|
||||
sock.sendto(b"REGISTER|" + room.encode("utf-8"), (host, port))
|
||||
except OSError as e:
|
||||
print(" 注册发送失败:%s" % e)
|
||||
return None
|
||||
last_send = now
|
||||
try:
|
||||
data, _src = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except ConnectionResetError:
|
||||
continue
|
||||
except OSError as e:
|
||||
if getattr(e, "winerror", None) == 10054:
|
||||
continue
|
||||
return None
|
||||
txt = data.decode("utf-8", "replace").strip()
|
||||
if txt.startswith("PEER|"):
|
||||
h, p, _f = parse_addr(txt[5:])
|
||||
return (h, p)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 主逻辑
|
||||
|
||||
class Probe:
|
||||
def __init__(self, bind_host, port, family):
|
||||
self.sock = socket.socket(family, socket.SOCK_DGRAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
if family == socket.AF_INET6:
|
||||
try:
|
||||
self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
except OSError:
|
||||
pass
|
||||
self.sock.bind((bind_host, port))
|
||||
self.sock.settimeout(0.2)
|
||||
# Windows 特有坑:曾经发过包给对方,但对方端口没开 → 会收到 ICMP 端口不可达,
|
||||
# 之后下一次 recv 直接抛 WSAECONNRESET(10054)。不打这个 ioctl,
|
||||
# 接收循环会被"自己之前的失败发送"打断,之后再也收不到包。
|
||||
# (Rust 实现同样要处理:tokio recv 返回 ConnectionReset 时不能退出循环)
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
self.sock.ioctl(socket.SIO_UDP_CONNRESET, False)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
self.port = self.sock.getsockname()[1]
|
||||
self.stop = threading.Event()
|
||||
self.first_src = None
|
||||
self.packets = 0
|
||||
self.junk = 0
|
||||
|
||||
def receiver(self, ignore=()):
|
||||
"""接收线程。
|
||||
|
||||
只认「以 MAGIC 开头」且「来自对端而非信令服务器」的包。
|
||||
信令服务器的 PEER/WAIT 回复绝不能算作打通——否则客户端反复注册引来的
|
||||
回复会造成假阳性(已踩过:把服务器的 PEER 回复误判成打洞成功)。
|
||||
"""
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
data, src = self.sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except ConnectionResetError:
|
||||
# ICMP 端口不可达留下的错误,忽略并继续收(绝不能 break)
|
||||
continue
|
||||
except OSError as e:
|
||||
if getattr(e, "winerror", None) == 10054:
|
||||
continue
|
||||
break
|
||||
|
||||
# ---- 过滤:非打洞包一律丢弃
|
||||
if not data.startswith(MAGIC) or (ignore and src[:2] in ignore):
|
||||
self.junk += 1
|
||||
continue
|
||||
|
||||
if self.first_src is None:
|
||||
self.first_src = src
|
||||
print("\n ✅ 收到对方的包!")
|
||||
print(" 对方源地址(NAT 之后)= %s" % fmt_addr(src))
|
||||
if src[1] != self.port:
|
||||
print(" 对方源端口 = %d(本机端口 %d);若双方约定端口相同,"
|
||||
"说明 NAT 改写了端口" % (src[1], self.port))
|
||||
else:
|
||||
print(" 对方源端口与约定端口一致 → NAT 保留了端口")
|
||||
self.packets += 1
|
||||
if self.packets <= 3:
|
||||
print(" [%s] 收到 %d 字节" % (fmt_addr(src), len(data)))
|
||||
|
||||
def send_rounds(self, peer_sockaddr, rounds, interval):
|
||||
for i in range(1, rounds + 1):
|
||||
if self.stop.is_set():
|
||||
return
|
||||
payload = MAGIC + struct.pack(">d", time.time())
|
||||
try:
|
||||
self.sock.sendto(payload, peer_sockaddr)
|
||||
except OSError as e:
|
||||
print(" 发送失败:%s" % e)
|
||||
return
|
||||
if i == 1 or i % 5 == 0 or self.first_src is not None:
|
||||
mark = "✅" if self.first_src is not None else " "
|
||||
print(" %s 第 %2d/%d 轮已发出" % (mark, i, rounds))
|
||||
if self.first_src is not None:
|
||||
return
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(add_help=True, description="UDP 打洞探针")
|
||||
ap.add_argument("--peer", help="对方地址 ip:port 或 [v6]:port;不给则只被动监听")
|
||||
ap.add_argument("--port", type=int, default=47800, help="本地端口(默认 47800)")
|
||||
ap.add_argument("--rounds", type=int, default=25, help="发包轮数(默认 25)")
|
||||
ap.add_argument("--interval", type=float, default=0.2, help="轮间隔秒(默认 0.2)")
|
||||
ap.add_argument("--grace", type=float, default=2.5,
|
||||
help="发完后继续监听多少秒等回包(默认 2.5)")
|
||||
ap.add_argument("--server", help="信令服务器 ip:port(与 --room 配合,自动配对)")
|
||||
ap.add_argument("--room", help="房间名;同一房间的两个客户端会被配对")
|
||||
ap.add_argument("--wait", type=float, default=60.0,
|
||||
help="等对方加入房间的秒数(默认 60)")
|
||||
ap.add_argument("--stun", action="store_true", help="只查询自己的公网映射后退出")
|
||||
ap.add_argument("--stun-port", type=int, default=0,
|
||||
help="配合 --stun:用指定本地端口发 STUN(与打洞端口一致时才能对比映射)")
|
||||
args = ap.parse_args()
|
||||
|
||||
# ---- 只查 STUN
|
||||
if args.stun:
|
||||
s = None
|
||||
if args.stun_port:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("0.0.0.0", args.stun_port))
|
||||
print("[STUN] 用本机端口 %d 查询(与打洞同端口)..." % args.stun_port)
|
||||
else:
|
||||
print("[STUN] 查询公网映射(临时端口)...")
|
||||
r = stun_mapped(sock=s)
|
||||
if s:
|
||||
s.close()
|
||||
if r:
|
||||
print(" 对 Google STUN 的映射 = %s:%d" % r)
|
||||
if args.stun_port:
|
||||
print(" 本机端口 = %d" % args.stun_port)
|
||||
print(" 下一步:对比信令服务器日志里记录的源端口")
|
||||
print(" 两者相同 → 端点无关映射(锥形 NAT)→ 打洞大概率成功")
|
||||
print(" 两者不同 → 对称 NAT → 打洞困难,需要端口预测或中继")
|
||||
else:
|
||||
print(" 查询失败(STUN 19302 可能被封;可稍后重试)")
|
||||
return 0
|
||||
|
||||
# ---- 决定地址族与模式
|
||||
peer = None
|
||||
if args.peer:
|
||||
host, pport, family = parse_addr(args.peer)
|
||||
peer = (host, pport)
|
||||
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||||
mode = "双向打洞(手工填地址)"
|
||||
elif args.server and args.room:
|
||||
_h, _p, family = parse_addr(args.server)
|
||||
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||||
mode = "信令自动配对 + 打洞"
|
||||
else:
|
||||
family = socket.AF_INET
|
||||
bind_host = "0.0.0.0"
|
||||
mode = "被动监听"
|
||||
|
||||
probe = Probe(bind_host, args.port, family)
|
||||
print("=" * 60)
|
||||
print(" UDP 打洞探针 模式=%s" % mode)
|
||||
print(" 本机监听 %s:%d" % (bind_host, probe.port))
|
||||
if peer:
|
||||
print(" 发往 %s (%s)" % (fmt_addr(peer + (0, 0)),
|
||||
"IPv6" if family == socket.AF_INET6 else "IPv4"))
|
||||
print("=" * 60)
|
||||
|
||||
# ---- 信令配对:不用自己查公网 IP,服务器看到的就是你的公网映射
|
||||
if args.server and args.room and peer is None:
|
||||
srv = parse_addr(args.server)[:2]
|
||||
print("\n向信令服务器 %s 注册 room=%s ..." % (fmt_addr(srv), args.room))
|
||||
found = discover_peer(probe.sock, srv, args.room, timeout=args.wait)
|
||||
if found is None:
|
||||
print(" ❌ 超时:没等到对方加入同一个房间")
|
||||
probe.sock.close()
|
||||
return 1
|
||||
peer = found
|
||||
print(" ✅ 配对成功,对方公网地址 = %s" % fmt_addr(found))
|
||||
|
||||
# 信令服务器的回复(PEER/WAIT)绝不能算作打通,接收线程必须把它排除掉
|
||||
ignore = set()
|
||||
if args.server:
|
||||
_sh, _sp, _sf = parse_addr(args.server)
|
||||
ignore.add((_sh, _sp))
|
||||
|
||||
t = threading.Thread(target=probe.receiver, args=(ignore,), daemon=True)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
if peer:
|
||||
print("\n开始同时发包(若对方也在发包,第 1~3 轮通常就通)...")
|
||||
probe.send_rounds(peer, args.rounds, args.interval)
|
||||
# 对方可能比我晚启动:发完后继续监听,否则会把"晚到的成功"误判成失败
|
||||
if probe.first_src is None:
|
||||
print("\n发包结束,继续监听 %.1f 秒等对方回包 ..." % args.grace)
|
||||
waited = 0.0
|
||||
while waited < args.grace and probe.first_src is None:
|
||||
time.sleep(0.1)
|
||||
waited += 0.1
|
||||
else:
|
||||
print("\n被动监听中,Ctrl+C 退出 ...")
|
||||
while not probe.stop.is_set():
|
||||
time.sleep(0.3)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
probe.stop.set()
|
||||
time.sleep(0.3)
|
||||
probe.sock.close()
|
||||
|
||||
print("\n" + "-" * 60)
|
||||
if probe.first_src is not None:
|
||||
print("结论:✅ 打洞成功")
|
||||
print(" 对方实际源地址 = %s" % fmt_addr(probe.first_src))
|
||||
print(" 共收到 %d 个包" % probe.packets)
|
||||
else:
|
||||
print("结论:❌ 本轮没有收到对方的打洞包")
|
||||
if probe.junk:
|
||||
print(" (另外收到 %d 个非打洞包并已忽略:那是信令服务器的回复,不算打通)"
|
||||
% probe.junk)
|
||||
print(" 可能原因(按概率排序):")
|
||||
print(" 1. 一端或两端是对称型 NAT / 运营商级 NAT(CGNAT) → 需要端口预测或中继")
|
||||
print(" 2. 一方根本没发包(对方没启动、或地址填错/端口填错)")
|
||||
print(" 3. UDP 被防火墙拦截(企业网络常见)")
|
||||
print(" 4. 双方没有真正同时发包(时间差超过 NAT 表项寿命)")
|
||||
print(" 下一步:两端各跑一次 --stun,看看是不是落在 100.64~100.127 网段(CGNAT)")
|
||||
print("-" * 60)
|
||||
return 0 if probe.first_src is not None else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""极简 rendezvous(信令)服务器 —— POC 用
|
||||
|
||||
作用只有一个:**告诉双方「对方看到的你」是什么地址**。
|
||||
双方注册到同一个房间后,服务器把各自观察到的公网地址回推给对方,
|
||||
之后双方直接打洞,服务器不再参与(也不承载任何媒体流量)。
|
||||
|
||||
用法(跑在有公网 IP 的机器上,比如 43.140.215.244):
|
||||
py rendezvous.py --port 47999
|
||||
|
||||
客户端:
|
||||
py punch_probe.py --server <服务器IP>:47999 --room test01 --port 47800
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 房间成员多久没动静就清掉
|
||||
MEMBER_TTL = 30.0
|
||||
|
||||
|
||||
def fmt(sockaddr):
|
||||
host, port = sockaddr[0], sockaddr[1]
|
||||
return "[%s]:%d" % (host, port) if ":" in host else "%s:%d" % (host, port)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="极简 rendezvous 服务器")
|
||||
ap.add_argument("--bind", default="0.0.0.0")
|
||||
ap.add_argument("--port", type=int, default=47999)
|
||||
args = ap.parse_args()
|
||||
|
||||
# 重定向到文件时 stdout 会全缓冲,日志要等进程退出才出现;强制行缓冲
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((args.bind, args.port))
|
||||
|
||||
print("[rendezvous] 监听 udp://%s:%d" % (args.bind, args.port))
|
||||
print("[rendezvous] 房间成员 TTL = %.0fs,Ctrl+C 退出\n" % MEMBER_TTL)
|
||||
|
||||
rooms = {} # room -> [(addr, last_seen)]
|
||||
|
||||
try:
|
||||
while True:
|
||||
data, src = sock.recvfrom(2048)
|
||||
txt = data.decode("utf-8", "replace").strip()
|
||||
now = time.time()
|
||||
|
||||
if not txt.startswith("REGISTER|"):
|
||||
continue
|
||||
room = txt.split("|", 1)[1].strip()
|
||||
if not room:
|
||||
continue
|
||||
|
||||
# 清掉过期成员 + 去掉本机的旧记录(支持重复注册)
|
||||
members = [(a, t) for (a, t) in rooms.get(room, [])
|
||||
if now - t < MEMBER_TTL and a[1] != src[1]]
|
||||
members.append((src, now))
|
||||
rooms[room] = members
|
||||
|
||||
print("[rendezvous] %s 注册 room=%s (现有 %d 人) 源地址=%s"
|
||||
% (fmt(src), room, len(members), fmt(src)))
|
||||
|
||||
if len(members) >= 2:
|
||||
a1, _ = members[0]
|
||||
a2, _ = members[1]
|
||||
sock.sendto(b"PEER|" + fmt(a2).encode(), a1)
|
||||
sock.sendto(b"PEER|" + fmt(a1).encode(), a2)
|
||||
print("[rendezvous] -> 配对成功:%s <==> %s"
|
||||
% (fmt(a1), fmt(a2)))
|
||||
else:
|
||||
sock.sendto(b"WAIT", src)
|
||||
print("[rendezvous] -> 等待第二个人加入 ...")
|
||||
except KeyboardInterrupt:
|
||||
print("\n[rendezvous] 退出")
|
||||
finally:
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
301
借鉴映射与落地规范.md
301
借鉴映射与落地规范.md
@ -1,301 +0,0 @@
|
||||
# 借鉴映射与落地规范
|
||||
|
||||
> 配套《多平台架构设计.md》。本文回答三个问题:**每个模块怎么设计、看谁、抄什么、不抄什么。**
|
||||
> 状态:架构部分已定稿;第 2 章的「逆向需求清单」等同事的逆向资料回填。
|
||||
|
||||
---
|
||||
|
||||
## 0. 使用方式
|
||||
|
||||
| 角色 | 看哪几章 |
|
||||
|---|---|
|
||||
| A(被控侧) | §3.5 platform、§3.6 agent-core、§4 表中 agent 相关行 |
|
||||
| B(主控+协议) | §3.1–3.4、§3.7 viewer-core、§5 许可 |
|
||||
| 移动端(待补人) | §3.4 media(解码后端)、§3.8 apps |
|
||||
| 全员 | §2 逆向红线(踩了会出大事) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 两条铁律
|
||||
|
||||
1. **架构由需求决定,不由参考项目决定。** 参考项目用来解决"这一块业界怎么做的",不是用来决定"我们做成什么样"。
|
||||
2. **能抄的是算法和协议思路,抄不到的是平台集成和 UI。** 后者是体力活,任何参考项目都救不了(它们自己也是硬写的)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 逆向资料的用法与红线
|
||||
|
||||
### 2.1 红线(务必转告同事)
|
||||
|
||||
| 可以做 | 不可以做 |
|
||||
|---|---|
|
||||
| 记录**功能清单、交互流程、界面布局** | 把反编译产物、反汇编清单提交进仓库 |
|
||||
| 记录**用户可见的错误提示、状态显示** | 逐字复制对方的 UI 文案、图标、素材 |
|
||||
| 推断**产品模型**(账号/设备/授权怎么设计) | 抄对方私有协议的具体编码(抄了也不能互通) |
|
||||
| 记录**网络行为的宏观特征**(是否中转、连接耗时) | 提取并移植对方的算法实现 |
|
||||
|
||||
**仓库里只提交"行为结论",不提交"逆向产物"。** 建议逆向资料单独放一个**不纳入 git** 的目录(或内部网盘),文档里只写结论。这条不是洁癖——商用产品里出现竞品反编译代码是实打实的法律风险。
|
||||
|
||||
### 2.2 逆向需求清单(直接发给同事)
|
||||
|
||||
**A. 产品功能面(最重要,优先做)**
|
||||
|
||||
1. 主控端完整交互流程截图流:登录 → 设备列表 → 连接(输密码/免密)→ 会话中 → 断开
|
||||
2. 每个平台的功能矩阵:Win/Linux/Android/iOS 各有哪些功能、哪些缺失
|
||||
3. 会话中工具栏全清单:分辨率切换、多屏、文件传输、剪贴板、音频开关、画质/帧率档位、游戏模式
|
||||
4. 设置项全清单(分"被控设置"和"主控设置"两类)
|
||||
5. 错误提示文案全集 + 断线重连时的用户可见行为
|
||||
|
||||
**B. 身份与授权模型**
|
||||
|
||||
6. 设备 ID 的形式(长度/字符集)、在哪里查看、能否自定义
|
||||
7. 授权方式有几种:临时密码 / 固定密码 / 免密白名单 / 账号直接授权?默认是哪种
|
||||
8. 账号体系:是否必须登录、登录后设备列表怎么呈现、一台设备能否被多账号访问
|
||||
9. 被控端的"允许谁连"控制(黑名单/白名单/单次确认弹窗)
|
||||
|
||||
**C. 被控端形态**
|
||||
|
||||
10. 是不是系统服务?开机自启吗?锁屏/注销/UAC 提权界面能不能连?
|
||||
11. 安装包形态、是否需要管理员权限、卸载残留
|
||||
|
||||
**D. 网络行为(只看宏观,不要深入协议)**
|
||||
|
||||
12. 连接时用户是否能看到"直连 / 中转"标识
|
||||
13. 典型连接耗时、转中继时的画质变化
|
||||
14. 端口/协议宏观特征:UDP 还是 TCP 为主、监听端口是否固定
|
||||
|
||||
**E. 商业化(可选但有用)**
|
||||
|
||||
15. 免费额度/收费点/增值功能边界
|
||||
|
||||
### 2.3 逆向资料回填位置
|
||||
|
||||
| 资料 | 回填到 |
|
||||
|---|---|
|
||||
| A 组 | 本文 §6「产品范围基线」+ 里程碑取舍 |
|
||||
| B/C 组 | §3.3 session(身份模型影响状态机) |
|
||||
| D 组 | §3.2 conn(验证我们的打洞/中继策略) |
|
||||
| E 组 | 不在本文,属于商业决策 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块级设计规范
|
||||
|
||||
### 3.1 `proto` — 协议
|
||||
|
||||
**职责**:信令消息、控制消息、媒体容器格式。三端(agent/viewer/信令服务)共享契约。
|
||||
|
||||
**设计点**
|
||||
- **双协议分离**:信令协议(`signaling.rs`,文本友好、低频、可 JSON)与媒体控制协议(`lib.rs`,二进制、高频)**不要混**。前者要便于抓包排查,后者要极致紧凑。
|
||||
- **版本协商**:`SIGNALING_VERSION` 不一致直接拒绝,不做兼容。媒体协议用"能力协商"而非版本号(见 §3.3)。
|
||||
- **消息演进**:现用 bincode。二期若换 protobuf,**必须一次性切换**,不要两套并存。
|
||||
- **单帧长度上限**:现有 `ensure!(len < 1<<20)` 这类边界检查要保留,且信令侧对端候选列表必须经 `sanitize_candidates`。
|
||||
|
||||
**看谁 / 抄什么**
|
||||
|
||||
| 项目 | 抄什么 | 不抄 |
|
||||
|---|---|---|
|
||||
| RustDesk `libs/hbb_common` | 消息分类方式(信令/中继/控制分层) | 具体协议编码(AGPL + 抄了不互通) |
|
||||
| Moonlight `moonlight-common-c` | **控制消息与媒体分离**的成熟做法 | 它的私有控制协议 |
|
||||
|
||||
**产出物**:`crates/proto/src/{lib.rs, signaling.rs}`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `conn` — 连接层(最值得借鉴的一块)
|
||||
|
||||
**职责**:信令客户端、打洞编排、路径选择、QUIC 会话、保活。
|
||||
|
||||
**设计点**
|
||||
- **`PathProvider` 抽象**(已在《打洞与信令设计.md》定义):现在只实现 Direct,类型上预留 Relay。
|
||||
- **打洞编排**:候选收集(v6/Host/UPnP/Reflexive/Predicted)→ 排序 → 信令交换 → 同步打洞 → 落选清理。
|
||||
- **同一 socket 双角色**:打洞用的 UDP socket 必须与后续 QUIC 是同一个,否则洞不在同一映射上。
|
||||
- **保活**:按双方 `binding_timeout_s` 折半,5–60s。**这是"连一会儿就断"的根治点。**
|
||||
- **不用 QUIC 握手当探测**:先用轻量 UDP 探测包确认通路(秒级失败),再 `connect`。
|
||||
|
||||
**看谁 / 抄什么**
|
||||
|
||||
| 项目 | 抄什么 | 许可 |
|
||||
|---|---|---|
|
||||
| **iroh** | **整套**:打洞编排、中继回退、地址发现。技术栈与你们重合(Rust + quinn) | Apache-2.0 ✅ **可直接依赖** |
|
||||
| webrtc-rs / pion/ice | ICE 状态机、候选配对(candidate pairing)策略 | MIT/Apache ✅ |
|
||||
| Tailscale / headscale | DERP 中继的**回退判定条件**与降级策略 | BSD-3 ✅ |
|
||||
| RustDesk `rendezvous_mediator` 相关 | 信令 ↔ 打洞的**编排顺序**(读思路) | AGPL ⚠️ 只读 |
|
||||
| libjuice / libnice | ICE 精简实现(作为对照,不必依赖) | MPL / LGPL |
|
||||
|
||||
> **建议**:`conn` 优先评估**直接依赖 iroh**。它解决的正是你们纠结的"打洞要不要中继",而且给了"直连优先、失败回退"的完整实现。自研仅当 iroh 有硬伤(体积、可控性、特定 NAT 场景)时才考虑。
|
||||
|
||||
**产出物**:`crates/conn/`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `session` — 会话编排("一体程序"的核心)
|
||||
|
||||
**职责**:会话状态机、角色仲裁、能力协商、生命周期管理、被控端准入控制。
|
||||
|
||||
**设计点**
|
||||
- **状态机双线**:主控线 / 被控线各自独立,同一进程可并发(被控中也能主控别人)。
|
||||
- **角色仲裁**:打洞阶段双方对称,**谁先打通谁定角色**(被控方做 QUIC server),不预设。
|
||||
- **能力协商**:被控端上报硬件能力(可用编码器、最大分辨率、帧率上限、是否支持音频),主控端选择。**用能力协商代替版本号**——旧客户端缺字段就是"不支持",天然向后兼容。
|
||||
- **准入控制**:单会话/多会话、白名单、单次确认弹窗。**这一条直接来自 §2 逆向需求 B 组第 7/9 项**,等资料回填后定稿。
|
||||
- **失败出口**:`PunchFailReason::user_hint()` 必须一路透传到 UI,不允许出现无解释的"连接中"。
|
||||
|
||||
**看谁 / 抄什么**
|
||||
|
||||
| 项目 | 抄什么 |
|
||||
|---|---|
|
||||
| RustDesk `src/server` / `src/client` | 会话生命周期与连接管理的分层方式(读思路) |
|
||||
| Sunshine + Moonlight | host/client 两侧的**能力协商**与配置下发流程 |
|
||||
| — | 状态机本身建议自己画,不要套用别人的(各家产品逻辑差异大) |
|
||||
|
||||
**产出物**:`crates/session/`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `media` — 编解码抽象
|
||||
|
||||
**职责**:编码/解码 trait、各平台后端、零拷贝通路、码率自适应闭环。
|
||||
|
||||
**设计点**
|
||||
- **不对称策略**:**编码用 FFmpeg**(只在 Win/Linux,桌面端不心疼包体),**解码用平台原生**(四个主控平台都要,移动端必须硬解且要零拷贝)。
|
||||
- **trait 收口**(见《多平台架构设计.md》§4 决策 4),上层不允许出现平台判断。
|
||||
- **零拷贝**:解码器直接输出到渲染表面(D3D11 纹理 / `SurfaceView` / `CAMetalLayer`),中间不做 CPU 拷贝。
|
||||
- **软硬兜底**:硬编失败回落 openh264;硬解失败回落软解(桌面可,移动端应直接提示而非硬撑)。
|
||||
- **码率自适应闭环**:接收端统计(丢包/抖动/帧间隔 p95)→ 反馈编码端 → AIMD 调码率。协议已留 `RateReport`(见 B侧设计文档 B2)。
|
||||
|
||||
**看谁 / 抄什么**
|
||||
|
||||
| 项目 | 抄什么 | 许可 |
|
||||
|---|---|---|
|
||||
| **libwebrtc** `modules/congestion_controller/goog_cc` | **GCC 拥塞控制**的完整思路——这是码率自适应的教科书 | BSD-3 ✅ 可参考可移植 |
|
||||
| **libwebrtc** `modules/video_coding/jitter_buffer` | 抖动缓冲与丢包重排 | BSD-3 ✅ |
|
||||
| **Sunshine** `src/video` + `src/platform` | **抓屏→编码→打包**的完整管线组织,尤其是多编码器抽象与回落 | GPL-3.0 ⚠️ 只读 |
|
||||
| **Moonlight** `moonlight-common-c` | 接收端解码→渲染→HUD 的管线,以及**丢包恢复策略** | GPL-3.0 ⚠️ 只读 |
|
||||
| FFmpeg | NVENC/QSV/AMF/VAAPI 的调用姿势与参数 | LGPL/GPL ⚠️ 注意构建选项 |
|
||||
| openh264 | 软编兜底(已在用) | BSD ✅ |
|
||||
|
||||
**产出物**:`crates/media/`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 `platform` — L0 平台层(抄不到,只能自己写)
|
||||
|
||||
**职责**:抓屏、注入、渲染表面、音频 IO、权限、常驻。
|
||||
|
||||
**为什么抄不到**:这层是各平台 API 的体力活。参考项目能给的是"用哪个 API、踩过什么坑",不是可复用代码。
|
||||
|
||||
**设计点**
|
||||
|
||||
| 平台 | 关键坑 |
|
||||
|---|---|
|
||||
| Win 抓屏 | DXGI DD 在**会话切换/RDP 断开**时失效,必须处理 `DXGI_ERROR_ACCESS_LOST` 并重建 |
|
||||
| Win 注入 | 服务进程注入到用户会话需要 `CreateProcessAsUser` + 会话隔离处理;UAC 提权界面默认收不到输入(需 `uiAccess` 签名) |
|
||||
| Win 常驻 | 服务 + 用户会话代理双进程,锁屏可连靠的是**服务侧保持会话** |
|
||||
| Linux 抓屏 | X11 用 XShm/XComposite;**Wayland 必须走 PipeWire + xdg-desktop-portal**(有授权弹窗,无法静默) |
|
||||
| Linux 注入 | X11 用 XTest;**Wayland 用 libei/uinput**(uinput 需 root 或 udev 规则) |
|
||||
| Linux 常驻 | systemd 服务 + 会话切换处理(Wayland 的 portal 授权是最大障碍) |
|
||||
| 渲染表面 | 四平台各一套;**必须支持"表面尺寸≠视频尺寸"的缩放与拉伸模式** |
|
||||
|
||||
**看谁**
|
||||
|
||||
| 项目 | 看什么 |
|
||||
|---|---|
|
||||
| Sunshine `src/platform/{windows,linux}` | 抓屏与注入的分平台组织方式、Wayland 处理 |
|
||||
| RustDesk `libs/scrap` | 同样的抓屏抽象(多平台统一 trait)——**这是它最有价值的部分** |
|
||||
| libei / xdg-desktop-portal 官方文档 | Wayland 的正规姿势(别用野路子) |
|
||||
|
||||
**产出物**:`crates/platform/`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 `agent-core` / `viewer-core` — 两条链路
|
||||
|
||||
**职责**:把现有 `agent`/`viewer` 的 main 逻辑抽成库,供一体程序按角色调用。
|
||||
|
||||
**设计点**
|
||||
- **`agent-core`**:capture → encode → 打包 → 发送;输入接收 → 注入。A 已在进行中(`agent-core/src/lib.rs` 398 行)。
|
||||
- **`viewer-core`**:接收 → 解码 → 渲染表面;输入采集 → 发送。
|
||||
- **生命周期**:两者都不允许自己决定退出,由 `session` 层驱动。
|
||||
- **单槽 latest-wins**:现有做法保留(网络慢时只发最新帧),这是正确设计。
|
||||
|
||||
**看谁**:RustDesk(抽库方式)、Sunshine/Moonlight(管线组织)。
|
||||
|
||||
**产出物**:`crates/agent-core/`、`crates/viewer-core/`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 `apps/*` — UI 与常驻(第二个抄不到的地方)
|
||||
|
||||
**职责**:UI 壳、常驻服务、安装包。
|
||||
|
||||
**设计点**
|
||||
- **硬约束**:所有业务逻辑在 Rust,UI 只调一层稳定 API(决定了将来能否换 UI 框架)。
|
||||
- **视频走原生子表面**,绝不在 WebView 里逐帧渲染。
|
||||
- **一体 = 一个安装包 + 两个进程**:前台 UI + 后台被控服务,本地 IPC。
|
||||
- **移动端只有前台进程**(纯主控)。
|
||||
|
||||
**看谁**
|
||||
|
||||
| 项目 | 抄什么 |
|
||||
|---|---|
|
||||
| Tauri v2 官方 mobile 示例 | WKWebView/Android WebView 与原生 surface 的叠加方式 |
|
||||
| RustDesk `flutter/` | 同一套 UI 适配多端的组织方式(它用 Flutter,可对照取舍) |
|
||||
| Moonlight `moonlight-android` / `moonlight-ios` | **移动端主控的完整交互**:虚拟鼠标、手势映射、软键盘、性能档位 |
|
||||
|
||||
**产出物**:`apps/desktop/`、`apps/android/`、`apps/ios/`、`apps/daemon-win/`、`apps/daemon-linux/`
|
||||
|
||||
---
|
||||
|
||||
## 4. 借鉴映射总表
|
||||
|
||||
| 模块 | 主参考 | 许可 | 用法 |
|
||||
|---|---|---|---|
|
||||
| 打洞 / 中继 / 地址发现 | **iroh** | Apache-2.0 | **直接依赖** |
|
||||
| ICE / STUN / TURN | webrtc-rs、pion、coturn | MIT/BSD | 依赖或参考 |
|
||||
| 中继回退策略 | Tailscale / headscale | BSD-3 | 读思路 |
|
||||
| 拥塞控制 / 抖动缓冲 | **libwebrtc** | BSD-3 | 读思路,重写 |
|
||||
| 抓屏→编码管线 | **Sunshine** | GPL-3.0 | 只读 |
|
||||
| 接收端解码→渲染 | **Moonlight** | GPL-3.0 | 只读 |
|
||||
| 整机架构 / 抽库方式 | **RustDesk** | **AGPL-3.0** | **只读,绝不抄代码** |
|
||||
| 移动端主控交互 | Moonlight mobile | GPL-3.0 | 只读 |
|
||||
| Rust↔移动端绑定 | UniFFI | MPL-2.0 | 依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 许可结论与依赖策略
|
||||
|
||||
| 许可 | 能否进产品 | 项目 |
|
||||
|---|---|---|
|
||||
| MIT / Apache-2.0 / BSD | ✅ 可依赖、可移植 | iroh、webrtc-rs、pion、coturn、libwebrtc、UniFFI(MPL 弱) |
|
||||
| LGPL | ⚠️ 可动态链接,改库要开源 | FFmpeg、GStreamer、libnice |
|
||||
| **GPL / AGPL** | ❌ **不可进代码** | **RustDesk(AGPL)、Sunshine/Moonlight(GPL)** |
|
||||
|
||||
**AGPL 特别提示**:网络服务也算分发。引入 RustDesk 代码 = 你们整个产品连服务端都要开源。
|
||||
|
||||
**依赖准入流程**(补充进《Git协作规范.md》的合并规则):新增依赖必须在 PR 描述里写明**许可证 + 是否修改其源码**,由另一人确认。
|
||||
|
||||
---
|
||||
|
||||
## 6. 产品范围基线(待逆向资料回填)
|
||||
|
||||
> 拿到 §2.2 的 A 组资料后填这里,用于定"做哪些功能、砍哪些平台"。
|
||||
|
||||
| 功能 | UU 有 | 我们一期 | 备注 |
|
||||
|---|---|---|---|
|
||||
| 桌面画面 | | ✅ | 已有 |
|
||||
| 键鼠控制 | | ✅ | 已有 |
|
||||
| 音频 | | ? | |
|
||||
| 文件传输 | | ? | |
|
||||
| 剪贴板同步 | | ? | |
|
||||
| 多屏 | | ? | |
|
||||
| 移动端主控 | | ? | 人力问题见《多平台架构设计.md》§8 |
|
||||
| 无人值守 | | ? | 安全含义,需固定密码+白名单 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 待拍板
|
||||
|
||||
1. `conn` 是否**直接依赖 iroh**(而不是自研打洞)——建议先花 2 天做技术验证
|
||||
2. 逆向资料回来后,"产品范围基线"的取舍(尤其移动端)
|
||||
3. `media` 编码侧是否引入 FFmpeg(包体/构建成本 vs 省下的适配工作)
|
||||
4. 依赖准入流程是否写进《Git协作规范.md》
|
||||
270
多平台架构设计.md
270
多平台架构设计.md
@ -1,270 +0,0 @@
|
||||
# 多平台架构设计
|
||||
|
||||
> 需求:**主控端** Windows / Linux / Android / iOS;**被控端** Windows / Linux。
|
||||
> 桌面端(Win/Linux)主控+被控合为一体;移动端仅主控。
|
||||
> 前置文档:《打洞与信令设计.md》《B侧设计文档.md》《构建运行手册.md》
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话架构
|
||||
|
||||
**一个 Rust 核心 + 一层薄平台适配 + 可替换的 UI 壳。**
|
||||
业务逻辑(连接/打洞/会话/协议)100% 在 Rust 里跨平台共享;只有**抓屏、编解码、注入、渲染表面**四件事分平台写;UI 层只管"壳",**永远不碰视频流**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 纵向分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ L4 UI 壳 Win/Linux/Android/iOS 各自实现 │
|
||||
│ 设备列表 / 设置 / 连接管理 / 悬浮控件 │
|
||||
│ ★ 绝不放视频渲染 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L3 会话层 session 纯 Rust,全平台共享 │
|
||||
│ 会话状态机、角色仲裁、能力协商、keepalive 调度 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L2 连接层 conn 纯 Rust,全平台共享 │
|
||||
│ 信令客户端、打洞编排、中继接口(预留)、QUIC(quinn)多流 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L1 媒体层 media Rust + 平台后端 │
|
||||
│ 编码/解码/色彩转换/缩放/音频(Opus) trait 化 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L0 平台层 platform 唯一必须分平台的地方 │
|
||||
│ 抓屏 / 注入 / 音频IO / 渲染表面 / 权限 / 常驻 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**分界线原则**:L1 以上任何代码不允许出现 `#[cfg(windows)]`。平台差异只能通过 L0 的 trait 暴露。
|
||||
|
||||
---
|
||||
|
||||
## 2. 仓库结构
|
||||
|
||||
```
|
||||
remote/
|
||||
├── crates/
|
||||
│ ├── proto/ # 协议:信令 + 控制 + 媒体容器(现 common/)
|
||||
│ ├── conn/ # 信令客户端 / 打洞编排 / QUIC 会话 / 中继接口
|
||||
│ ├── session/ # 会话状态机 + 角色仲裁("一体"的核心)
|
||||
│ ├── agent-core/ # 被控链路:capture → encode → send [A 已在做]
|
||||
│ ├── viewer-core/ # 主控链路:recv → decode → surface
|
||||
│ ├── media/ # 编解码 trait + 各平台后端 + openh264 软兜底
|
||||
│ └── platform/ # L0 实现,按 cfg 编译各平台
|
||||
├── apps/
|
||||
│ ├── desktop/ # Tauri v2 壳:Win/Linux 一体程序
|
||||
│ ├── android/ # 主控(Tauri mobile 或原生 Kotlin)
|
||||
│ ├── ios/ # 主控(Tauri mobile 或原生 Swift)
|
||||
│ ├── daemon-win/ # Windows 服务:被控常驻
|
||||
│ └── daemon-linux/ # systemd:被控常驻
|
||||
├── bindings/ # UniFFI 生成的 Kotlin/Swift 绑定
|
||||
└── tools/ # bench / 信令服务器
|
||||
```
|
||||
|
||||
`agent-core` 与 `viewer-core` 的拆分是**一体化的前提**:现在的 `agent`/`viewer` 是两个 main,必须先变成库,才能被同一个进程按角色调用。A 正在做的 `agent-core` 抽取就是在铺这条路。
|
||||
|
||||
---
|
||||
|
||||
## 3. 平台能力矩阵
|
||||
|
||||
| 能力 | Windows | Linux | Android | iOS |
|
||||
|---|---|---|---|---|
|
||||
| 抓屏 | DXGI Desktop Duplication | X11 (XShm) / Wayland (PipeWire portal) | — | — |
|
||||
| 硬编 | NVENC / QSV / AMF | VAAPI / NVENC | — | — |
|
||||
| 硬解 | D3D11VA / NVDEC | VAAPI | MediaCodec | VideoToolbox |
|
||||
| 渲染表面 | D3D11 子窗口 | X11/GL 子窗口 | SurfaceView | UIView + CAMetalLayer |
|
||||
| 键鼠注入 | SendInput | XTest / uinput / libei | — | — |
|
||||
| 键鼠采集 | Raw Input | evdev | 触摸→鼠标映射 | 触摸→鼠标映射 |
|
||||
| 音频出 | WASAPI loopback | PulseAudio monitor | — | — |
|
||||
| 音频入/放 | WASAPI | PulseAudio | AudioTrack | AVAudioEngine |
|
||||
| 常驻服务 | Windows Service | systemd | — | — |
|
||||
| **角色** | **主 + 被** | **主 + 被** | **仅主** | **仅主** |
|
||||
|
||||
移动端没有"被控",所以 Android/iOS **不需要**抓屏、编码、注入、服务化——少了一半工作量,这是需求里最省事的一条。
|
||||
|
||||
---
|
||||
|
||||
## 4. 六个关键决策
|
||||
|
||||
### 决策 1:视频渲染绝不走 UI 框架
|
||||
|
||||
从 WebView / Compose / Flutter 里逐帧渲染 60fps 视频必然掉帧、增延迟。
|
||||
**做法**:UI 框架只画"壳",视频走**原生子表面**叠加在 UI 之上——
|
||||
Win: 子 HWND + D3D11;Linux: 子窗口 + GL;Android: `SurfaceView`;iOS: `UIView` + `CAMetalLayer`。
|
||||
MediaCodec / VideoToolbox 都能**直接解码到该 Surface,零拷贝**。这是 Parsec/ToDesk 这类产品的通行做法。
|
||||
|
||||
### 决策 2:UI 框架 —— 推荐 Tauri v2,但必须可替换
|
||||
|
||||
| 方案 | 覆盖 | 评价 |
|
||||
|---|---|---|
|
||||
| **Tauri v2** | Win/Linux/Android/iOS | ✅ 推荐:Rust 原生、一套 UI 四端、IPC 天然 |
|
||||
| Flutter | 四端 | UI 强,但 Rust 桥(flutter_rust_bridge)+ 平台视图叠加更绕 |
|
||||
| 各平台原生 ×4 | 四端 | 性能最好,**2-3 人团队不可能维护** |
|
||||
|
||||
选 Tauri v2 的理由:你们本来就写 Rust,视频走原生表面后 WebView 只做表单和列表,性能足够。
|
||||
**代价**:Tauri 移动端还不够成熟(WKWebView 与原生 surface 叠加、生命周期)。所以——
|
||||
|
||||
> **硬性约束:所有业务逻辑必须在 Rust 里,UI 只通过一层稳定 API 调用。**
|
||||
> 这样移动端若体验不达标,可以单点退化成原生 Kotlin/Swift + UniFFI,桌面仍用 Tauri,核心零改动。
|
||||
|
||||
### 决策 3:Rust ↔ 移动端绑定用 UniFFI
|
||||
|
||||
6 个平台角色组合下手写 JNI/FFI 会失控。UniFFI 从 Rust 生成 Kotlin/Swift 绑定,含 async、错误类型、对象生命周期,是当前标准解法。
|
||||
|
||||
### 决策 4:编码用 FFmpeg,解码用平台原生
|
||||
|
||||
不对称,但都有理由:
|
||||
|
||||
- **编码(只在 Win/Linux 被控端)**:用 FFmpeg 统一接管 NVENC/QSV/AMF/VAAPI,openh264 软编兜底。桌面端不心疼包体,省下大量适配。
|
||||
- **解码(四个主控平台都要)**:直接用平台原生 API。
|
||||
- 移动端**必须**硬解(软解烧电池、发热、跑不动 1080p60)
|
||||
- 平台原生能零拷贝直出渲染表面,FFmpeg 反而多一次拷贝
|
||||
- 绕开 FFmpeg 在 Android/iOS 上的交叉编译地狱和许可问题
|
||||
|
||||
`media` crate 用 trait 收口:
|
||||
|
||||
```rust
|
||||
pub trait VideoDecoder {
|
||||
fn configure(&mut self, cfg: &VideoConfig, target: SurfaceHandle) -> Result<()>;
|
||||
fn decode(&mut self, packet: &[u8]) -> Result<Option<FrameHandle>>;
|
||||
}
|
||||
|
||||
pub trait VideoEncoder {
|
||||
fn configure(&mut self, cfg: &EncodeConfig) -> Result<()>;
|
||||
fn encode(&mut self, frame: &FrameHandle, out: &mut Vec<u8>) -> Result<()>;
|
||||
fn request_keyframe(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
### 决策 5:"一体程序" = 一个安装包 + 两个进程
|
||||
|
||||
用户视角是一个 app,实现上职责分离:
|
||||
|
||||
```
|
||||
┌────────────────────────┐ ┌──────────────────────────┐
|
||||
│ 前台进程(UI) │ IPC │ 后台常驻(被控服务) │
|
||||
│ 主控画面 / 设备列表 │<──────>│ 抓屏·编码·注入·保活 │
|
||||
│ 退出即结束 │ 本地管道│ 开机自启 / 锁屏可连 │
|
||||
└────────────────────────┘ └──────────────────────────┘
|
||||
▲ ▲
|
||||
│ 移动端只有这一个 │ 仅 Win/Linux
|
||||
```
|
||||
|
||||
理由:**两种角色的生命周期天然冲突**——被控要"开机就在、没人操作也在",主控要"跟窗口走、关了就走"。硬塞进一个进程会导致主控一退出就掉线。
|
||||
IPC 用命名管道(Win)/ Unix socket(Linux),或复用 QUIC 走本地回环。
|
||||
|
||||
### 决策 6:中继接口必须预留 —— 哪怕现在不实现
|
||||
|
||||
`conn` 层定义:
|
||||
|
||||
```rust
|
||||
pub trait PathProvider {
|
||||
/// 返回一条可用数据通道:直连优先,失败时可回退
|
||||
async fn establish(&self, peer: &PeerInfo) -> Result<Path>;
|
||||
}
|
||||
|
||||
pub enum Path { Direct { addr: SocketAddr }, Relay { endpoint: EndpointId } }
|
||||
```
|
||||
|
||||
现在只实现 `Direct`。**但类型上留出 `Relay`**,将来补中继不必动会话层。
|
||||
|
||||
> ⚠️ **这条与"不做中继"的决定直接相关,见 §6。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 会话状态机(L3 核心)
|
||||
|
||||
一体程序两条线:
|
||||
|
||||
```
|
||||
主控线: Idle → Signalling → Punching → Handshaking → Streaming → Closing
|
||||
被控线: Idle → Registered → Incoming → Punching → Handshaking → Serving → Closing
|
||||
↑ 同一进程两线可并发(被控中也能主控别人)
|
||||
```
|
||||
|
||||
要点:
|
||||
- 打洞阶段**双方对称**:谁都不预设客户端/服务端,各自向对端候选发包,谁先打通谁定角色(被控方做 QUIC server)
|
||||
- 会话能力协商:分辨率、编码器、码率上限、是否带音频——由**被控端**根据硬件能力给候选,主控端选
|
||||
- 单进程内用 tokio 一runtime 跑多会话;被控同时只服务一个主控(可配置)
|
||||
|
||||
---
|
||||
|
||||
## 6. ⚠️ 必须现在拍板:手机主控 vs 不做中继
|
||||
|
||||
**手机在蜂窝网络下几乎 100% 处于运营商级 NAT(CGNAT),打洞打不通。**
|
||||
这不是实现问题,是网络事实——`100.64.0.0/10` 那段地址根本没给你可映射的公网端口。
|
||||
|
||||
后果:**"主控端支持安卓/iOS" + "不做中继" = 手机只能在 WiFi 下用。**
|
||||
地铁上、外面、用流量,一律连不上。
|
||||
|
||||
三条出路,必须选一条:
|
||||
|
||||
| 方案 | 代价 | 说明 |
|
||||
|---|---|---|
|
||||
| **A. 只靠 IPv6** | 部分用户不可用 | 国内三大运营商 v6 覆盖率高,手机 v6 可用性远好于 v4 公网。**推荐先做,能覆盖大多数** |
|
||||
| **B. 补一个只为移动端服务的中继** | 视频带宽成本 | 可用率 100%,但账单跟着视频流量走 |
|
||||
| **C. 接受"手机只能 WiFi"** | 产品力受限 | 零成本,但要写进产品说明,别让用户以为坏了 |
|
||||
|
||||
**我的建议:A 先做(架构里 v6 是一等公民),B 的接口留在 `PathProvider` 里不给实现,等真要推移动端再决定。**
|
||||
不要现在就把路堵死——留接口的成本几乎为零,推倒重来成本极高。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分期路线(不要一次做 6 个组合)
|
||||
|
||||
| 里程碑 | 内容 | 预估 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| **M1** | Win↔Win 打通:信令 + 打洞 + 证书钉扎 | 2-3 周 | 协议已就绪 |
|
||||
| **M2** | Win 一体化:agent-core 抽库、会话状态机、前台/后台双进程 | 2-3 周 | M1 |
|
||||
| **M3** | Win 被控服务化(开机即在线、锁屏可连、UAC 处理) | 2 周 | M2 |
|
||||
| **M4** | Linux 被控(X11 先行,Wayland/PipeWire 预留) | 2-3 周 | M2,A 在做 |
|
||||
| **M5** | 桌面 UI(Tauri v2 壳 + 原生视频表面) | 3 周 | M2 |
|
||||
| **M6** | **Android 主控**(MediaCodec + UniFFI + 触摸映射) | 3-4 周 | M5 |
|
||||
| **M7** | **iOS 主控**(VideoToolbox + SwiftUI/UniFFI) | 3-4 周 | M6 |
|
||||
| **M8** | Linux 主控(复用 M5 的桌面 UI) | 1 周 | M5 |
|
||||
| 穿插 | 音频(Opus) / 文件传输 / 多屏 | 各 1-2 周 | — |
|
||||
|
||||
**先做 Android 再做 iOS**:不用苹果开发者账号、真机调试方便、热重载快、审核风险低。iOS 放最后。
|
||||
|
||||
---
|
||||
|
||||
## 8. ⚠️ 必须说清楚的人力问题
|
||||
|
||||
现有团队 **2-3 人**,目前连"Win↔Win 打通"都还没做完。而这份需求是:
|
||||
|
||||
- 2 个被控平台(抓屏 + 编码 + 注入 + 服务化)× 各自两套显示服务器(Linux X11/Wayland)
|
||||
- 4 个主控平台(解码 + 渲染 + 输入 + UI)× 各自一套原生集成
|
||||
- 外加信令、打洞、音频、文件、多屏、账号体系
|
||||
|
||||
**这是 6-10 人年的工作量,2-3 人做不完。** 不是唱衰,是排期事实——Android + iOS 两个平台的 UI 和平台集成,单独拿出来就够一个人全职做半年。
|
||||
|
||||
可行的收缩方式(选一个):
|
||||
|
||||
1. **砍掉 iOS**(苹果审核 + 真机调试 + 开发者账号成本最高,收益最小)
|
||||
2. **移动端只做 Android**,iOS 用 Web 端替代(但 Web 端无法硬解,体验差)
|
||||
3. **桌面优先,做深做透**,移动端放到"产品验证之后",先招人
|
||||
4. 移动端主控**外包**,核心协议和桌面端自己掌握
|
||||
|
||||
我的建议是 **3 或 1**:先把 Win/Linux 两端做成一款真的好用的产品(含打洞、服务化、音频、文件),移动端等有用户了再投入。现在同时铺 6 个平台,最大概率是每个都半成品。
|
||||
|
||||
---
|
||||
|
||||
## 9. 给 A / B 的分工调整建议
|
||||
|
||||
| 人 | 主责 | 说明 |
|
||||
|---|---|---|
|
||||
| A | `platform/` 被控侧:Win 抓屏/编码/注入/服务化 + Linux 被控 | 与现状一致 |
|
||||
| B | `proto/` + `conn/` + `session/` + `viewer-core/` + 桌面 UI | 与现状一致 |
|
||||
| — | **移动端(Android/iOS)** | **无人** ← 这是缺口 |
|
||||
|
||||
现有分工在"桌面 + 协议"上是自洽的,**移动端是纯增量,必须补人或砍需求**。
|
||||
|
||||
---
|
||||
|
||||
## 10. 开放问题(需要拍板)
|
||||
|
||||
1. 中继:确认走 §6 的 A 方案(v6 优先、留接口不实现)?
|
||||
2. 移动端:先 Android 还是直接砍 iOS?
|
||||
3. UI 框架:确认 Tauri v2,还是先原生桌面 + 后面再说移动?
|
||||
4. 账号体系:现在是"输验证码",要支持设备列表就得有账号/设备 ID 体系——什么时候做?
|
||||
5. 被控服务化后,主控端要不要支持"无人值守开机连接"(有安全含义,需要固定密码 + 白名单)?
|
||||
122
打洞与信令设计.md
122
打洞与信令设计.md
@ -1,122 +0,0 @@
|
||||
# 打洞与信令设计(无中继方案)
|
||||
|
||||
> 状态:协议契约已落在 `common/src/signaling.rs`,待 A 过审后合入 main。
|
||||
> 决策:**不做中继(无 TURN / coturn)**,服务器只保留信令 + STUN 职能。
|
||||
> 关联:`构建运行手册.md` §7 第 4 项、`Git协作规范.md` 二·B 第 ③ 项。
|
||||
|
||||
## 1. 结论先行:信令不是中继
|
||||
|
||||
| | 信令服务器 | 中继服务器(已砍掉) |
|
||||
|---|---|---|
|
||||
| 承载媒体流量 | ❌ 不承载 | ✅ 全部承载 |
|
||||
| 生命周期 | 只在建连那几秒 | 整个会话期间 |
|
||||
| 成本 | 一台最低配 VPS 足够 | 视频流带宽,主要成本项 |
|
||||
| 必要性 | **必须**(无它则无法交换地址) | 可选(可用率兜底) |
|
||||
|
||||
砍掉中继 ≠ 不要服务器。信令必须保留,因为它解决的是死循环:双方都在 NAT 后,
|
||||
各自只知道自己内网地址,**"自己的公网映射"这个信息只有第三方发包过来时才能看见**。
|
||||
|
||||
## 2. 无中继的代价:可用率,不是功能
|
||||
|
||||
打洞成功率由 NAT 类型决定,与代码质量基本无关:
|
||||
|
||||
| NAT 类型 | 典型场景 | 无中继 |
|
||||
|---|---|---|
|
||||
| Full cone / Restricted cone | 家用路由器默认 | ✅ 很高 |
|
||||
| Port-restricted cone | 家用宽带常见 | ✅ 需同时发包 |
|
||||
| Symmetric NAT | 部分企业网关 | ⚠️ 仅端口可预测时可行 |
|
||||
| CGNAT(`100.64.0.0/10`) | 移动/社区宽带 | ❌ 无解(IPv6 除外) |
|
||||
|
||||
预期 P2P 成功率 **80–90%**,其余连不上。
|
||||
|
||||
**产品边界(需写进对外说明)**:目标用户框定为「家用宽带 + IPv6 可用 + 可开 UPnP」;
|
||||
企业网络 / CGNAT 标注为不支持。这样剩余失败就不是 bug,是已知边界。
|
||||
|
||||
## 3. 打洞流程(协议见 `common/src/signaling.rs`)
|
||||
|
||||
```
|
||||
agent 信令服务器 viewer
|
||||
|-- Register(info) ------->|<------- Register(info) -----|
|
||||
|<-- Registered(observed) -|------ Registered(observed) ->| observed = 公网映射(等价一次 STUN binding)
|
||||
|<-- PeerReady(peer info) -|------ PeerReady(peer info) -->| + 打洞前景 outlook
|
||||
|<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->| 两侧 start_in 不同,绝对时刻对齐
|
||||
| (双方同时向对端候选列表发包,多轮) |
|
||||
|<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>|
|
||||
|--- PunchReport(Connected) ->|<---- PunchReport ----------|
|
||||
```
|
||||
|
||||
### 3.1 候选地址与优先级
|
||||
|
||||
`Candidate::score()`:IPv6(+100)> UPnP 显式映射(90) > 本机地址(80) > 公网反射(60) > 端口预测(40)。
|
||||
|
||||
**IPv6 是无中继策略的最强后盾** —— 双方都有公网 v6 就是真直连,成功率≈100%,
|
||||
因此候选列表按 v6 → v4 排序,`EndpointInfo` 两类都带。
|
||||
|
||||
### 3.2 同时发包的时间对齐(不需要时钟同步)
|
||||
|
||||
两侧 RTT 不同,若下发同一个延迟,RTT 大的一方晚发包就打不上。服务器按
|
||||
`punch_start_delay_ms(own_rtt, max_rtt, margin)` 给两侧算不同的 `start_in_ms`:
|
||||
|
||||
```
|
||||
start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2
|
||||
```
|
||||
|
||||
`start_in_ms` 是**相对消息送达**的延迟,所以两端时钟不需要同步,误差≈单程抖动。
|
||||
|
||||
### 3.3 保活
|
||||
|
||||
NAT 的 UDP 映射默认 30s 左右超时,洞是出站包维持的,超时后静默关闭
|
||||
(表现为"连上了一会儿就断")。`keepalive_secs()` 取双方 `binding_timeout_s`
|
||||
较小值折半,夹在 5–60s。
|
||||
|
||||
## 4. 提高成功率的实现清单(按性价比)
|
||||
|
||||
1. **IPv6 优先**:候选排序已实现;agent 侧需枚举全局 v6 地址填 `CandidateKind::Host`。
|
||||
2. **UPnP / NAT-PMP / PCP**:agent 启动时就尝试为 `PUNCH_UDP_PORT(47800)` 申请映射,
|
||||
成功后以 `CandidateKind::Upnp` 补报(走 `ClientMsg::UpdateCandidates`)。
|
||||
一次成功就把对称 NAT 变成可直连,收益最大。
|
||||
3. **双方同时发包**:见 §3.2,这是打洞能成的关键。
|
||||
4. **端口预测**:`PortAllocation::Sequential/Preserved` 时才值得做,`Predicted` 候选由
|
||||
`sanitize_candidates` 统一清洗(去重、丢端口 0、上限 32 个)。
|
||||
5. **多轮重试**:`rounds=3 / 200ms`,一次不通不代表不通。
|
||||
|
||||
**不要走 TCP 打洞**:QUIC 只跑 UDP,TCP simultaneous open 成功率低,性价比不值得。
|
||||
|
||||
## 5. 实现约束(两端各自注意)
|
||||
|
||||
- **别拿 QUIC 握手当探测**:`quinn::Endpoint::connect` 打不通会持续重试,失败判定被
|
||||
idle timeout 拖很久。应先用同一 UDP socket 发轻量探测包确认通路(秒级),
|
||||
确认后再 `connect`;端口扫描也放这一层,便宜且可并行。
|
||||
- **UDP socket 复用**:打洞用的 socket 要和后续 QUIC 用同一个,否则洞不在同一个映射上。
|
||||
`quinn::Endpoint` 支持同时 `accept()` 与 `connect()`,可以在一个 Endpoint 上做完整流程。
|
||||
- **固定端口**:`47800` 对端口预测和用户手动端口转发都友好,不要随机端口。
|
||||
|
||||
## 6. 安全(本次必须同步改,否则公网不可用)
|
||||
|
||||
1. **证书钉扎**:`viewer/src/main.rs` 的 `SkipVerify` 必须替换为对
|
||||
`EndpointInfo.cert_fingerprint` 的校验(`CertFingerprint` 已定义 hex 编解码)。
|
||||
2. **认证**:`ClientMsg::Register.auth` 必须是一次性验证码(替代固定 `123456`/`888888`),
|
||||
带过期与失败限速,否则公网等于没有认证。
|
||||
3. **候选列表视为不可信输入**:一律经 `sanitize_candidates` 后再发包。
|
||||
|
||||
## 7. 失败必须有明确出口(无中继的用户体验下限)
|
||||
|
||||
没有中继兜底,就必须把"为什么连不上"讲清楚,不允许用户停在"连接中"。
|
||||
`PunchFailReason::user_hint()` 已给出每种原因对应的可执行建议,例如:
|
||||
|
||||
- `Cgnat` → "当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络……"
|
||||
- `SymmetricNat` → "请在被控端路由器开启 UPnP 后重试。"
|
||||
- `UdpBlocked` → "当前网络的防火墙拦截了 UDP 出站,请切换手机热点后重试。"
|
||||
|
||||
`punch_outlook()` 还能在**开打之前**就给出前景(`Likely/Possible/Unlikely/Hopeless`),
|
||||
`Hopeless`(CGNAT)时可直接跳过打洞轮次并立刻提示,不让用户白等。
|
||||
|
||||
## 8. 后续动作
|
||||
|
||||
| 顺序 | 事项 | 负责 |
|
||||
|---|---|---|
|
||||
| 1 | `common/src/signaling.rs` 过审合入 main | B 提 PR / A review |
|
||||
| 2 | 信令服务最小实现(WSS/443,配对 + 回推 observed + 下发 PunchPlan) | B |
|
||||
| 3 | agent:候选收集(v6/Host/UPnP/Reflexive)、同步打洞、保活 | A |
|
||||
| 4 | viewer:候选收集、同步打洞、改为证书钉扎 | B |
|
||||
| 5 | 双机联调:家庭宽带 / 手机热点 / CGNAT 三种网络各跑一轮 | A + B |
|
||||
135
打洞验证记录.md
135
打洞验证记录.md
@ -1,135 +0,0 @@
|
||||
# 打洞连通性验证记录
|
||||
|
||||
> 目的:用最小成本确认「自研 P2P 打洞」在本项目目标网络下是否可行,再决定要不要投入实现。
|
||||
> 工具:`tools/punch_probe.py`(探针)、`tools/rendezvous.py`(极简信令服务器)
|
||||
> 结论:**本机线路为锥形 NAT + 端口保留,打洞可行性高。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试环境
|
||||
|
||||
| 角色 | 地址 | 说明 |
|
||||
|---|---|---|
|
||||
| 信令服务器 | `47.98.245.66:47999/udp` | 阿里云 ECS,Ubuntu 24.04.2,python 3.12.3 |
|
||||
| 客户端 | 出口 `116.3.201.115` | 联通线路,位于 NAT 之后 |
|
||||
| 工具 | `tools/rendezvous.py` + `tools/punch_probe.py` | 纯 Python 标准库 |
|
||||
|
||||
部署位置:`/root/uu-punch-poc/`(与服务器上其他服务完全隔离)
|
||||
|
||||
---
|
||||
|
||||
## 2. 验证过程与结果
|
||||
|
||||
| # | 验证项 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 信令服务器回环注册 | ✅ 能注册、能记录 |
|
||||
| 2 | 服务器进程/监听 | ✅ `0.0.0.0:47999`,长期运行稳定 |
|
||||
| 3 | **跨公网注册** | ✅ 服务器日志出现 `116.3.201.115:47800` |
|
||||
| 4 | **双方配对与地址回推** | ✅ `配对成功:116.3.201.115:47800 <==> :47801` |
|
||||
| 5 | 同一 NAT 内两实例互打 | ❌ 失败(见 §4.1,属 hairpinning 限制) |
|
||||
| 6 | **NAT 映射行为判定** | ✅ **端点无关映射 + 端口保留**(见 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键结论:NAT 类型 = 锥形 + 端口保留
|
||||
|
||||
用**同一个本地端口 47800** 分别打两个完全不同的目标,观察公网映射:
|
||||
|
||||
| 目标 | 观察到的公网映射 |
|
||||
|---|---|
|
||||
| Google STUN(境外) | `116.3.201.115:47800` |
|
||||
| 自建信令服务器 `47.98.245.66:47999` | `116.3.201.115:47800` |
|
||||
|
||||
**不同目标 → 同一个公网端口 → 端点无关映射(Endpoint-Independent Mapping,即锥形 NAT),且端口原样保留。**
|
||||
|
||||
按打洞成功率排序:
|
||||
|
||||
```
|
||||
锥形 + 端口保留 ← 本机(最优)
|
||||
锥形 + 端口改写 成功率高
|
||||
对称 NAT 困难,需端口预测
|
||||
CGNAT 基本无解
|
||||
```
|
||||
|
||||
**推论**:信令服务器观察到的地址,对该客户端是**可复用的真实映射**,对端直接向它发包即可打洞,无需端口预测。这是最理想的情形。
|
||||
|
||||
复现命令:
|
||||
|
||||
```powershell
|
||||
# 一定要带 --stun-port,用与打洞相同的端口,否则映射不可比
|
||||
py tools\punch_probe.py --stun --stun-port 47800
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 踩到的坑(Rust 实现必须处理)
|
||||
|
||||
### 4.1 hairpinning:同一公网 IP 下两实例互打会失败
|
||||
|
||||
两个客户端都在 `116.3.201.115` 后面,互相打对方的公网地址,需要路由器支持 **NAT 环回(hairpinning)**。本线路不支持。
|
||||
|
||||
**这不代表真实打洞会失败**——真实场景是两台机器位于**不同**公网 IP 之后。此测试只能验证"信令配对"链路,不能验证穿透能力。
|
||||
|
||||
### 4.2 Windows:ICMP 端口不可达会打断接收循环
|
||||
|
||||
对方尚未启动时发包 → 收到 ICMP port unreachable → 下一次 `recv` 抛 `WSAECONNRESET(10054)`。若把它当致命错误 `break`,接收循环就此死掉,之后对方真打进来也收不到。
|
||||
|
||||
处理方式(两层):
|
||||
|
||||
```python
|
||||
sock.ioctl(socket.SIO_UDP_CONNRESET, False) # 1) 关闭该行为
|
||||
# 2) 收到该错误时 continue,绝不能 break
|
||||
```
|
||||
|
||||
**Rust 对应**:`tokio` 的 `recv_from` 会返回 `ConnectionReset`,同样不能 `?` 或 `break`。
|
||||
|
||||
### 4.3 发完就退出会漏掉晚到的回包
|
||||
|
||||
对方可能比自己晚启动。发完最后一包立即关闭 socket,会把"晚到的成功"误判为失败。
|
||||
→ 发完后保留 grace 期(默认 2.5s)继续监听。
|
||||
|
||||
### 4.4 部署层的两道防火墙
|
||||
|
||||
| 层 | 现象 | 处理 |
|
||||
|---|---|---|
|
||||
| 主机 ufw | `INPUT DROP`,无规则则静默丢弃 | `ufw allow 47999/udp` |
|
||||
| 云安全组 | 包到不了网卡(tcpdump 零捕获) | 控制台加 UDP 入方向规则 |
|
||||
|
||||
**排查手法**:`tcpdump -n -i any udp port <port>` 零捕获 = 包在到达主机前就被丢,问题在云安全组而不在主机防火墙。
|
||||
|
||||
> 注意:首次配置时误把安全组规则设成了 **TCP**,UDP 包仍被丢弃。协议类型必须选 UDP。
|
||||
|
||||
### 4.5 假阳性:信令回复被误判为"打通"
|
||||
|
||||
客户端在拿到对端地址前会反复注册,服务器的 `PEER` / `WAIT` 回复可能在 `discover_peer` 返回之后才到达。若接收线程不做区分,会把**服务器发来的回复**当成"对方打进来了",输出假的"✅ 打洞成功"。
|
||||
|
||||
实测踩到的形态:报告的"对方源地址"是**信令服务器的 IP:端口**(`47.98.245.66:47999`),包长 22 字节——而真正的打洞包固定是 **16 字节**(`PUNCHv1|` + 8 字节时间戳)。
|
||||
|
||||
**判据(Rust 实现必须照做)**:
|
||||
|
||||
1. 只认以协议魔数开头的包,长度必须是约定值
|
||||
2. 显式排除信令服务器的 `IP:端口`
|
||||
3. 结论里如果出现"非打洞包 N 个",说明收到了信令回复,**不能**据此判定成功
|
||||
|
||||
---
|
||||
|
||||
## 5. 尚未验证
|
||||
|
||||
| 项 | 为什么重要 | 怎么验 |
|
||||
|---|---|---|
|
||||
| **两台不同网络的机器互打** | 唯一能证明真实穿透能力的测试 | 一台机器 + 一台连手机热点的笔记本 |
|
||||
| 手机热点(CGNAT)场景 | 决定"无中继"方案对移动端是否成立 | 同上,热点那端即为 CGNAT |
|
||||
| IPv6 直连 | 若两端都有公网 v6,可绕开 NAT | `--peer [v6地址]:端口` |
|
||||
| 对端 NAT 是否同样友好 | 打洞需要两端配合 | 在对方网络重复 §3 的映射判定 |
|
||||
|
||||
**下一步优先级:手机热点组合 > IPv6 > 其他。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 对实现的影响
|
||||
|
||||
1. **信令服务器的职责就此确认**:注册、配对、回推观察到的源地址。不承载媒体,建连后即退出。
|
||||
2. **信令与打洞必须复用同一个 socket**:服务器看到的映射端口,必须就是打洞时使用的端口,否则映射不可复用。
|
||||
- Rust 实现中:该 socket 需继续交给 `quinn::Endpoint` 使用(一次绑定,三处复用:信令 / 打洞 / QUIC)。
|
||||
3. **失败判定要快**:先做 NAT 行为探测,若判定为对称 NAT 直接给出结论,不必耗完多轮。
|
||||
4. **保活**:NAT 表项会超时,建连后需按 `binding_timeout_s` 折半做 keepalive(协议中已定义 `keepalive_secs()`)。
|
||||
Loading…
Reference in New Issue
Block a user