feat(net): Phase 7 — dual-socket IPv4+IPv6 ICE

Adds a dedicated IPv6 QUIC endpoint (IPV6_V6ONLY=1 via socket2)
alongside the existing IPv4 signal endpoint for proper dual-stack
P2P connectivity. Previous [::]:0 dual-stack attempt broke IPv4
on Android; this uses separate sockets per address family like
WebRTC/libwebrtc.

- create_ipv6_endpoint(): socket2-based IPv6-only UDP socket,
  tries same port as IPv4 signal EP, falls back to ephemeral
- local_host_candidates(v4_port, v6_port): now gathers IPv6
  global-unicast (2000::/3) and unique-local (fc00::/7) addrs
- dual_path::race(): A-role accepts on both v4+v6 via select!,
  D-role routes each candidate to matching-AF endpoint
- Graceful fallback: if IPv6 unavailable, .ok() → None → pure
  IPv4 behavior identical to pre-Phase-7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 11:54:13 +04:00
parent aee41a638d
commit c2d298beb5
8 changed files with 224 additions and 65 deletions

View File

@@ -131,6 +131,11 @@ pub async fn race(
// When `None`, falls back to fresh endpoints per role.
// Used by tests.
shared_endpoint: Option<wzp_transport::Endpoint>,
// Phase 7: dedicated IPv6 endpoint with IPV6_V6ONLY=1.
// When `Some`, A-role accepts on both v4+v6, D-role routes
// each candidate to its matching-AF endpoint. When `None`,
// IPv6 candidates are skipped (IPv4-only, pre-Phase-7).
ipv6_endpoint: Option<wzp_transport::Endpoint>,
) -> anyhow::Result<RaceResult> {
// Rustls provider must be installed before any quinn endpoint
// is created. Install attempt is idempotent.
@@ -187,18 +192,33 @@ pub async fn race(
}
};
let ep_for_fut = ep.clone();
let v6_ep_for_accept = ipv6_endpoint.clone();
direct_fut = Box::pin(async move {
// `wzp_transport::accept` wraps the same
// `endpoint.accept().await?.await?` dance we want.
// If `ep_for_fut` is the shared signal endpoint,
// this pulls the NEXT incoming connection —
// normally that's the peer's direct-P2P dial.
// Signal recv is done via the signal CONNECTION
// (accept_bi), not the endpoint, so no conflict.
let conn = wzp_transport::accept(&ep_for_fut)
.await
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
Ok(QuinnTransport::new(conn))
// Phase 7: accept on both IPv4 and IPv6 endpoints.
// First incoming connection on either wins.
match v6_ep_for_accept {
Some(v6_ep) => {
tracing::debug!("dual_path: A-role accepting on both v4 + v6 endpoints");
tokio::select! {
v4 = wzp_transport::accept(&ep_for_fut) => {
let conn = v4.map_err(|e| anyhow::anyhow!("v4 accept: {e}"))?;
tracing::info!("dual_path: A-role accepted on IPv4 endpoint");
Ok(QuinnTransport::new(conn))
}
v6 = wzp_transport::accept(&v6_ep) => {
let conn = v6.map_err(|e| anyhow::anyhow!("v6 accept: {e}"))?;
tracing::info!("dual_path: A-role accepted on IPv6 endpoint");
Ok(QuinnTransport::new(conn))
}
}
}
None => {
let conn = wzp_transport::accept(&ep_for_fut)
.await
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
Ok(QuinnTransport::new(conn))
}
}
});
direct_ep = ep;
}
@@ -231,6 +251,7 @@ pub async fn race(
}
};
let ep_for_fut = ep.clone();
let v6_ep_for_dial = ipv6_endpoint.clone();
let dial_order = peer_candidates.dial_order();
let sni = call_sni.clone();
direct_fut = Box::pin(async move {
@@ -250,10 +271,26 @@ pub async fn race(
// when ALL have failed do we return Err.
let mut set = tokio::task::JoinSet::new();
for (idx, candidate) in dial_order.iter().enumerate() {
let ep = ep_for_fut.clone();
// Phase 7: route each candidate to the
// endpoint matching its address family.
let candidate = *candidate;
let ep = if candidate.is_ipv6() {
match &v6_ep_for_dial {
Some(v6) => v6.clone(),
None => {
tracing::debug!(
%candidate,
candidate_idx = idx,
"dual_path: skipping IPv6 candidate, no v6 endpoint"
);
continue;
}
}
} else {
ep_for_fut.clone()
};
let client_cfg = wzp_transport::client_config();
let sni = sni.clone();
let candidate = *candidate;
set.spawn(async move {
let result = wzp_transport::connect(
&ep,
@@ -474,7 +511,7 @@ pub async fn race(
return Err(anyhow::anyhow!("both paths failed: no media transport available"));
}
let _ = (direct_ep, relay_ep);
let _ = (direct_ep, relay_ep, ipv6_endpoint);
Ok(RaceResult {
direct_transport: direct_result

View File

@@ -292,7 +292,7 @@ pub async fn detect_nat_type(
/// Safe to call from any thread; no I/O, no async. The `if-addrs`
/// crate reads the kernel's interface table via a single
/// getifaddrs(3) syscall.
pub fn local_host_candidates(port: u16) -> Vec<SocketAddr> {
pub fn local_host_candidates(v4_port: u16, v6_port: Option<u16>) -> Vec<SocketAddr> {
let Ok(ifaces) = if_addrs::get_if_addrs() else {
return Vec::new();
};
@@ -311,28 +311,35 @@ pub fn local_host_candidates(port: u16) -> Vec<SocketAddr> {
// Skip public v4 because the reflex addr already
// covers that path.
if v4.is_private() {
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), port));
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
} else if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40 {
// 100.64/10 CGNAT — rare but valid if two
// phones are on the same CGNAT-hairpinned
// carrier LAN (some hotspot setups).
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), port));
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
}
}
std::net::IpAddr::V6(_v6) => {
// IPv6 host candidates are disabled until we add
// a dedicated IPv6 socket alongside the IPv4 one.
// Android's IPV6_V6ONLY=1 default on some kernels
// makes [::]:0 dual-stack unreliable — IPv4 dials
// silently fail. Advertising IPv6 addrs from an
// IPv4-only socket wastes JoinSet slots and adds
// timeout delays before the working IPv4 candidate
// gets picked.
//
// TODO: Phase 7 — create a second quinn::Endpoint
// on [::]:0 for IPv6-only dials, run them alongside
// the IPv4 JoinSet. This gives true dual-stack ICE
// without the v4-mapped-address fragility.
std::net::IpAddr::V6(v6) => {
// Phase 7: IPv6 host candidates via dedicated
// IPv6 socket. When v6_port is None, no IPv6
// endpoint exists — skip silently.
let Some(port) = v6_port else { continue };
if v6.is_loopback() || v6.is_unspecified() {
continue;
}
// fe80::/10 link-local — needs scope ID, not
// routable across interfaces.
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
// Accept global unicast (2000::/3) and
// unique-local (fc00::/7).
let first_seg = v6.segments()[0];
let is_global = (first_seg & 0xe000) == 0x2000;
let is_ula = (first_seg & 0xfe00) == 0xfc00;
if is_global || is_ula {
out.push(SocketAddr::new(std::net::IpAddr::V6(v6), port));
}
}
}
}

View File

@@ -15,6 +15,7 @@ tracing = { workspace = true }
async-trait = { workspace = true }
serde_json = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
socket2 = { workspace = true }
rcgen = "0.13"
ed25519-dalek = { workspace = true }
hkdf = { workspace = true }

View File

@@ -39,6 +39,71 @@ pub async fn connect(
Ok(connection)
}
/// Create an IPv6-only QUIC endpoint with `IPV6_V6ONLY=1`.
///
/// Tries `[::]:preferred_port` first (same port as the IPv4 signal
/// endpoint — allowed on Linux/Android when the AFs differ and
/// V6ONLY is set). Falls back to `[::]:0` (OS-assigned) if the
/// preferred port is already taken.
///
/// Must be called from within a tokio runtime (quinn needs the
/// async runtime handle for its I/O driver).
pub fn create_ipv6_endpoint(
preferred_port: u16,
server_config: Option<quinn::ServerConfig>,
) -> Result<quinn::Endpoint, TransportError> {
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{Ipv6Addr, SocketAddrV6};
let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))
.map_err(|e| TransportError::Internal(format!("ipv6 socket: {e}")))?;
// Critical: IPv6-only so this socket never intercepts IPv4.
// On Android some kernels default to V6ONLY=1 anyway, but we
// set it explicitly for cross-platform consistency.
sock.set_only_v6(true)
.map_err(|e| TransportError::Internal(format!("set_only_v6: {e}")))?;
sock.set_reuse_address(true)
.map_err(|e| TransportError::Internal(format!("set_reuse_address: {e}")))?;
// Try the preferred port (same as IPv4 signal endpoint), fall
// back to ephemeral if the OS rejects it.
let bind_addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, preferred_port, 0, 0);
if let Err(e) = sock.bind(&bind_addr.into()) {
if preferred_port != 0 {
tracing::debug!(
preferred_port,
error = %e,
"ipv6 bind to preferred port failed, falling back to ephemeral"
);
let fallback = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0);
sock.bind(&fallback.into())
.map_err(|e| TransportError::Internal(format!("ipv6 bind fallback: {e}")))?;
} else {
return Err(TransportError::Internal(format!("ipv6 bind: {e}")));
}
}
sock.set_nonblocking(true)
.map_err(|e| TransportError::Internal(format!("set_nonblocking: {e}")))?;
let udp_socket: std::net::UdpSocket = sock.into();
let runtime = quinn::default_runtime()
.ok_or_else(|| TransportError::Internal("no async runtime for ipv6 endpoint".into()))?;
let endpoint = quinn::Endpoint::new(
quinn::EndpointConfig::default(),
server_config,
udp_socket,
runtime,
)
.map_err(|e| TransportError::Internal(format!("ipv6 endpoint: {e}")))?;
Ok(endpoint)
}
/// Accept the next incoming connection on an endpoint.
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> {
let incoming = endpoint

View File

@@ -23,7 +23,7 @@ pub mod quic;
pub mod reliable;
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
pub use connection::{accept, connect, create_endpoint};
pub use connection::{accept, connect, create_endpoint, create_ipv6_endpoint};
pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport;
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};