Files
wz-phone/crates/wzp-relay/src/call_registry.rs
Siavash Sameni fa038df057 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>
2026-04-12 07:34:49 +04:00

355 lines
13 KiB
Rust

//! Direct call state tracking.
//!
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
//! Each call goes through: Pending → Ringing → Active → Ended.
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// State of a direct call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectCallState {
/// Offer sent to callee, waiting for response.
Pending,
/// Callee acknowledged, ringing.
Ringing,
/// Call accepted, media room active.
Active,
/// Call ended (hangup, reject, timeout, or error).
Ended,
}
/// A tracked direct call between two users.
pub struct DirectCall {
pub call_id: String,
pub caller_fingerprint: String,
pub callee_fingerprint: String,
pub state: DirectCallState,
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
/// Private room name (set when accepted).
pub room_name: Option<String>,
pub created_at: Instant,
pub answered_at: Option<Instant>,
pub ended_at: Option<Instant>,
/// Phase 3 (hole-punching): caller's server-reflexive address
/// as carried in the `DirectCallOffer`. The relay stashes it
/// here when the offer arrives so it can later inject it as
/// `peer_direct_addr` into the callee's `CallSetup`.
pub caller_reflexive_addr: Option<String>,
/// Phase 3 (hole-punching): callee's server-reflexive address
/// as carried in the `DirectCallAnswer`. Only populated for
/// `AcceptTrusted` answers — privacy-mode answers leave this
/// `None`. Fed into the caller's `CallSetup.peer_direct_addr`.
pub callee_reflexive_addr: Option<String>,
/// Phase 4 (cross-relay): federation TLS fingerprint of the
/// PEER RELAY that forwarded the offer/answer for this call.
/// `None` for local calls — caller and callee both
/// registered on this relay. `Some(fp)` when one side of
/// the call is on a remote relay reached through the
/// federation link identified by `fp`. The
/// `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.
pub struct CallRegistry {
calls: HashMap<String, DirectCall>,
}
impl CallRegistry {
pub fn new() -> Self {
Self {
calls: HashMap::new(),
}
}
/// Create a new pending call. Returns the call_id.
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
let call = DirectCall {
call_id: call_id.clone(),
caller_fingerprint: caller_fp,
callee_fingerprint: callee_fp,
state: DirectCallState::Pending,
accept_mode: None,
room_name: None,
created_at: Instant::now(),
answered_at: None,
ended_at: None,
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
/// a previously-set value.
pub fn set_peer_relay_fp(&mut self, call_id: &str, fp: Option<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.peer_relay_fp = fp;
}
}
/// Phase 3: stash the caller's server-reflexive address read
/// off a `DirectCallOffer`. Safe to call on any call state;
/// a no-op if the call doesn't exist.
pub fn set_caller_reflexive_addr(&mut self, call_id: &str, addr: Option<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.caller_reflexive_addr = addr;
}
}
/// Phase 3: stash the callee's server-reflexive address read
/// off a `DirectCallAnswer`. Safe to call on any call state;
/// a no-op if the call doesn't exist.
pub fn set_callee_reflexive_addr(&mut self, call_id: &str, addr: Option<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.callee_reflexive_addr = addr;
}
}
/// Get a call by ID.
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
self.calls.get(call_id)
}
/// Get a mutable call by ID.
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
self.calls.get_mut(call_id)
}
/// Transition to Ringing state.
pub fn set_ringing(&mut self, call_id: &str) -> bool {
if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending {
call.state = DirectCallState::Ringing;
return true;
}
}
false
}
/// Transition to Active state.
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
call.state = DirectCallState::Active;
call.accept_mode = Some(mode);
call.room_name = Some(room);
call.answered_at = Some(Instant::now());
return true;
}
}
false
}
/// End a call.
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
if let Some(call) = self.calls.get_mut(call_id) {
call.state = DirectCallState::Ended;
call.ended_at = Some(Instant::now());
}
self.calls.remove(call_id)
}
/// Find active/pending calls involving a fingerprint.
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
self.calls.values()
.filter(|c| {
c.state != DirectCallState::Ended
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
})
.collect()
}
/// Find the peer's fingerprint in a call.
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
self.calls.get(call_id).map(|c| {
if c.caller_fingerprint == my_fp {
c.callee_fingerprint.as_str()
} else {
c.caller_fingerprint.as_str()
}
})
}
/// Remove calls that have been pending longer than the timeout.
/// Returns call IDs of expired calls.
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
let now = Instant::now();
let expired: Vec<String> = self.calls.iter()
.filter(|(_, c)| {
c.state == DirectCallState::Pending
&& now.duration_since(c.created_at) > timeout
})
.map(|(id, _)| id.clone())
.collect();
expired.into_iter()
.filter_map(|id| self.calls.remove(&id))
.collect()
}
/// Number of active (non-ended) calls.
pub fn active_count(&self) -> usize {
self.calls.values()
.filter(|c| c.state != DirectCallState::Ended)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_lifecycle() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
assert!(reg.set_ringing("c1"));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
let ended = reg.end_call("c1").unwrap();
assert_eq!(ended.state, DirectCallState::Ended);
assert_eq!(reg.active_count(), 0);
}
#[test]
fn expire_stale_calls() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
// Not expired yet
let expired = reg.expire_stale(Duration::from_secs(30));
assert!(expired.is_empty());
// Force expiry with 0 timeout
let expired = reg.expire_stale(Duration::from_secs(0));
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].call_id, "c1");
}
#[test]
fn peer_lookup() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
}
#[test]
fn call_registry_stores_reflexive_addrs() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
// Default: both addrs are None.
let c = reg.get("c1").unwrap();
assert!(c.caller_reflexive_addr.is_none());
assert!(c.callee_reflexive_addr.is_none());
// Caller advertises its reflex addr via DirectCallOffer.
reg.set_caller_reflexive_addr("c1", Some("192.0.2.1:4433".into()));
assert_eq!(
reg.get("c1").unwrap().caller_reflexive_addr.as_deref(),
Some("192.0.2.1:4433")
);
// Callee responds with AcceptTrusted + its own reflex addr.
reg.set_callee_reflexive_addr("c1", Some("198.51.100.9:4433".into()));
assert_eq!(
reg.get("c1").unwrap().callee_reflexive_addr.as_deref(),
Some("198.51.100.9:4433")
);
// Both addrs are independently readable — the relay uses
// them to cross-wire peer_direct_addr in CallSetup.
let c = reg.get("c1").unwrap();
assert_eq!(
c.caller_reflexive_addr.as_deref(),
Some("192.0.2.1:4433")
);
assert_eq!(
c.callee_reflexive_addr.as_deref(),
Some("198.51.100.9:4433")
);
// Setter on an unknown call is a no-op, not a panic.
reg.set_caller_reflexive_addr("does-not-exist", Some("x".into()));
}
#[test]
fn call_registry_stores_peer_relay_fp() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
// Default: no peer relay.
assert!(reg.get("c1").unwrap().peer_relay_fp.is_none());
// Cross-relay call: origin relay's fp is stashed.
reg.set_peer_relay_fp("c1", Some("relay-a-tls-fp".into()));
assert_eq!(
reg.get("c1").unwrap().peer_relay_fp.as_deref(),
Some("relay-a-tls-fp")
);
// Clearing with None is a valid no-op and empties the field.
reg.set_peer_relay_fp("c1", None);
assert!(reg.get("c1").unwrap().peer_relay_fp.is_none());
// Unknown call is a no-op, not a panic.
reg.set_peer_relay_fp("does-not-exist", Some("x".into()));
}
#[test]
fn call_registry_clearing_reflex_addr_works() {
// Passing None to the setter must clear a previously-set value
// so callers that downgrade to privacy mode mid-flow don't
// leak a stale addr into CallSetup.
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
reg.set_caller_reflexive_addr("c1", Some("192.0.2.1:4433".into()));
reg.set_caller_reflexive_addr("c1", None);
assert!(reg.get("c1").unwrap().caller_reflexive_addr.is_none());
}
}