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:
@@ -745,6 +745,16 @@ pub enum SignalMessage {
|
||||
/// `None` means "caller doesn't want P2P, use relay only".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_reflexive_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): caller's LAN-local
|
||||
/// interface addresses paired with its signal endpoint's
|
||||
/// port. Peers on the same physical LAN can direct-dial
|
||||
/// these without going through the WAN reflex addr,
|
||||
/// which is important because most consumer NATs
|
||||
/// (including MikroTik masquerade) don't support NAT
|
||||
/// hairpinning — the reflex addr is unreachable from
|
||||
/// the same LAN.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
caller_local_addrs: Vec<String>,
|
||||
},
|
||||
|
||||
/// Callee's response to a direct call.
|
||||
@@ -771,6 +781,13 @@ pub enum SignalMessage {
|
||||
/// carries it opaquely into the caller's `CallSetup`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_reflexive_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): callee's LAN-local
|
||||
/// interface addresses. Same purpose as
|
||||
/// `caller_local_addrs` in `DirectCallOffer`. Only
|
||||
/// populated on `AcceptTrusted` alongside
|
||||
/// `callee_reflexive_addr`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
callee_local_addrs: Vec<String>,
|
||||
},
|
||||
|
||||
/// Relay tells both parties: media room is ready.
|
||||
@@ -791,6 +808,14 @@ pub enum SignalMessage {
|
||||
/// wasn't viable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
peer_direct_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): the OTHER party's LAN
|
||||
/// host addresses (RFC1918 IPv4 + CGNAT + non-link-local
|
||||
/// IPv6). On same-LAN calls these are directly dialable
|
||||
/// and bypass the NAT-hairpinning problem that blocks
|
||||
/// same-LAN peers from using `peer_direct_addr`.
|
||||
/// Client-side race tries all of these in parallel.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
peer_local_addrs: Vec<String>,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
@@ -1034,6 +1059,7 @@ mod tests {
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
};
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(inner),
|
||||
@@ -1075,6 +1101,7 @@ mod tests {
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
},
|
||||
SignalMessage::CallRinging { call_id: "c1".into() },
|
||||
SignalMessage::Hangup { reason: HangupReason::Normal },
|
||||
@@ -1109,6 +1136,7 @@ mod tests {
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&offer).unwrap();
|
||||
assert!(
|
||||
@@ -1136,6 +1164,7 @@ mod tests {
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
};
|
||||
let json_none = serde_json::to_string(&offer_none).unwrap();
|
||||
assert!(
|
||||
@@ -1152,6 +1181,7 @@ mod tests {
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
|
||||
@@ -1171,6 +1201,7 @@ mod tests {
|
||||
room: "call-c1".into(),
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: Some("192.0.2.1:4433".into()),
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user