fix(net): bind all endpoints to [::]:0 for dual-stack IPv4+IPv6

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 11:09:06 +04:00
parent 9f2ff6a6ec
commit 9fb92967eb
3 changed files with 27 additions and 6 deletions

View File

@@ -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)?
}
};

View File

@@ -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}"))?
}
};

View File

@@ -194,7 +194,7 @@ fn get_call_debug_logs() -> bool {
async fn ping_relay(relay: String) -> Result<PingResult, String> {
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}"))?;