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:
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user