441 lines
16 KiB
Rust
441 lines
16 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>,
|
|
/// Phase 8 (Tailscale-inspired): caller's port-mapped
|
|
/// external address from NAT-PMP/PCP/UPnP. Cross-wired
|
|
/// into callee's `CallSetup.peer_mapped_addr`.
|
|
pub caller_mapped_addr: Option<String>,
|
|
/// Phase 8: callee's port-mapped external address.
|
|
/// Cross-wired into caller's `CallSetup.peer_mapped_addr`.
|
|
pub callee_mapped_addr: Option<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(),
|
|
caller_mapped_addr: None,
|
|
callee_mapped_addr: None,
|
|
};
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Phase 8: stash the caller's port-mapped address from
|
|
/// the `DirectCallOffer`.
|
|
pub fn set_caller_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
|
|
if let Some(call) = self.calls.get_mut(call_id) {
|
|
call.caller_mapped_addr = addr;
|
|
}
|
|
}
|
|
|
|
/// Phase 8: stash the callee's port-mapped address from
|
|
/// the `DirectCallAnswer`.
|
|
pub fn set_callee_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
|
|
if let Some(call) = self.calls.get_mut(call_id) {
|
|
call.callee_mapped_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_stores_mapped_addrs() {
|
|
let mut reg = CallRegistry::new();
|
|
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
|
|
// Default: both mapped addrs are None.
|
|
let c = reg.get("c1").unwrap();
|
|
assert!(c.caller_mapped_addr.is_none());
|
|
assert!(c.callee_mapped_addr.is_none());
|
|
|
|
// Caller advertises its port-mapped addr via DirectCallOffer.
|
|
reg.set_caller_mapped_addr("c1", Some("203.0.113.5:12345".into()));
|
|
assert_eq!(
|
|
reg.get("c1").unwrap().caller_mapped_addr.as_deref(),
|
|
Some("203.0.113.5:12345")
|
|
);
|
|
|
|
// Callee responds with its mapped addr.
|
|
reg.set_callee_mapped_addr("c1", Some("198.51.100.9:54321".into()));
|
|
assert_eq!(
|
|
reg.get("c1").unwrap().callee_mapped_addr.as_deref(),
|
|
Some("198.51.100.9:54321")
|
|
);
|
|
|
|
// Both addrs readable — relay uses them to cross-wire
|
|
// peer_mapped_addr in CallSetup.
|
|
let c = reg.get("c1").unwrap();
|
|
assert_eq!(c.caller_mapped_addr.as_deref(), Some("203.0.113.5:12345"));
|
|
assert_eq!(c.callee_mapped_addr.as_deref(), Some("198.51.100.9:54321"));
|
|
|
|
// Setter on unknown call is a no-op.
|
|
reg.set_caller_mapped_addr("nope", Some("x".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn call_registry_clearing_mapped_addr_works() {
|
|
let mut reg = CallRegistry::new();
|
|
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
reg.set_caller_mapped_addr("c1", Some("1.2.3.4:5".into()));
|
|
reg.set_caller_mapped_addr("c1", None);
|
|
assert!(reg.get("c1").unwrap().caller_mapped_addr.is_none());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|