feat(p2p): Phase 5.5 — ICE LAN host candidates (IPv4 + IPv6)

Same-LAN P2P was failing because MikroTik masquerade (like most
consumer NATs) doesn't support NAT hairpinning — the advertised
WAN reflex addr is unreachable from a peer on the same LAN as
the advertiser. Phase 5 got us Cone NAT classification and fixed
the measurement artifact, but same-LAN direct dials still had
nowhere to land.

Phase 5.5 adds ICE-style host candidates: each client enumerates
its LAN-local network interface addresses, includes them in the
DirectCallOffer/Answer alongside the reflex addr, and the
dual-path race fans out to ALL peer candidates in parallel.
Same-LAN peers find each other via their RFC1918 IPv4 + ULA /
global-unicast IPv6 addresses without touching the NAT at all.

Dual-stack IPv6 is in scope from the start — on modern ISPs
(including Starlink) the v6 path often works even when v4
hairpinning doesn't, because there's no NAT on the v6 side.

## Changes

### `wzp_client::reflect::local_host_candidates(port)` (new)

Enumerates network interfaces via `if-addrs` and returns
SocketAddrs paired with the caller's port. Filters:

- IPv4: RFC1918 (10/8, 172.16/12, 192.168/16) + CGNAT (100.64/10)
- IPv6: global unicast (2000::/3) + ULA (fc00::/7)
- Skipped: loopback, link-local (169.254, fe80::), public v4
  (already covered by reflex-addr), unspecified

Safe from any thread, one `getifaddrs(3)` syscall.

### Wire protocol (wzp-proto/packet.rs)

Three new `#[serde(default, skip_serializing_if = "Vec::is_empty")]`
fields, backward-compat with pre-5.5 clients/relays by
construction:

- `DirectCallOffer.caller_local_addrs: Vec<String>`
- `DirectCallAnswer.callee_local_addrs: Vec<String>`
- `CallSetup.peer_local_addrs: Vec<String>`

### Call registry (wzp-relay/call_registry.rs)

`DirectCall` gains `caller_local_addrs` + `callee_local_addrs`
Vec<String> fields. New `set_caller_local_addrs` /
`set_callee_local_addrs` setters. Follow the same pattern as
the reflex addr fields.

### Relay cross-wiring (wzp-relay/main.rs)

Both the local-call and cross-relay-federation paths now track
the local_addrs through the registry and inject them into the
CallSetup's peer_local_addrs. Cross-wiring is identical to the
existing peer_direct_addr logic — each party's CallSetup
carries the OTHER party's LAN candidates.

### Client side (desktop/src-tauri/lib.rs)

- `place_call`: gathers local host candidates via
  `local_host_candidates(signal_endpoint.local_addr().port())`
  and includes them in `DirectCallOffer.caller_local_addrs`.
  The port match is critical — it's the Phase 5 shared signal
  socket, so incoming dials to these addrs land on the same
  endpoint that's already listening.
- `answer_call`: same, AcceptTrusted only (privacy mode keeps
  LAN addrs hidden too, for consistency with the reflex addr).
- `connect` Tauri command: new `peer_local_addrs: Vec<String>`
  arg. Builds a `PeerCandidates` bundle and passes it to the
  dual-path race.
- Recv loop's CallSetup handler: destructures + forwards the
  new field to JS via the signal-event payload.

### `dual_path::race` (wzp-client/dual_path.rs)

Signature change: takes `PeerCandidates` (reflex + local Vec)
instead of a single SocketAddr. The D-role branch now fans out
N parallel dials via `tokio::task::JoinSet` — one per candidate
— and the first successful dial wins (losers are aborted
immediately via `set.abort_all()`). Only when ALL candidates
have failed do we return Err; individual candidate failures are
just traced at debug level and the race waits for the others.

LAN host candidates are tried BEFORE the reflex addr in
`PeerCandidates::dial_order()` — they're faster when they work,
and the reflex addr is the fallback for the not-on-same-LAN
case.

### JS side (desktop/main.ts)

`connect` invoke now passes `peerLocalAddrs: data.peer_local_addrs ?? []`
alongside the existing `peerDirectAddr`.

### Tests

All existing test callsites updated for the new Vec<String>
fields (defaults to Vec::new() in tests — they don't exercise
the multi-candidate path). `dual_path.rs` integration tests
wrap the single `dead_peer` / `acceptor_listen_addr` in a
`PeerCandidates { reflexive: Some(_), local: Vec::new() }`.

Full workspace test: 423 passing (same as before 5.5).

## Expected behavior on the reporter's setup

Two phones behind MikroTik, both on the same LAN:

  place_call:host_candidates {"local_addrs": ["192.168.88.21:XXX", "2001:...:YY:XXX"]}
  recv:DirectCallAnswer {"callee_local_addrs": ["192.168.88.22:ZZZ", "2001:...:WW:ZZZ"]}
  recv:CallSetup {"peer_direct_addr":"150.228.49.65:NN",
                  "peer_local_addrs":["192.168.88.22:ZZZ","2001:...:WW:ZZZ"]}
  connect:dual_path_race_start {"peer_reflex":"...","peer_local":[...]}
  dual_path: direct dial succeeded on candidate 0   ← LAN v4 wins
  connect:dual_path_race_won {"path":"Direct"}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 07:34:49 +04:00
parent 8990514417
commit fa038df057
13 changed files with 463 additions and 82 deletions

View File

@@ -323,21 +323,18 @@ async fn connect(
alias: String,
os_aec: bool,
quality: String,
// Phase 3 hole-punching: peer's server-reflexive address as
// cross-wired by the relay in CallSetup.peer_direct_addr. JS
// passes it through when present. Currently LOGGED for
// observability but not yet used to race a direct QUIC
// handshake — that's the Phase 3.5 follow-up. Passing it
// through now so real-hardware testing can confirm the
// advertising layer is delivering the addrs end to end, and so
// the JS → Rust wire is stable before we add the race logic.
#[allow(non_snake_case)]
// Phase 3 hole-punching: peer's server-reflexive address
// cross-wired by the relay in CallSetup.peer_direct_addr.
peer_direct_addr: Option<String>,
// Phase 5.5: peer's LAN host candidates from CallSetup.
// JS side passes [] when empty.
peer_local_addrs: Vec<String>,
) -> Result<String, String> {
emit_call_debug(&app, "connect:start", serde_json::json!({
"relay": relay,
"room": room,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}));
let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() {
@@ -373,12 +370,26 @@ async fn connect(
peer_direct_addr.as_deref(),
);
// Phase 5.5: build the full peer candidate bundle (reflex +
// LAN hosts). The dial_order helper will fan them out in
// priority order for the D-role race.
let peer_local_parsed: Vec<std::net::SocketAddr> = peer_local_addrs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>> =
match (role, peer_addr_parsed, relay_addr_parsed) {
(Some(r), Some(peer_addr), Some(relay_sockaddr)) => {
match (role, relay_addr_parsed) {
(Some(r), Some(relay_sockaddr))
if peer_addr_parsed.is_some() || !peer_local_parsed.is_empty() =>
{
let candidates = wzp_client::dual_path::PeerCandidates {
reflexive: peer_addr_parsed,
local: peer_local_parsed.clone(),
};
tracing::info!(
role = ?r,
%peer_addr,
candidates = ?candidates.dial_order(),
%relay,
%room,
own = ?own_reflex_addr,
@@ -386,7 +397,8 @@ async fn connect(
);
emit_call_debug(&app, "connect:dual_path_race_start", serde_json::json!({
"role": format!("{:?}", r),
"peer_addr": peer_addr.to_string(),
"peer_reflex": peer_addr_parsed.map(|a| a.to_string()),
"peer_local": peer_local_parsed.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
"relay_addr": relay_sockaddr.to_string(),
"own_reflex_addr": own_reflex_addr,
}));
@@ -394,11 +406,9 @@ async fn connect(
let call_sni = format!("call-{room}");
// Phase 5: pass the signal endpoint so the race
// reuses ONE socket for listen + dial + relay.
// The advertised reflex addr then matches the
// actual listening port and peers can reach us.
match wzp_client::dual_path::race(
r,
peer_addr,
candidates,
relay_sockaddr,
room_sni,
call_sni,
@@ -430,7 +440,8 @@ async fn connect(
}
_ => {
tracing::info!(
has_peer = peer_direct_addr.is_some(),
has_peer_reflex = peer_direct_addr.is_some(),
has_peer_local = !peer_local_addrs.is_empty(),
has_own = own_reflex_addr.is_some(),
?role,
%relay,
@@ -438,7 +449,8 @@ async fn connect(
"connect: skipping dual-path race (missing inputs), relay-only"
);
emit_call_debug(&app, "connect:dual_path_skipped", serde_json::json!({
"has_peer": peer_direct_addr.is_some(),
"has_peer_reflex": peer_direct_addr.is_some(),
"has_peer_local": !peer_local_addrs.is_empty(),
"has_own": own_reflex_addr.is_some(),
"role": format!("{:?}", role),
}));
@@ -878,18 +890,17 @@ fn do_register_signal(
"callee_reflexive_addr": callee_reflexive_addr,
}));
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, peer_direct_addr })) => {
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, peer_direct_addr, peer_local_addrs })) => {
// Phase 3: peer_direct_addr carries the OTHER party's
// reflex addr when hole-punching is viable. Forwarded
// to JS alongside the relay addr so the connect flow
// can attempt a dual-path race. `null` when either
// side didn't advertise (pre-Phase-3 peer, privacy
// mode callee, or relay policy).
// reflex addr. Phase 5.5: peer_local_addrs carries
// their LAN host candidates (usable for same-LAN
// direct dials that can't hairpin through the NAT).
tracing::info!(
%call_id,
%room,
%relay_addr,
peer_direct = ?peer_direct_addr,
peer_local = ?peer_local_addrs,
"signal: CallSetup — emitting setup event to JS"
);
emit_call_debug(&app_clone, "recv:CallSetup", serde_json::json!({
@@ -897,6 +908,7 @@ fn do_register_signal(
"room": room,
"relay_addr": relay_addr,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}));
let mut sig = signal_state.lock().await;
sig.signal_status = "setup".into();
@@ -908,6 +920,7 @@ fn do_register_signal(
"room": room,
"relay_addr": relay_addr,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}),
);
}
@@ -1164,6 +1177,26 @@ async fn place_call(
emit_call_debug(&app, "place_call:reflect_query_none", serde_json::json!({}));
}
// Phase 5.5: gather LAN host candidates using the signal
// endpoint's bound port so incoming dials land on the same
// socket that's already listening.
let caller_local_addrs: Vec<String> = {
let sig = state.signal.lock().await;
sig.endpoint
.as_ref()
.and_then(|ep| ep.local_addr().ok())
.map(|la| {
wzp_client::reflect::local_host_candidates(la.port())
.into_iter()
.map(|a| a.to_string())
.collect()
})
.unwrap_or_default()
};
emit_call_debug(&app, "place_call:host_candidates", serde_json::json!({
"local_addrs": caller_local_addrs,
}));
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or("not registered")?;
let call_id = format!(
@@ -1185,6 +1218,7 @@ async fn place_call(
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
caller_reflexive_addr: own_reflex.clone(),
caller_local_addrs: caller_local_addrs.clone(),
})
.await
.map_err(|e| {
@@ -1245,6 +1279,29 @@ async fn answer_call(
None
};
// Phase 5.5: gather LAN host candidates (AcceptTrusted only
// for symmetry with the reflex addr — privacy mode keeps
// LAN addrs hidden too).
let callee_local_addrs: Vec<String> =
if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
let sig = state.signal.lock().await;
sig.endpoint
.as_ref()
.and_then(|ep| ep.local_addr().ok())
.map(|la| {
wzp_client::reflect::local_host_candidates(la.port())
.into_iter()
.map(|a| a.to_string())
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
emit_call_debug(&app, "answer_call:host_candidates", serde_json::json!({
"local_addrs": callee_local_addrs,
}));
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or_else(|| {
tracing::warn!("answer_call: not registered (no transport)");
@@ -1260,6 +1317,7 @@ async fn answer_call(
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
callee_reflexive_addr: own_reflex.clone(),
callee_local_addrs: callee_local_addrs.clone(),
})
.await
.map_err(|e| {

View File

@@ -1488,6 +1488,7 @@ listen("signal-event", (event: any) => {
osAec: osAecCheckbox.checked,
quality: loadSettings().quality || "auto",
peerDirectAddr: data.peer_direct_addr ?? null,
peerLocalAddrs: data.peer_local_addrs ?? [],
});
showCallScreen();
} catch (e: any) {