锥形nat协议,QUIC打洞

This commit is contained in:
LIm1tZz7 committed 2026-10-12 23:42:18 +08:00
1 parent 4ee0c0735a
commit 9886e0306f
5 files changed
+99 -17

No files matched your search

+12 -2
View File
@@ -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。 1. 客户端建立经过认证的 QUIC 控制连接并发出 Register。
2. 服务器回复 Registered(观察到的 UDP 来源 endpoint、有效期)。身份来自连接认证,地址来自实际连接,不接受客户端声称的任意 endpoint。 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,聊天继续原有消息加密握手。 4. 客户端响应 Ack,并从原端口进行有限次数的同时拨号。实际 QUIC 连接必须验证预期 PeerId,聊天继续原有消息加密握手。
5. 客户端按服务器有效期刷新登记,默认每20秒;60秒过期或断线时移除登记。登记仅在内存,重启后需重新登记。 5. 客户端按服务器有效期刷新登记,默认每20秒;60秒过期或断线时移除登记。登记仅在内存,重启后需重新登记。
@@ -96,3 +96,13 @@ docker compose up --build
`punch_coordination_dispatched` 表示双方通知已提交,`punch_notice_response ... Ack` 表示客户端确认收到通知。服务器无法观察双方后续直连,不能据此宣布打洞或聊天成功,最终结果以客户端为准。 `punch_coordination_dispatched` 表示双方通知已提交,`punch_notice_response ... Ack` 表示客户端确认收到通知。服务器无法观察双方后续直连,不能据此宣布打洞或聊天成功,最终结果以客户端为准。
新版客户端使用 `/join 公网IP或域名` 一次添加服务器,之后每次启动自动连接和登记;`/peerid` 只显示本机 PeerId,双方用 `/chat 对方PeerId` 聊天。 新版客户端使用 `/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。
+40 -2
View File
@@ -14,7 +14,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>; pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>;
pub const PROTOCOL: &str = "/chatmask/punch/2.0.0"; pub const PROTOCOL: &str = "/chatmask/punch/3.0.0";
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about = "ChatMask QUIC hole-punch coordination server")] #[command(version, about = "ChatMask QUIC hole-punch coordination server")]
pub struct Args { pub struct Args {
@@ -29,13 +29,16 @@ pub struct Args {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Request { pub enum Request {
Register, Register {
candidates: Vec<SocketAddr>,
},
Connect { Connect {
peer: String, peer: String,
}, },
Punch { Punch {
peer: String, peer: String,
endpoint: SocketAddr, endpoint: SocketAddr,
candidates: Vec<SocketAddr>,
attempt: String, attempt: String,
timeout_ms: u32, timeout_ms: u32,
}, },
@@ -56,6 +59,7 @@ pub enum Response {
} }
struct Registration { struct Registration {
endpoint: SocketAddr, endpoint: SocketAddr,
candidates: Vec<SocketAddr>,
seen: Instant, seen: Instant,
last_connect: Option<Instant>, last_connect: Option<Instant>,
} }
@@ -81,7 +85,40 @@ impl Registry {
self.clients.retain(|_, r| r.seen.elapsed() < ttl); self.clients.retain(|_, r| r.seen.elapsed() < ttl);
before - self.clients.len() before - self.clients.len()
} }
pub fn candidates(&self, peer: PeerId) -> Vec<SocketAddr> {
self.clients
.get(&peer)
.map(|r| r.candidates.clone())
.unwrap_or_default()
}
pub fn register(&mut self, peer: PeerId, endpoint: SocketAddr) -> Response { 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(); self.expire();
if !self.clients.contains_key(&peer) && self.clients.len() >= self.max { if !self.clients.contains_key(&peer) && self.clients.len() >= self.max {
return Response::Error { return Response::Error {
@@ -93,6 +130,7 @@ impl Registry {
peer, peer,
Registration { Registration {
endpoint, endpoint,
candidates: filtered,
seen: Instant::now(), seen: Instant::now(),
last_connect, last_connect,
}, },
+5 -5
View File
@@ -39,8 +39,8 @@ async fn main() -> Result<()> {
}, },
SwarmEvent::Behaviour(BehaviourEvent::Control(request_response::Event::Message { peer, message: request_response::Message::Request { request, channel, .. }, .. })) => { SwarmEvent::Behaviour(BehaviourEvent::Control(request_response::Event::Message { peer, message: request_response::Message::Request { request, channel, .. }, .. })) => {
let response = match request { let response = match request {
Request::Register => { Request::Register { candidates } => {
let response = match endpoints.get(&peer) { Some(endpoint) => registry.register(peer, *endpoint), None => Response::Error { message: "QUIC connection required".into() } }; 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 log(format_args!("registration peer={peer} response={response:?}")); response
}, },
Request::Connect { peer: target } => { Request::Connect { peer: target } => {
@@ -48,10 +48,10 @@ async fn main() -> Result<()> {
if notices.len() >= 2048 { Response::Error { message: "Coordination busy".into() } } 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))) { 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))) => { 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_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, attempt: attempt.clone(), timeout_ms: 5000 }); 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)); 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 } Response::Started { attempt }
}, },
Err(error) => { log(format_args!("node_query_failed source={peer} target={target:?} reason={error:?}")); Response::Error { message: error.to_string() } }, Err(error) => { log(format_args!("node_query_failed source={peer} target={target:?} reason={error:?}")); Response::Error { message: error.to_string() } },
+16 -8
View File
@@ -100,10 +100,18 @@ async fn real_server_registers_observed_udp_addresses_signals_both_peers_and_cli
.unwrap(); .unwrap();
a.dial(control.clone()).unwrap(); a.dial(control.clone()).unwrap();
b.dial(control).unwrap(); b.dial(control).unwrap();
a.behaviour_mut() a.behaviour_mut().send_request(
.send_request(&server_peer, Request::Register); &server_peer,
b.behaviour_mut() Request::Register {
.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); let (mut endpoint_a, mut endpoint_b) = (None, None);
tokio::time::timeout(Duration::from_secs(10), async { tokio::time::timeout(Duration::from_secs(10), async {
while endpoint_a.is_none() || endpoint_b.is_none() { 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 { event = a.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer, message, .. }) = event {
assert_eq!(peer, server_peer); assert_eq!(peer, server_peer);
match message { match message {
request_response::Message::Request { request: Request::Punch { peer, endpoint, attempt, .. }, channel, .. } => { 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); notice_a = Some(attempt); 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(); a.behaviour_mut().send_response(channel, Response::Ack).unwrap();
}, },
request_response::Message::Response { response: Response::Started { attempt }, .. } => started = Some(attempt), 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 { 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); notice_b = Some(attempt); 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(); b.behaviour_mut().send_response(channel, Response::Ack).unwrap();
} }
} }
+26
View File
@@ -33,3 +33,29 @@ fn only_quic_addresses_produce_udp_candidates() {
"127.0.0.1:9000".parse().unwrap() "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());
}