From 9fb92967ebaa4c0102044c950f294ef06419e060 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 12 Apr 2026 11:09:06 +0400 Subject: [PATCH] fix(net): bind all endpoints to [::]:0 for dual-stack IPv4+IPv6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every QUIC endpoint was bound to 0.0.0.0:0 (IPv4-only). This silently killed ALL IPv6 host candidates: the Dialer couldn't send packets to [2a0d:...] addresses (wrong address family on the socket), and the Acceptor couldn't receive incoming IPv6 QUIC handshakes. The IPv6 candidates were gathered and advertised in DirectCallOffer/Answer but were completely non-functional. On same-LAN with dual-stack (which both test phones have), this meant: - JoinSet fanned out 3+ candidates (2× IPv6 + 1× IPv4) - IPv6 dials failed silently or timed out - IPv4 dial worked but competed with failed IPv6 for JoinSet attention - Sometimes the JoinSet returned an IPv6 failure before the IPv4 success, causing unnecessary fallback to relay Fix: bind to [::]:0 (IPv6 any) instead of 0.0.0.0:0. On dual-stack systems (Linux/Android default), [::]:0 creates a socket that handles BOTH: - IPv6 natively (global unicast, ULA) - IPv4 via v4-mapped addresses (::ffff:172.16.81.x) One socket, both protocols. All 7 bind sites updated: - register_signal (signal endpoint) - do_register_signal - ping_relay - probe_reflect_addr (fresh endpoint fallback) - dual_path::race (A-role fresh, D-role fresh, relay fresh) With this fix, same-LAN P2P should prefer the IPv6 path (no NAT, direct routing, lower latency) and fall through to IPv4 if IPv6 fails — relay is the last resort after ALL candidates are exhausted. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-client/src/dual_path.rs | 20 +++++++++++++++++--- crates/wzp-client/src/reflect.rs | 3 ++- desktop/src-tauri/src/lib.rs | 10 ++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/wzp-client/src/dual_path.rs b/crates/wzp-client/src/dual_path.rs index 061f99b..aa06fc0 100644 --- a/crates/wzp-client/src/dual_path.rs +++ b/crates/wzp-client/src/dual_path.rs @@ -170,7 +170,14 @@ pub async fn race( } None => { let (sc, _cert_der) = wzp_transport::server_config(); - let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + // [::]:0 = dual-stack socket — handles both IPv4 (via + // v4-mapped addrs) and IPv6 natively. Pre-Phase-5.5 + // used 0.0.0.0:0 (IPv4-only) which silently made + // all IPv6 host candidates non-functional: dials + // to [2a0d:...] failed or hung, accepts from IPv6 + // peers never arrived, and the JoinSet wasted time + // on dead candidates before the IPv4 one won. + let bind: SocketAddr = "[::]:0".parse().unwrap(); let fresh = wzp_transport::create_endpoint(bind, Some(sc))?; tracing::info!( local_addr = ?fresh.local_addr().ok(), @@ -206,7 +213,14 @@ pub async fn race( ep } None => { - let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + // [::]:0 = dual-stack socket — handles both IPv4 (via + // v4-mapped addrs) and IPv6 natively. Pre-Phase-5.5 + // used 0.0.0.0:0 (IPv4-only) which silently made + // all IPv6 host candidates non-functional: dials + // to [2a0d:...] failed or hung, accepts from IPv6 + // peers never arrived, and the JoinSet wasted time + // on dead candidates before the IPv4 one won. + let bind: SocketAddr = "[::]:0".parse().unwrap(); let fresh = wzp_transport::create_endpoint(bind, None)?; tracing::info!( local_addr = ?fresh.local_addr().ok(), @@ -302,7 +316,7 @@ pub async fn race( let relay_ep = match shared_endpoint.clone() { Some(ep) => ep, None => { - let relay_bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + let relay_bind: SocketAddr = "[::]:0".parse().unwrap(); wzp_transport::create_endpoint(relay_bind, None)? } }; diff --git a/crates/wzp-client/src/reflect.rs b/crates/wzp-client/src/reflect.rs index c22a8c7..38ab046 100644 --- a/crates/wzp-client/src/reflect.rs +++ b/crates/wzp-client/src/reflect.rs @@ -102,7 +102,8 @@ pub async fn probe_reflect_addr( let endpoint = match existing_endpoint { Some(ep) => ep, None => { - let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + // [::]:0 = dual-stack socket for both IPv4 + IPv6 + let bind: SocketAddr = "[::]:0".parse().unwrap(); create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))? } }; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e3064b6..1e67165 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -194,7 +194,7 @@ fn get_call_debug_logs() -> bool { async fn ping_relay(relay: String) -> Result { let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?; let _ = rustls::crypto::ring::default_provider().install_default(); - let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); + let bind: std::net::SocketAddr = "[::]:0".parse().unwrap(); let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?; let client_cfg = wzp_transport::client_config(); @@ -914,7 +914,13 @@ fn do_register_signal( // endpoints, which made MikroTik look symmetric and broke direct // P2P because the advertised reflex port was not the listening // port. - let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); + // [::]:0 = dual-stack socket — handles IPv4 (via ::ffff:x.x.x.x + // mapped addresses) AND native IPv6 on one socket. Critical for + // Phase 5.5 ICE host candidates: without dual-stack, the IPv6 + // candidates advertised in DirectCallOffer/Answer are dead on + // arrival — the Dialer can't send to them and the Acceptor can't + // receive from them. + let bind: std::net::SocketAddr = "[::]:0".parse().unwrap(); let (server_cfg, _cert_der) = wzp_transport::server_config(); let endpoint = wzp_transport::create_endpoint(bind, Some(server_cfg)) .map_err(|e| format!("{e}"))?;