From 9886e0306f7453d16b575b4fadfed5bbe757e561 Mon Sep 17 00:00:00 2001 From: LIm1tZz7 <36764786+TheBadRoger@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:42:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=94=A5=E5=BD=A2nat=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=EF=BC=8CQUIC=E6=89=93=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++++++++++-- src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 10 +++++----- tests/coordination.rs | 24 ++++++++++++++++-------- tests/registry.rs | 26 ++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 03ca34b..8e45a2f 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,11 @@ chatmask-client --bind 0.0.0.0 --server /ip4/服务器公网IP/udp/7001/quic-v1/ ## 协议和资源 -协议为 `/chatmask/punch/2.0.0`,CBOR request-response,类型见 `src/lib.rs`: +协议为 `/chatmask/punch/3.0.0`,CBOR request-response,类型见 `src/lib.rs`: 1. 客户端建立经过认证的 QUIC 控制连接并发出 Register。 2. 服务器回复 Registered(观察到的 UDP 来源 endpoint、有效期)。身份来自连接认证,地址来自实际连接,不接受客户端声称的任意 endpoint。 -3. A 请求 Connect(B的PeerId)。双方已登记时向双方发出 Punch(对端身份、endpoint、attempt、最长 5000ms),并向 A 回复 Started。Started 仅表示信令已安排,不表示打洞成功。 +3. A 请求 Connect(B的PeerId)。双方已登记时向双方发出 Punch(对端身份、endpoint、attempt、最长 12000ms),并向 A 回复 Started。Started 仅表示信令已安排,不表示打洞成功。 4. 客户端响应 Ack,并从原端口进行有限次数的同时拨号。实际 QUIC 连接必须验证预期 PeerId,聊天继续原有消息加密握手。 5. 客户端按服务器有效期刷新登记,默认每20秒;60秒过期或断线时移除登记。登记仅在内存,重启后需重新登记。 @@ -96,3 +96,13 @@ docker compose up --build `punch_coordination_dispatched` 表示双方通知已提交,`punch_notice_response ... Ack` 表示客户端确认收到通知。服务器无法观察双方后续直连,不能据此宣布打洞或聊天成功,最终结果以客户端为准。 新版客户端使用 `/join 公网IP或域名` 一次添加服务器,之后每次启动自动连接和登记;`/peerid` 只显示本机 PeerId,双方用 `/chat 对方PeerId` 聊天。 + +## 锥形 NAT 与同网直连 + +协议升级为 `/chatmask/punch/3.0.0`,需要同时更新服务端与两个客户端,再执行 `docker compose up -d --build`。服务器密钥和已有配置可以保留,旧 2.0 客户端不兼容。 + +Register 携带最多 8 个客户端实际 QUIC 监听地址中的内网候选。服务器只保留私有单播地址及非零端口,公网端点仍从认证连接观察;公网来源不能注入回环地址,候选按身份登记、TTL 过期、断线清理。Connect 时仅将候选发送给本轮连接双方,不提供列表查询或中继。 + +新版客户端优先排列内网候选,同时探测公网端点;按双方 PeerId 确定唯一握手方向。一方用原 QUIC 监听套接字发送 UDP 探测并等待入站,另一方复用监听端口发起握手。单轮窗口 12 秒,QUIC 握手超时 10 秒。双方可通过内网可达路径绕过不支持 hairpin 的 NAT,但校园网设备隔离仍可能阻断。不同目标映射不同的对称 NAT、多层 NAT 不保证成功。 + +候选地址来自客户端声明,不能作为真实位置的证明;客户端仍验证目标 PeerId,限制候选数量、地址类型和尝试时间。双方会获知对方的私有地址及公网地址,日志也会记录候选。此实现不是完整 ICE/STUN、TURN 或 libp2p DCUtR。 diff --git a/src/lib.rs b/src/lib.rs index 5ba43d1..a0979e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ use std::{ time::{Duration, Instant}, }; pub type Result = std::result::Result>; -pub const PROTOCOL: &str = "/chatmask/punch/2.0.0"; +pub const PROTOCOL: &str = "/chatmask/punch/3.0.0"; #[derive(Parser, Debug)] #[command(version, about = "ChatMask QUIC hole-punch coordination server")] pub struct Args { @@ -29,13 +29,16 @@ pub struct Args { } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Request { - Register, + Register { + candidates: Vec, + }, Connect { peer: String, }, Punch { peer: String, endpoint: SocketAddr, + candidates: Vec, attempt: String, timeout_ms: u32, }, @@ -56,6 +59,7 @@ pub enum Response { } struct Registration { endpoint: SocketAddr, + candidates: Vec, seen: Instant, last_connect: Option, } @@ -81,7 +85,40 @@ impl Registry { self.clients.retain(|_, r| r.seen.elapsed() < ttl); before - self.clients.len() } + pub fn candidates(&self, peer: PeerId) -> Vec { + self.clients + .get(&peer) + .map(|r| r.candidates.clone()) + .unwrap_or_default() + } pub fn register(&mut self, peer: PeerId, endpoint: SocketAddr) -> Response { + self.register_candidates(peer, endpoint, &[]) + } + pub fn register_candidates( + &mut self, + peer: PeerId, + endpoint: SocketAddr, + candidates: &[SocketAddr], + ) -> Response { + if candidates.len() > 8 { + return Response::Error { + message: "Too many local candidates".into(), + }; + } + let mut filtered = Vec::new(); + for candidate in candidates { + let local = match candidate.ip() { + std::net::IpAddr::V4(ip) => ip.is_private(), + std::net::IpAddr::V6(ip) => ip.is_unique_local(), + }; + if candidate.port() != 0 + && candidate.is_ipv4() == endpoint.is_ipv4() + && (local || (endpoint.ip().is_loopback() && candidate.ip().is_loopback())) + && !filtered.contains(candidate) + { + filtered.push(*candidate); + } + } self.expire(); if !self.clients.contains_key(&peer) && self.clients.len() >= self.max { return Response::Error { @@ -93,6 +130,7 @@ impl Registry { peer, Registration { endpoint, + candidates: filtered, seen: Instant::now(), last_connect, }, diff --git a/src/main.rs b/src/main.rs index e0251c2..53f52b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,8 +39,8 @@ async fn main() -> Result<()> { }, SwarmEvent::Behaviour(BehaviourEvent::Control(request_response::Event::Message { peer, message: request_response::Message::Request { request, channel, .. }, .. })) => { let response = match request { - Request::Register => { - let response = match endpoints.get(&peer) { Some(endpoint) => registry.register(peer, *endpoint), None => Response::Error { message: "QUIC connection required".into() } }; + Request::Register { candidates } => { + let response = match endpoints.get(&peer) { Some(endpoint) => registry.register_candidates(peer, *endpoint, &candidates), None => Response::Error { message: "QUIC connection required".into() } }; log(format_args!("registration peer={peer} response={response:?}")); response }, Request::Connect { peer: target } => { @@ -48,10 +48,10 @@ async fn main() -> Result<()> { if notices.len() >= 2048 { Response::Error { message: "Coordination busy".into() } } else { match target.parse().map_err(|_| "Invalid target".into()).and_then(|target| registry.connect(peer, target).map(|pair| (target, pair))) { Ok((target, (a, b, attempt))) => { - let to_target = swarm.behaviour_mut().control.send_request(&target, Request::Punch { peer: peer.to_string(), endpoint: a, attempt: attempt.clone(), timeout_ms: 5000 }); - let to_source = swarm.behaviour_mut().control.send_request(&peer, Request::Punch { peer: target.to_string(), endpoint: b, attempt: attempt.clone(), timeout_ms: 5000 }); + let to_target = swarm.behaviour_mut().control.send_request(&target, Request::Punch { peer: peer.to_string(), endpoint: a, candidates: registry.candidates(peer), attempt: attempt.clone(), timeout_ms: 12000 }); + let to_source = swarm.behaviour_mut().control.send_request(&peer, Request::Punch { peer: target.to_string(), endpoint: b, candidates: registry.candidates(target), attempt: attempt.clone(), timeout_ms: 12000 }); notices.insert(to_target, (attempt.clone(), target)); notices.insert(to_source, (attempt.clone(), peer)); - log(format_args!("punch_coordination_dispatched source={peer} target={target} source_endpoint={a} target_endpoint={b} attempt={attempt}")); + log(format_args!("punch_coordination_dispatched source={peer} target={target} source_endpoint={a} target_endpoint={b} source_candidates={:?} target_candidates={:?} attempt={attempt}", registry.candidates(peer), registry.candidates(target))); Response::Started { attempt } }, Err(error) => { log(format_args!("node_query_failed source={peer} target={target:?} reason={error:?}")); Response::Error { message: error.to_string() } }, diff --git a/tests/coordination.rs b/tests/coordination.rs index b0f9dea..4004767 100644 --- a/tests/coordination.rs +++ b/tests/coordination.rs @@ -100,10 +100,18 @@ async fn real_server_registers_observed_udp_addresses_signals_both_peers_and_cli .unwrap(); a.dial(control.clone()).unwrap(); b.dial(control).unwrap(); - a.behaviour_mut() - .send_request(&server_peer, Request::Register); - b.behaviour_mut() - .send_request(&server_peer, Request::Register); + a.behaviour_mut().send_request( + &server_peer, + Request::Register { + candidates: vec!["10.20.30.40:9001".parse().unwrap()], + }, + ); + b.behaviour_mut().send_request( + &server_peer, + Request::Register { + candidates: vec!["10.20.30.40:9002".parse().unwrap()], + }, + ); let (mut endpoint_a, mut endpoint_b) = (None, None); tokio::time::timeout(Duration::from_secs(10), async { while endpoint_a.is_none() || endpoint_b.is_none() { @@ -128,16 +136,16 @@ async fn real_server_registers_observed_udp_addresses_signals_both_peers_and_cli event = a.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer, message, .. }) = event { assert_eq!(peer, server_peer); match message { - request_response::Message::Request { request: Request::Punch { peer, endpoint, attempt, .. }, channel, .. } => { - assert_eq!(peer, b_peer.to_string()); assert_eq!(endpoint, endpoint_b); notice_a = Some(attempt); + request_response::Message::Request { request: Request::Punch { peer, endpoint, candidates, attempt, timeout_ms }, channel, .. } => { + assert_eq!(peer, b_peer.to_string()); assert_eq!(endpoint, endpoint_b); assert_eq!(candidates, vec!["10.20.30.40:9002".parse().unwrap()]); assert_eq!(timeout_ms, 12000); notice_a = Some(attempt); a.behaviour_mut().send_response(channel, Response::Ack).unwrap(); }, request_response::Message::Response { response: Response::Started { attempt }, .. } => started = Some(attempt), _ => {}, } }, - event = b.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer: sender, message: request_response::Message::Request { request: Request::Punch { peer, endpoint, attempt, .. }, channel, .. }, .. }) = event { - assert_eq!(sender, server_peer); assert_eq!(peer, a_peer.to_string()); assert_eq!(endpoint, endpoint_a); notice_b = Some(attempt); + event = b.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer: sender, message: request_response::Message::Request { request: Request::Punch { peer, endpoint, candidates, attempt, timeout_ms }, channel, .. }, .. }) = event { + assert_eq!(sender, server_peer); assert_eq!(peer, a_peer.to_string()); assert_eq!(endpoint, endpoint_a); assert_eq!(candidates, vec!["10.20.30.40:9001".parse().unwrap()]); assert_eq!(timeout_ms, 12000); notice_b = Some(attempt); b.behaviour_mut().send_response(channel, Response::Ack).unwrap(); } } diff --git a/tests/registry.rs b/tests/registry.rs index 071d9d4..ad3187f 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -33,3 +33,29 @@ fn only_quic_addresses_produce_udp_candidates() { "127.0.0.1:9000".parse().unwrap() ); } + +#[test] +fn private_candidates_are_bounded_filtered_and_removed_with_registration() { + let mut registry = Registry::new(2, 60); + let peer = PeerId::random(); + let observed = "203.0.113.10:5000".parse().unwrap(); + let private: SocketAddr = "192.168.1.5:4000".parse().unwrap(); + let candidates = [ + private, + private, + "127.0.0.1:4000".parse().unwrap(), + "8.8.8.8:4000".parse().unwrap(), + "0.0.0.0:4000".parse().unwrap(), + "10.0.0.1:0".parse().unwrap(), + ]; + assert!( + matches!(registry.register_candidates(peer, observed, &candidates), Response::Registered { endpoint, .. } if endpoint == observed) + ); + assert_eq!(registry.candidates(peer), vec![private]); + assert!(matches!( + registry.register_candidates(peer, observed, &[private; 9]), + Response::Error { .. } + )); + registry.remove(peer); + assert!(registry.candidates(peer).is_empty()); +}