锥形nat协议,QUIC打洞
This commit is contained in:
1 parent
4ee0c0735a
commit
9886e0306f
5 files changed
+99
-17
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user