perf(ipv6_hole_punch): handle multiple ipv6 public ip correctly (#2387)

This PR fixes IPv6 UDP hole punching for peers with multiple public IPv6
addresses by adding two RPC signals:

 - connector_addrs: connector-side candidate public IPv6 socket addresses
that the remote peer should punch back to.

 - preferred_src_ipv6: remote listener IPv6 address that the remote peer
should use as the UDP source when sending hole-punch packets back.
Together, these let the connector try all usable local IPv6 candidates
while keeping the remote punch-back

packet sourced from the same IPv6 address that the connector is dialing.
This commit is contained in:
KKRainbow
2026-06-28 20:42:16 +08:00
committed by GitHub
parent 9cb3833216
commit 46f1b57367
7 changed files with 762 additions and 66 deletions
+167 -15
View File
@@ -1,7 +1,7 @@
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use crate::{
common::global_ctx::ArcGlobalCtx,
common::{global_ctx::ArcGlobalCtx, network::IPCollector},
proto::{
common::Void,
peer_rpc::{
@@ -12,6 +12,8 @@ use crate::{
tunnel::udp,
};
const MAX_UDP_HOLE_PUNCH_CONNECTOR_ADDRS: usize = 16;
fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGlobalCtx) {
ret.interface_ipv6s.retain(|ip| {
let ip = std::net::Ipv6Addr::from(*ip);
@@ -28,6 +30,86 @@ fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGl
}
}
fn is_usable_preferred_src_ipv6(ip: &Ipv6Addr, global_ctx: &ArcGlobalCtx) -> bool {
!global_ctx.is_ip_easytier_managed_ipv6(ip)
&& !ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_unique_local()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()
}
async fn local_preferred_src_ipv6(
global_ctx: &ArcGlobalCtx,
preferred_src_ipv6: Option<crate::proto::common::Ipv6Addr>,
) -> Option<udp::PreferredIpv6Source> {
let preferred_src_ipv6 = preferred_src_ipv6.map(Ipv6Addr::from)?;
if !is_usable_preferred_src_ipv6(&preferred_src_ipv6, global_ctx) {
tracing::debug!(
?preferred_src_ipv6,
"ignore unusable preferred IPv6 source for udp hole punch"
);
return None;
}
let ifaces = IPCollector::collect_interfaces(global_ctx.net_ns.clone(), false).await;
for iface in ifaces {
let is_local = iface.ips.iter().any(|ip| match ip.ip() {
IpAddr::V6(v6) => v6 == preferred_src_ipv6,
IpAddr::V4(_) => false,
});
if is_local {
tracing::debug!(
?preferred_src_ipv6,
ifindex = iface.index,
"use preferred IPv6 source for udp hole punch"
);
return Some(udp::PreferredIpv6Source {
ip: preferred_src_ipv6,
ifindex: iface.index,
});
}
}
tracing::debug!(
?preferred_src_ipv6,
"ignore non-local preferred IPv6 source for udp hole punch"
);
None
}
fn connector_addrs_from_request(
req: SendUdpHolePunchPacketRequest,
) -> rpc_types::error::Result<(u16, Vec<SocketAddr>, Option<crate::proto::common::Ipv6Addr>)> {
let listener_port = u16::try_from(req.listener_port)
.map_err(|_| anyhow::anyhow!("listener_port is out of range: {}", req.listener_port))?;
let mut connector_addrs = req
.connector_addrs
.into_iter()
.map(SocketAddr::from)
.collect::<Vec<_>>();
if connector_addrs.is_empty() {
connector_addrs.push(
req.connector_addr
.ok_or(anyhow::anyhow!("connector_addr is required"))?
.into(),
);
}
let mut deduped = Vec::with_capacity(connector_addrs.len());
for addr in connector_addrs {
if !deduped.contains(&addr) {
deduped.push(addr);
}
if deduped.len() >= MAX_UDP_HOLE_PUNCH_CONNECTOR_ADDRS {
break;
}
}
Ok((listener_port, deduped, req.preferred_src_ipv6))
}
#[derive(Clone)]
pub struct DirectConnectorManagerRpcServer {
// TODO: this only cache for one src peer, should make it global
@@ -67,23 +149,38 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer {
_: BaseController,
req: SendUdpHolePunchPacketRequest,
) -> rpc_types::error::Result<Void> {
let listener_port = req.listener_port as u16;
let connector_addr: SocketAddr = req
.connector_addr
.ok_or(anyhow::anyhow!("connector_addr is required"))?
.into();
let (listener_port, connector_addrs, preferred_src_ipv6) =
connector_addrs_from_request(req)?;
let preferred_src_ipv6 =
local_preferred_src_ipv6(&self.global_ctx, preferred_src_ipv6).await;
tracing::info!(
"Sending udp hole punch packet to {} from listener port {}",
connector_addr,
listener_port
?connector_addrs,
?preferred_src_ipv6,
listener_port,
"Sending udp hole punch packet"
);
// send 3 packets to the connector
for _ in 0..3 {
match connector_addr {
SocketAddr::V4(addr) => udp::send_v4_hole_punch_packet(listener_port, addr).await?,
SocketAddr::V6(addr) => udp::send_v6_hole_punch_packet(listener_port, addr).await?,
for connector_addr in &connector_addrs {
let ret = match connector_addr {
SocketAddr::V4(addr) => {
udp::send_v4_hole_punch_packet(listener_port, *addr).await
}
SocketAddr::V6(addr) => {
udp::send_v6_hole_punch_packet(listener_port, *addr, preferred_src_ipv6)
.await
}
};
if let Err(e) = ret {
tracing::debug!(
?e,
?connector_addr,
listener_port,
"send udp hole punch packet failed"
);
}
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
@@ -99,11 +196,12 @@ impl DirectConnectorManagerRpcServer {
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::{collections::BTreeSet, net::SocketAddr};
use crate::{
common::global_ctx::tests::get_mock_global_ctx,
peers::peer_rpc_service::remove_easytier_managed_ipv6s, proto::peer_rpc::GetIpListResponse,
peers::peer_rpc_service::{connector_addrs_from_request, remove_easytier_managed_ipv6s},
proto::peer_rpc::{GetIpListResponse, SendUdpHolePunchPacketRequest},
};
#[tokio::test]
@@ -133,4 +231,58 @@ mod tests {
assert_eq!(ip_list.public_ipv6, None);
assert_eq!(ip_list.interface_ipv6s, vec![physical_ipv6.into()]);
}
#[test]
fn hole_punch_request_prefers_batch_connector_addrs() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let first_batch_addr: SocketAddr = "[2001:db8::2]:10002".parse().unwrap();
let second_batch_addr: SocketAddr = "[2001:db8::3]:10003".parse().unwrap();
let preferred_src_ipv6: std::net::Ipv6Addr = "2001:db8::4".parse().unwrap();
let (listener_port, connector_addrs, preferred_src) =
connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: 11010,
preferred_src_ipv6: Some(preferred_src_ipv6.into()),
connector_addrs: vec![
first_batch_addr.into(),
first_batch_addr.into(),
second_batch_addr.into(),
],
})
.unwrap();
assert_eq!(listener_port, 11010);
assert_eq!(connector_addrs, vec![first_batch_addr, second_batch_addr]);
assert_eq!(preferred_src, Some(preferred_src_ipv6.into()));
}
#[test]
fn hole_punch_request_falls_back_to_legacy_connector_addr() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let (_, connector_addrs, _) = connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: 11010,
preferred_src_ipv6: None,
connector_addrs: vec![],
})
.unwrap();
assert_eq!(connector_addrs, vec![old_addr]);
}
#[test]
fn hole_punch_request_rejects_out_of_range_listener_port() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let ret = connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: u16::MAX as u32 + 1,
preferred_src_ipv6: None,
connector_addrs: vec![],
});
assert!(ret.is_err());
}
}