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>
355 lines
13 KiB
Rust
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());
|
|
}
|
|
}
|