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:
@@ -50,6 +50,17 @@ pub struct DirectCall {
|
||||
/// `DirectCallAnswer` handling uses this to route the reply
|
||||
/// back through the SAME link instead of broadcasting again.
|
||||
pub peer_relay_fp: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): caller's LAN-local
|
||||
/// interface addresses from the `DirectCallOffer`. Cross-
|
||||
/// wired into the callee's `CallSetup.peer_local_addrs` so
|
||||
/// the callee can direct-dial the caller over the same LAN
|
||||
/// without going through the WAN reflex addr (NAT
|
||||
/// hairpinning often doesn't work for same-LAN peers).
|
||||
pub caller_local_addrs: Vec<String>,
|
||||
/// Phase 5.5 (ICE host candidates): callee's LAN-local
|
||||
/// interface addresses from the `DirectCallAnswer`. Cross-
|
||||
/// wired into the caller's `CallSetup.peer_local_addrs`.
|
||||
pub callee_local_addrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Registry of active direct calls.
|
||||
@@ -79,11 +90,30 @@ impl CallRegistry {
|
||||
caller_reflexive_addr: None,
|
||||
callee_reflexive_addr: None,
|
||||
peer_relay_fp: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
callee_local_addrs: Vec::new(),
|
||||
};
|
||||
self.calls.insert(call_id.clone(), call);
|
||||
self.calls.get(&call_id).unwrap()
|
||||
}
|
||||
|
||||
/// Phase 5.5: stash the caller's LAN host candidates from
|
||||
/// the `DirectCallOffer`. Empty Vec is a valid value meaning
|
||||
/// "caller has no LAN candidates" (e.g. old client).
|
||||
pub fn set_caller_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.caller_local_addrs = addrs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 5.5: stash the callee's LAN host candidates from
|
||||
/// the `DirectCallAnswer`.
|
||||
pub fn set_callee_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.callee_local_addrs = addrs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: stash the federation TLS fingerprint of the peer
|
||||
/// relay that originated (or will receive) the cross-relay
|
||||
/// forward for this call. Safe to call with `None` to clear
|
||||
|
||||
@@ -543,6 +543,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref caller_fingerprint,
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
..
|
||||
} => {
|
||||
// Is the target on THIS relay? If not, drop —
|
||||
@@ -561,7 +562,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
// Stash in local registry so the answer path
|
||||
// can find the call + route the reply back
|
||||
// through the same federation link.
|
||||
// through the same federation link. Include
|
||||
// Phase 5.5 LAN host candidates too.
|
||||
{
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.create_call(
|
||||
@@ -570,6 +572,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
target_fingerprint.clone(),
|
||||
);
|
||||
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
|
||||
reg.set_caller_local_addrs(call_id, caller_local_addrs.clone());
|
||||
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
|
||||
}
|
||||
// Deliver the offer to the local target.
|
||||
@@ -587,6 +590,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref call_id,
|
||||
accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
..
|
||||
} => {
|
||||
// Look up the local caller fp from the registry.
|
||||
@@ -616,24 +620,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept — stash the callee's reflex addr + mark
|
||||
// the call active, then read back BOTH addrs so
|
||||
// we can cross-wire peer_direct_addr in CallSetup.
|
||||
// Accept — stash the callee's reflex addr + LAN
|
||||
// host candidates + mark the call active,
|
||||
// then read back everything needed to cross-
|
||||
// wire peer_direct_addr + peer_local_addrs in
|
||||
// the local CallSetup.
|
||||
let room_name = format!("call-{call_id}");
|
||||
let (caller_addr, callee_addr_for_setup) = {
|
||||
let (callee_addr_for_setup, callee_local_for_setup) = {
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.set_active(call_id, accept_mode, room_name.clone());
|
||||
reg.set_callee_reflexive_addr(
|
||||
call_id,
|
||||
callee_reflexive_addr.clone(),
|
||||
);
|
||||
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
|
||||
let c = reg.get(call_id);
|
||||
(
|
||||
c.and_then(|c| c.caller_reflexive_addr.clone()),
|
||||
c.and_then(|c| c.callee_reflexive_addr.clone()),
|
||||
c.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let _ = caller_addr; // unused on the caller side; callee holds the relevant addr
|
||||
|
||||
// Forward the raw answer to the local caller so
|
||||
// the JS side sees DirectCallAnswer (fires any
|
||||
@@ -649,12 +655,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
// (single-relay fallback — Phase 4.1 will wire
|
||||
// federated media so that actually reaches the
|
||||
// peer). peer_direct_addr = the callee's reflex
|
||||
// addr carried in the answer.
|
||||
// addr carried in the answer. peer_local_addrs
|
||||
// = callee's LAN host candidates (Phase 5.5 ICE).
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room_name.clone(),
|
||||
relay_addr: advertised_addr_d.clone(),
|
||||
peer_direct_addr: callee_addr_for_setup,
|
||||
peer_local_addrs: callee_local_for_setup,
|
||||
};
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub.send_to(&caller_fp, &setup).await;
|
||||
@@ -984,11 +992,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref target_fingerprint,
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
..
|
||||
} => {
|
||||
let target_fp = target_fingerprint.clone();
|
||||
let call_id = call_id.clone();
|
||||
let caller_addr_for_registry = caller_reflexive_addr.clone();
|
||||
let caller_local_for_registry = caller_local_addrs.clone();
|
||||
|
||||
// Check if target is online
|
||||
let online = {
|
||||
@@ -1035,7 +1045,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Create call in registry with the
|
||||
// caller's reflex addr + mark it as
|
||||
// caller's reflex addr + LAN host
|
||||
// candidates, and mark it as
|
||||
// cross-relay so the answer path knows
|
||||
// to route the CallSetup's
|
||||
// peer_direct_addr from what the
|
||||
@@ -1053,7 +1064,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
reg.set_caller_reflexive_addr(
|
||||
&call_id,
|
||||
caller_addr_for_registry,
|
||||
caller_addr_for_registry.clone(),
|
||||
);
|
||||
reg.set_caller_local_addrs(
|
||||
&call_id,
|
||||
caller_local_for_registry.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1067,14 +1082,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Create call in registry + stash the caller's
|
||||
// reflex addr (Phase 3 hole-punching). The relay
|
||||
// treats the addr as opaque — no validation.
|
||||
// Injected later into the callee's CallSetup as
|
||||
// peer_direct_addr.
|
||||
// reflex addr (Phase 3 hole-punching) AND its
|
||||
// LAN host candidates (Phase 5.5 ICE). The
|
||||
// relay treats both as opaque. Both are
|
||||
// injected later into the callee's CallSetup.
|
||||
{
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
|
||||
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry);
|
||||
reg.set_caller_local_addrs(&call_id, caller_local_for_registry);
|
||||
}
|
||||
|
||||
// Forward offer to callee
|
||||
@@ -1095,11 +1111,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref call_id,
|
||||
ref accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
..
|
||||
} => {
|
||||
let call_id = call_id.clone();
|
||||
let mode = *accept_mode;
|
||||
let callee_addr_for_registry = callee_reflexive_addr.clone();
|
||||
let callee_local_for_registry = callee_local_addrs.clone();
|
||||
|
||||
// Phase 4: look up peer fingerprint AND
|
||||
// peer_relay_fp in one lock acquisition.
|
||||
@@ -1160,14 +1178,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
// BOTH parties' addrs so we can cross-wire
|
||||
// peer_direct_addr on the CallSetups below.
|
||||
let room = format!("call-{call_id}");
|
||||
let (caller_addr, callee_addr) = {
|
||||
let (caller_addr, callee_addr, caller_local, callee_local) = {
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.set_active(&call_id, mode, room.clone());
|
||||
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry);
|
||||
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone());
|
||||
let call = reg.get(&call_id);
|
||||
(
|
||||
call.and_then(|c| c.caller_reflexive_addr.clone()),
|
||||
call.and_then(|c| c.callee_reflexive_addr.clone()),
|
||||
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(),
|
||||
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
info!(
|
||||
@@ -1215,6 +1236,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
|
||||
@@ -1227,18 +1249,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Send CallSetup to BOTH parties with
|
||||
// cross-wired peer_direct_addr.
|
||||
// cross-wired peer_direct_addr +
|
||||
// peer_local_addrs (Phase 5.5 ICE).
|
||||
let setup_for_caller = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup.clone(),
|
||||
peer_direct_addr: callee_addr.clone(),
|
||||
peer_local_addrs: callee_local.clone(),
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
|
||||
|
||||
@@ -51,6 +51,7 @@ fn alice_offer(call_id: &str) -> SignalMessage {
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some(ALICE_ADDR.into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +131,7 @@ fn bob_answer(call_id: &str) -> SignalMessage {
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some(BOB_ADDR.into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +175,7 @@ fn relay_b_handle_local_answer(
|
||||
room: format!("call-{call_id}"),
|
||||
relay_addr: RELAY_B_ADDR.into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let _ = callee_addr;
|
||||
(forward, setup_for_bob)
|
||||
@@ -213,6 +216,7 @@ fn relay_a_handle_forwarded_answer(
|
||||
room: format!("call-{call_id}"),
|
||||
relay_addr: RELAY_A_ADDR.into(),
|
||||
peer_direct_addr: callee_reflexive_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,12 +81,14 @@ fn handle_answer_and_build_setups(
|
||||
room: room.clone(),
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: callee_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id,
|
||||
room,
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
(setup_for_caller, setup_for_callee)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
|
||||
caller_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +121,7 @@ fn mk_answer(
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
|
||||
callee_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user