feat(nat): Tailscale-inspired STUN/ICE + port mapping + mid-call re-gathering (#28)
Phase 8: 5 new modules bringing NAT traversal close to Tailscale's approach. - stun.rs: RFC 5389 STUN client — public server reflexive discovery, XOR-MAPPED-ADDRESS parsing, parallel probe with retry, STUN fallback in desktop try_reflect_own_addr() - portmap.rs: NAT-PMP (RFC 6886) + PCP (RFC 6887) + UPnP IGD port mapping — gateway discovery, acquire/release/refresh lifecycle, new PeerCandidates.mapped candidate type in dial order - ice_agent.rs: candidate lifecycle — gather(), re_gather(), apply_peer_update() with monotonic generation counter, CandidateUpdate signal message forwarded by relay - netcheck.rs: comprehensive diagnostic — NAT type, IPv4/v6, port mapping availability, relay latencies, CLI --netcheck - relay_map.rs: RTT-sorted relay map, preferred() selection, populate_from_ack() for RegisterPresenceAck.available_relays Relay: CallRegistry stores + cross-wires caller/callee_mapped_addr into CallSetup.peer_mapped_addr. Region config + available_relays populated from federation peers in RegisterPresenceAck. Desktop: place_call/answer_call call acquire_port_mapping() and fill caller/callee_mapped_addr. STUN+relay combined NAT detection. 571 tests pass (66 new), 0 regressions, 0 warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -8006,6 +8006,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"if-addrs",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"rustls",
|
||||
"serde",
|
||||
|
||||
@@ -33,6 +33,7 @@ libc = "0.2"
|
||||
# through the WAN reflex addr (which many consumer NATs, including
|
||||
# MikroTik's default masquerade, don't support).
|
||||
if-addrs = "0.13"
|
||||
rand = { workspace = true }
|
||||
|
||||
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
||||
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
||||
|
||||
@@ -52,6 +52,8 @@ struct CliArgs {
|
||||
signal: bool,
|
||||
/// Place a direct call to a fingerprint (requires --signal).
|
||||
call_target: Option<String>,
|
||||
/// Run network diagnostic (STUN, port mapping, relay latencies).
|
||||
netcheck: bool,
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
@@ -97,6 +99,7 @@ fn parse_args() -> CliArgs {
|
||||
let mut relay_str = None;
|
||||
let mut signal = false;
|
||||
let mut call_target = None;
|
||||
let mut netcheck = false;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
@@ -182,6 +185,7 @@ fn parse_args() -> CliArgs {
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--netcheck" => { netcheck = true; }
|
||||
"--version-check" => { version_check = true; }
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
@@ -238,6 +242,7 @@ fn parse_args() -> CliArgs {
|
||||
version_check,
|
||||
signal,
|
||||
call_target,
|
||||
netcheck,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +261,23 @@ async fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --netcheck: run network diagnostic and exit
|
||||
if cli.netcheck {
|
||||
let config = wzp_client::netcheck::NetcheckConfig {
|
||||
stun_config: wzp_client::stun::StunConfig::default(),
|
||||
relays: vec![
|
||||
("relay".into(), cli.relay_addr),
|
||||
],
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
test_portmap: true,
|
||||
test_ipv6: true,
|
||||
local_port: 0,
|
||||
};
|
||||
let report = wzp_client::netcheck::run_netcheck(&config).await;
|
||||
print!("{}", wzp_client::netcheck::format_report(&report));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --version-check: query relay version over QUIC and exit
|
||||
if cli.version_check {
|
||||
let client_config = wzp_transport::client_config();
|
||||
@@ -776,6 +798,7 @@ async fn run_signal_mode(
|
||||
// relay-path.
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
}).await?;
|
||||
}
|
||||
@@ -810,13 +833,14 @@ async fn run_signal_mode(
|
||||
// so callee addr stays hidden from the caller.
|
||||
callee_reflexive_addr: None,
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
}).await;
|
||||
}
|
||||
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
||||
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
||||
}
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _ } => {
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _, peer_mapped_addr: _ } => {
|
||||
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
||||
|
||||
// Connect to the media room
|
||||
|
||||
@@ -88,19 +88,30 @@ pub struct PeerCandidates {
|
||||
/// same-LAN pairs — direct dials to these bypass the NAT
|
||||
/// entirely.
|
||||
pub local: Vec<SocketAddr>,
|
||||
/// Phase 8 (Tailscale-inspired): peer's port-mapped external
|
||||
/// address from NAT-PMP/PCP/UPnP. When the router supports
|
||||
/// port mapping, this gives a stable external address even
|
||||
/// behind symmetric NATs.
|
||||
pub mapped: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl PeerCandidates {
|
||||
/// Flatten into the list of addrs the D-role should dial.
|
||||
/// Order: LAN host candidates first (fastest when they
|
||||
/// work), then reflexive (covers the non-LAN case).
|
||||
/// work), then port-mapped (stable even behind symmetric
|
||||
/// NATs), then reflexive (covers the non-LAN case).
|
||||
pub fn dial_order(&self) -> Vec<SocketAddr> {
|
||||
let mut out = Vec::with_capacity(self.local.len() + 1);
|
||||
let mut out = Vec::with_capacity(self.local.len() + 2);
|
||||
out.extend(self.local.iter().copied());
|
||||
// Port-mapped address goes before reflexive — it's
|
||||
// more reliable on symmetric NATs where the reflexive
|
||||
// addr might not match what the peer actually sees.
|
||||
if let Some(a) = self.mapped {
|
||||
if !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
if let Some(a) = self.reflexive {
|
||||
// Only add if it's not already in the list (some
|
||||
// edge cases on same-LAN could have the same addr
|
||||
// in both).
|
||||
if !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
@@ -111,7 +122,7 @@ impl PeerCandidates {
|
||||
/// Is there anything for the D-role to dial? If not, the
|
||||
/// race reduces to relay-only.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.reflexive.is_none() && self.local.is_empty()
|
||||
self.reflexive.is_none() && self.local.is_empty() && self.mapped.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,3 +555,121 @@ pub async fn race(
|
||||
local_winner,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_all_types() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
||||
local: vec![
|
||||
"192.168.1.10:4433".parse().unwrap(),
|
||||
"10.0.0.5:4433".parse().unwrap(),
|
||||
],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
// Order: local first, then mapped, then reflexive
|
||||
assert_eq!(order.len(), 4);
|
||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[2], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_no_mapped() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
||||
local: vec!["192.168.1.10:4433".parse().unwrap()],
|
||||
mapped: None,
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 2);
|
||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[1], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_only_mapped() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: None,
|
||||
local: vec![],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_dedup_mapped_equals_reflexive() {
|
||||
let addr: SocketAddr = "203.0.113.5:4433".parse().unwrap();
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some(addr),
|
||||
local: vec![],
|
||||
mapped: Some(addr), // same as reflexive
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
// Should be deduped to 1
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_dedup_mapped_in_local() {
|
||||
let addr: SocketAddr = "192.168.1.10:4433".parse().unwrap();
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: None,
|
||||
local: vec![addr],
|
||||
mapped: Some(addr), // same as a local addr
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_is_empty() {
|
||||
let empty = PeerCandidates::default();
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let with_reflexive = PeerCandidates {
|
||||
reflexive: Some("1.2.3.4:5".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_reflexive.is_empty());
|
||||
|
||||
let with_local = PeerCandidates {
|
||||
local: vec!["10.0.0.1:5".parse().unwrap()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_local.is_empty());
|
||||
|
||||
let with_mapped = PeerCandidates {
|
||||
mapped: Some("1.2.3.4:5".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_mapped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_empty_dial_order() {
|
||||
let empty = PeerCandidates::default();
|
||||
assert!(empty.dial_order().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_path_debug() {
|
||||
// Just verify Debug impl doesn't panic
|
||||
let _ = format!("{:?}", WinningPath::Direct);
|
||||
let _ = format!("{:?}", WinningPath::Relay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
// bridge. Catch-all mapping for completeness.
|
||||
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
|
||||
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
|
||||
SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather
|
||||
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
|
||||
}
|
||||
}
|
||||
|
||||
444
crates/wzp-client/src/ice_agent.rs
Normal file
444
crates/wzp-client/src/ice_agent.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
//! Phase 8 (Tailscale-inspired): ICE agent for candidate lifecycle
|
||||
//! management and mid-call re-gathering.
|
||||
//!
|
||||
//! The `IceAgent` owns the state of all candidate discovery
|
||||
//! mechanisms (STUN, port mapping, host candidates) and provides:
|
||||
//!
|
||||
//! - `gather()`: initial candidate gathering during call setup
|
||||
//! - `re_gather()`: triggered on network change, produces a
|
||||
//! `CandidateUpdate` to send to the peer
|
||||
//! - `apply_peer_update()`: processes peer's candidate updates
|
||||
//!
|
||||
//! This is NOT a full ICE agent (RFC 8445). It's the Tailscale-style
|
||||
//! "gather all candidates, race them all in parallel, pick the
|
||||
//! winner" approach, adapted for QUIC transport.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_proto::SignalMessage;
|
||||
|
||||
use crate::dual_path::PeerCandidates;
|
||||
use crate::portmap;
|
||||
use crate::reflect;
|
||||
use crate::stun;
|
||||
|
||||
/// All candidates gathered for the local side.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandidateSet {
|
||||
/// STUN-discovered server-reflexive address.
|
||||
pub reflexive: Option<SocketAddr>,
|
||||
/// LAN host candidates from local interfaces.
|
||||
pub local: Vec<SocketAddr>,
|
||||
/// Port-mapped address from NAT-PMP/PCP/UPnP.
|
||||
pub mapped: Option<SocketAddr>,
|
||||
/// Generation counter (monotonically increasing per call).
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
/// Configuration for the ICE agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IceAgentConfig {
|
||||
/// STUN servers to use for reflexive discovery.
|
||||
pub stun_config: stun::StunConfig,
|
||||
/// Whether to attempt port mapping.
|
||||
pub enable_portmap: bool,
|
||||
/// Timeout for each discovery mechanism.
|
||||
pub gather_timeout: Duration,
|
||||
/// The QUIC endpoint's local port (for host candidate pairing).
|
||||
pub local_v4_port: u16,
|
||||
/// Optional IPv6 port.
|
||||
pub local_v6_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Default for IceAgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stun_config: stun::StunConfig::default(),
|
||||
enable_portmap: true,
|
||||
gather_timeout: Duration::from_secs(3),
|
||||
local_v4_port: 0,
|
||||
local_v6_port: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ICE agent managing candidate lifecycle.
|
||||
pub struct IceAgent {
|
||||
config: IceAgentConfig,
|
||||
generation: AtomicU32,
|
||||
call_id: String,
|
||||
/// Last-seen peer generation (to filter stale updates).
|
||||
peer_generation: AtomicU32,
|
||||
}
|
||||
|
||||
impl IceAgent {
|
||||
pub fn new(call_id: String, config: IceAgentConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
generation: AtomicU32::new(0),
|
||||
call_id,
|
||||
peer_generation: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial candidate gathering. Runs all discovery mechanisms
|
||||
/// in parallel and returns the full candidate set.
|
||||
pub async fn gather(&self) -> CandidateSet {
|
||||
let generation = self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Run STUN + port mapping + host candidates in parallel.
|
||||
let stun_fut = stun::discover_reflexive(&self.config.stun_config);
|
||||
let portmap_fut = async {
|
||||
if self.config.enable_portmap && self.config.local_v4_port > 0 {
|
||||
portmap::acquire_port_mapping(self.config.local_v4_port, None)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let (stun_result, portmap_result) = tokio::join!(
|
||||
tokio::time::timeout(self.config.gather_timeout, stun_fut),
|
||||
tokio::time::timeout(self.config.gather_timeout, portmap_fut),
|
||||
);
|
||||
|
||||
let reflexive = stun_result.ok().and_then(|r| r.ok());
|
||||
let mapped = portmap_result
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.external_addr);
|
||||
let local = reflect::local_host_candidates(
|
||||
self.config.local_v4_port,
|
||||
self.config.local_v6_port,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
reflexive = ?reflexive,
|
||||
mapped = ?mapped,
|
||||
local_count = local.len(),
|
||||
"ice_agent: gathered candidates"
|
||||
);
|
||||
|
||||
CandidateSet {
|
||||
reflexive,
|
||||
local,
|
||||
mapped,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-gather candidates after a network change. Increments the
|
||||
/// generation counter and returns a `CandidateUpdate` signal
|
||||
/// message to send to the peer.
|
||||
pub async fn re_gather(&self) -> (CandidateSet, SignalMessage) {
|
||||
let candidates = self.gather().await;
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
call_id: self.call_id.clone(),
|
||||
reflexive_addr: candidates.reflexive.map(|a| a.to_string()),
|
||||
local_addrs: candidates.local.iter().map(|a| a.to_string()).collect(),
|
||||
mapped_addr: candidates.mapped.map(|a| a.to_string()),
|
||||
generation: candidates.generation,
|
||||
};
|
||||
|
||||
(candidates, update)
|
||||
}
|
||||
|
||||
/// Process a peer's candidate update. Returns `Some(PeerCandidates)`
|
||||
/// if the update is newer than the last-seen generation, `None`
|
||||
/// if it's stale.
|
||||
pub fn apply_peer_update(
|
||||
&self,
|
||||
update: &SignalMessage,
|
||||
) -> Option<PeerCandidates> {
|
||||
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
|
||||
SignalMessage::CandidateUpdate {
|
||||
reflexive_addr,
|
||||
local_addrs,
|
||||
mapped_addr,
|
||||
generation,
|
||||
..
|
||||
} => (reflexive_addr, local_addrs, mapped_addr, *generation),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Only accept if newer than last-seen generation.
|
||||
let prev = self.peer_generation.fetch_max(generation, Ordering::AcqRel);
|
||||
if generation <= prev {
|
||||
tracing::debug!(
|
||||
generation,
|
||||
prev,
|
||||
"ice_agent: ignoring stale CandidateUpdate"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let reflexive = reflexive_addr
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let local: Vec<SocketAddr> = local_addrs
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let mapped = mapped_addr
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
reflexive = ?reflexive,
|
||||
mapped = ?mapped,
|
||||
local_count = local.len(),
|
||||
"ice_agent: applied peer candidate update"
|
||||
);
|
||||
|
||||
Some(PeerCandidates {
|
||||
reflexive,
|
||||
local,
|
||||
mapped,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current generation counter.
|
||||
pub fn generation(&self) -> u32 {
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_rejects_stale() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
|
||||
// First update (gen=1) should succeed.
|
||||
let update1 = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec!["192.168.1.10:4433".into()],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
let result = agent.apply_peer_update(&update1);
|
||||
assert!(result.is_some());
|
||||
let candidates = result.unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("203.0.113.5:4433".parse().unwrap())
|
||||
);
|
||||
assert_eq!(candidates.local.len(), 1);
|
||||
|
||||
// Same generation (gen=1) should be rejected.
|
||||
let update1b = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
assert!(agent.apply_peer_update(&update1b).is_none());
|
||||
|
||||
// Older generation (gen=0) should be rejected.
|
||||
let update0 = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("10.0.0.1:4433".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 0,
|
||||
};
|
||||
assert!(agent.apply_peer_update(&update0).is_none());
|
||||
|
||||
// Newer generation (gen=2) should succeed.
|
||||
let update2 = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("198.51.100.9:5555".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: Some("203.0.113.5:12345".into()),
|
||||
generation: 2,
|
||||
};
|
||||
let result = agent.apply_peer_update(&update2);
|
||||
assert!(result.is_some());
|
||||
let candidates = result.unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("198.51.100.9:5555".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
candidates.mapped,
|
||||
Some("203.0.113.5:12345".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_wrong_signal_returns_none() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
let wrong = SignalMessage::Reflect;
|
||||
assert!(agent.apply_peer_update(&wrong).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_increments() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
assert_eq!(agent.generation(), 0);
|
||||
// Simulate what gather() does internally
|
||||
let g1 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
||||
assert_eq!(g1, 0);
|
||||
assert_eq!(agent.generation(), 1);
|
||||
let g2 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
||||
assert_eq!(g2, 1);
|
||||
assert_eq!(agent.generation(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_parses_all_fields() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec![
|
||||
"192.168.1.10:4433".into(),
|
||||
"10.0.0.5:4433".into(),
|
||||
],
|
||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("203.0.113.5:4433".parse().unwrap())
|
||||
);
|
||||
assert_eq!(candidates.local.len(), 2);
|
||||
assert_eq!(
|
||||
candidates.local[0],
|
||||
"192.168.1.10:4433".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
candidates.mapped,
|
||||
Some("198.51.100.42:12345".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_handles_empty_fields() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
call_id: "test".into(),
|
||||
reflexive_addr: None,
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert!(candidates.reflexive.is_none());
|
||||
assert!(candidates.local.is_empty());
|
||||
assert!(candidates.mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_skips_unparseable_addrs() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
call_id: "test".into(),
|
||||
reflexive_addr: Some("not-an-addr".into()),
|
||||
local_addrs: vec![
|
||||
"192.168.1.10:4433".into(),
|
||||
"garbage".into(),
|
||||
"10.0.0.5:4433".into(),
|
||||
],
|
||||
mapped_addr: Some("also-bad".into()),
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert!(candidates.reflexive.is_none()); // unparseable
|
||||
assert_eq!(candidates.local.len(), 2); // garbage filtered
|
||||
assert!(candidates.mapped.is_none()); // unparseable
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_values() {
|
||||
let cfg = IceAgentConfig::default();
|
||||
assert!(cfg.enable_portmap);
|
||||
assert!(cfg.gather_timeout.as_secs() > 0);
|
||||
assert!(!cfg.stun_config.servers.is_empty());
|
||||
assert_eq!(cfg.local_v4_port, 0);
|
||||
assert!(cfg.local_v6_port.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gather_returns_candidates_even_with_no_stun() {
|
||||
// With default config (port 0 = no portmap, STUN will timeout
|
||||
// quickly on loopback), gather should still return host candidates.
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![], // no servers = quick failure
|
||||
timeout: Duration::from_millis(100),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(200),
|
||||
local_v4_port: 12345,
|
||||
local_v6_port: None,
|
||||
});
|
||||
|
||||
let candidates = agent.gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
// Reflexive should be None (no STUN servers)
|
||||
assert!(candidates.reflexive.is_none());
|
||||
// Mapped should be None (portmap disabled)
|
||||
assert!(candidates.mapped.is_none());
|
||||
// Local candidates depend on the machine's interfaces
|
||||
// but gather() should not panic.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_gather_produces_signal_message() {
|
||||
let agent = IceAgent::new("call-42".into(), IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![],
|
||||
timeout: Duration::from_millis(50),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(100),
|
||||
local_v4_port: 4433,
|
||||
local_v6_port: None,
|
||||
});
|
||||
|
||||
let (candidates, signal) = agent.re_gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
|
||||
match signal {
|
||||
SignalMessage::CandidateUpdate {
|
||||
call_id,
|
||||
generation,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "call-42");
|
||||
assert_eq!(generation, 0);
|
||||
}
|
||||
_ => panic!("expected CandidateUpdate"),
|
||||
}
|
||||
|
||||
// Second re_gather increments generation
|
||||
let (candidates2, signal2) = agent.re_gather().await;
|
||||
assert_eq!(candidates2.generation, 1);
|
||||
match signal2 {
|
||||
SignalMessage::CandidateUpdate { generation, .. } => {
|
||||
assert_eq!(generation, 1);
|
||||
}
|
||||
_ => panic!("expected CandidateUpdate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,12 @@ pub mod featherchat;
|
||||
pub mod handshake;
|
||||
pub mod dual_path;
|
||||
pub mod metrics;
|
||||
pub mod ice_agent;
|
||||
pub mod netcheck;
|
||||
pub mod portmap;
|
||||
pub mod reflect;
|
||||
pub mod relay_map;
|
||||
pub mod stun;
|
||||
pub mod sweep;
|
||||
|
||||
// AudioPlayback: three possible backends depending on feature flags.
|
||||
|
||||
510
crates/wzp-client/src/netcheck.rs
Normal file
510
crates/wzp-client/src/netcheck.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! Phase 8 (Tailscale-inspired): Comprehensive network diagnostic.
|
||||
//!
|
||||
//! Probes STUN servers, relay infrastructure, port mapping
|
||||
//! capabilities, IPv6 reachability, and NAT hairpinning in parallel
|
||||
//! to produce a `NetcheckReport` that captures the client's network
|
||||
//! environment at a point in time.
|
||||
//!
|
||||
//! Used for:
|
||||
//! - Troubleshooting connectivity issues
|
||||
//! - Automatic relay selection (Phase 5)
|
||||
//! - Pre-call NAT assessment
|
||||
//! - Quality prediction
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::portmap::{self, PortMapProtocol};
|
||||
use crate::reflect::{self, NatType};
|
||||
use crate::stun::{self, StunConfig};
|
||||
|
||||
/// Complete network diagnostic report.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NetcheckReport {
|
||||
/// NAT type classification (from combined STUN + relay probes).
|
||||
pub nat_type: NatType,
|
||||
/// Server-reflexive address (consensus from probes).
|
||||
pub reflexive_addr: Option<String>,
|
||||
/// Whether IPv4 connectivity is available.
|
||||
pub ipv4_reachable: bool,
|
||||
/// Whether IPv6 connectivity is available.
|
||||
pub ipv6_reachable: bool,
|
||||
/// Whether the NAT supports hairpinning (loopback to own
|
||||
/// reflexive address).
|
||||
pub hairpin_works: Option<bool>,
|
||||
/// Which port mapping protocol is available (if any).
|
||||
pub port_mapping: Option<PortMapProtocol>,
|
||||
/// Per-relay latency measurements.
|
||||
pub relay_latencies: Vec<RelayLatency>,
|
||||
/// Preferred relay (lowest latency).
|
||||
pub preferred_relay: Option<String>,
|
||||
/// STUN latency to first responding server (ms).
|
||||
pub stun_latency_ms: Option<u32>,
|
||||
/// Whether UPnP is available on the gateway.
|
||||
pub upnp_available: bool,
|
||||
/// Whether PCP is available on the gateway.
|
||||
pub pcp_available: bool,
|
||||
/// Whether NAT-PMP is available on the gateway.
|
||||
pub nat_pmp_available: bool,
|
||||
/// Default gateway address.
|
||||
pub gateway: Option<String>,
|
||||
/// Total time taken for the diagnostic (ms).
|
||||
pub duration_ms: u32,
|
||||
/// Individual STUN probe results.
|
||||
pub stun_probes: Vec<reflect::NatProbeResult>,
|
||||
}
|
||||
|
||||
/// Latency to a specific relay.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayLatency {
|
||||
pub name: String,
|
||||
pub addr: String,
|
||||
pub rtt_ms: Option<u32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for the netcheck run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetcheckConfig {
|
||||
/// STUN servers to probe.
|
||||
pub stun_config: StunConfig,
|
||||
/// Relay servers to probe (name, address pairs).
|
||||
pub relays: Vec<(String, SocketAddr)>,
|
||||
/// Per-probe timeout.
|
||||
pub timeout: Duration,
|
||||
/// Whether to test port mapping.
|
||||
pub test_portmap: bool,
|
||||
/// Whether to test IPv6.
|
||||
pub test_ipv6: bool,
|
||||
/// Local port for port mapping test (0 = skip).
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
impl Default for NetcheckConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stun_config: StunConfig::default(),
|
||||
relays: Vec::new(),
|
||||
timeout: Duration::from_secs(5),
|
||||
test_portmap: true,
|
||||
test_ipv6: true,
|
||||
local_port: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a comprehensive network diagnostic.
|
||||
///
|
||||
/// Probes run in parallel for speed — the total time is bounded
|
||||
/// by the slowest individual probe, not the sum.
|
||||
pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
||||
let start = Instant::now();
|
||||
|
||||
// Run all probes in parallel.
|
||||
let stun_fut = stun::probe_stun_servers(&config.stun_config);
|
||||
let relay_fut = probe_relays(&config.relays, config.timeout);
|
||||
let portmap_fut = probe_portmap(config.test_portmap, config.local_port);
|
||||
let gateway_fut = portmap::default_gateway();
|
||||
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
|
||||
|
||||
let (stun_probes, relay_latencies, portmap_result, gateway_result, ipv6_reachable) =
|
||||
tokio::join!(stun_fut, relay_fut, portmap_fut, gateway_result_fut(gateway_fut), ipv6_fut);
|
||||
|
||||
// Classify NAT from STUN probes.
|
||||
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
|
||||
|
||||
// Determine STUN latency (first successful probe).
|
||||
let stun_latency_ms = stun_probes
|
||||
.iter()
|
||||
.filter_map(|p| p.latency_ms)
|
||||
.min();
|
||||
|
||||
// IPv4 reachable if any STUN probe succeeded.
|
||||
let ipv4_reachable = stun_probes
|
||||
.iter()
|
||||
.any(|p| p.observed_addr.is_some());
|
||||
|
||||
// Preferred relay = lowest RTT.
|
||||
let preferred_relay = relay_latencies
|
||||
.iter()
|
||||
.filter_map(|r| r.rtt_ms.map(|rtt| (r.name.clone(), rtt)))
|
||||
.min_by_key(|(_, rtt)| *rtt)
|
||||
.map(|(name, _)| name);
|
||||
|
||||
// Port mapping availability.
|
||||
let (port_mapping, nat_pmp_available, pcp_available, upnp_available) = match portmap_result {
|
||||
Some(mapping) => {
|
||||
let proto = mapping.protocol;
|
||||
(
|
||||
Some(proto),
|
||||
proto == PortMapProtocol::NatPmp,
|
||||
proto == PortMapProtocol::Pcp,
|
||||
proto == PortMapProtocol::UPnP,
|
||||
)
|
||||
}
|
||||
None => (None, false, false, false),
|
||||
};
|
||||
|
||||
let gateway = match gateway_result {
|
||||
Ok(gw) => Some(gw.to_string()),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
NetcheckReport {
|
||||
nat_type,
|
||||
reflexive_addr: consensus_addr,
|
||||
ipv4_reachable,
|
||||
ipv6_reachable,
|
||||
hairpin_works: None, // TODO: implement hairpin test
|
||||
port_mapping,
|
||||
relay_latencies,
|
||||
preferred_relay,
|
||||
stun_latency_ms,
|
||||
upnp_available,
|
||||
pcp_available,
|
||||
nat_pmp_available,
|
||||
gateway,
|
||||
duration_ms: start.elapsed().as_millis() as u32,
|
||||
stun_probes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe relay latencies via reflect.
|
||||
async fn probe_relays(
|
||||
relays: &[(String, SocketAddr)],
|
||||
timeout: Duration,
|
||||
) -> Vec<RelayLatency> {
|
||||
if relays.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let timeout_ms = timeout.as_millis() as u64;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
|
||||
for (name, addr) in relays {
|
||||
let name = name.clone();
|
||||
let addr = *addr;
|
||||
set.spawn(async move {
|
||||
let start = Instant::now();
|
||||
match reflect::probe_reflect_addr(addr, timeout_ms, None).await {
|
||||
Ok((_observed, _latency)) => RelayLatency {
|
||||
name,
|
||||
addr: addr.to_string(),
|
||||
rtt_ms: Some(start.elapsed().as_millis() as u32),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => RelayLatency {
|
||||
name,
|
||||
addr: addr.to_string(),
|
||||
rtt_ms: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(relays.len());
|
||||
while let Some(join_result) = set.join_next().await {
|
||||
match join_result {
|
||||
Ok(r) => results.push(r),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by RTT (lowest first).
|
||||
results.sort_by_key(|r| r.rtt_ms.unwrap_or(u32::MAX));
|
||||
results
|
||||
}
|
||||
|
||||
/// Attempt port mapping and return the mapping if successful.
|
||||
async fn probe_portmap(
|
||||
enabled: bool,
|
||||
local_port: u16,
|
||||
) -> Option<portmap::PortMapping> {
|
||||
if !enabled || local_port == 0 {
|
||||
return None;
|
||||
}
|
||||
portmap::acquire_port_mapping(local_port, None).await.ok()
|
||||
}
|
||||
|
||||
/// Wrap the gateway future to handle the Result.
|
||||
async fn gateway_result_fut(
|
||||
fut: impl std::future::Future<Output = Result<std::net::Ipv4Addr, portmap::PortMapError>>,
|
||||
) -> Result<std::net::Ipv4Addr, portmap::PortMapError> {
|
||||
fut.await
|
||||
}
|
||||
|
||||
/// Test IPv6 connectivity by attempting to bind and send on an IPv6 socket.
|
||||
async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
|
||||
if !enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to resolve and connect to an IPv6 STUN server.
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
|
||||
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
|
||||
// and we can send a packet, IPv6 is working.
|
||||
let addr = stun::resolve_stun_server("stun.l.google.com:19302").await.ok()?;
|
||||
if addr.is_ipv6() {
|
||||
sock.send_to(&[0u8; 1], addr).await.ok()?;
|
||||
Some(true)
|
||||
} else {
|
||||
// Server resolved to IPv4 — try binding to [::] at least
|
||||
Some(false)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(true)) => true,
|
||||
_ => {
|
||||
// Fallback: can we at least bind an IPv6 socket?
|
||||
tokio::net::UdpSocket::bind("[::]:0").await.is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a netcheck report as a human-readable string.
|
||||
pub fn format_report(report: &NetcheckReport) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
|
||||
out.push_str(&format!(
|
||||
"NAT Type: {:?}\n",
|
||||
report.nat_type
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Reflexive Addr: {}\n",
|
||||
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"IPv4: {}\n",
|
||||
if report.ipv4_reachable { "yes" } else { "no" }
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"IPv6: {}\n",
|
||||
if report.ipv6_reachable { "yes" } else { "no" }
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Gateway: {}\n",
|
||||
report.gateway.as_deref().unwrap_or("(unknown)")
|
||||
));
|
||||
|
||||
out.push_str(&format!("\n--- Port Mapping ---\n"));
|
||||
out.push_str(&format!(
|
||||
"NAT-PMP: {} PCP: {} UPnP: {}\n",
|
||||
if report.nat_pmp_available { "yes" } else { "no" },
|
||||
if report.pcp_available { "yes" } else { "no" },
|
||||
if report.upnp_available { "yes" } else { "no" },
|
||||
));
|
||||
if let Some(proto) = &report.port_mapping {
|
||||
out.push_str(&format!("Active mapping: {:?}\n", proto));
|
||||
}
|
||||
|
||||
if !report.stun_probes.is_empty() {
|
||||
out.push_str(&format!("\n--- STUN Probes ---\n"));
|
||||
for p in &report.stun_probes {
|
||||
out.push_str(&format!(
|
||||
" {} → {} ({}ms){}\n",
|
||||
p.relay_name,
|
||||
p.observed_addr.as_deref().unwrap_or("failed"),
|
||||
p.latency_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
||||
p.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !report.relay_latencies.is_empty() {
|
||||
out.push_str(&format!("\n--- Relay Latencies ---\n"));
|
||||
for r in &report.relay_latencies {
|
||||
out.push_str(&format!(
|
||||
" {} ({}) → {}ms{}\n",
|
||||
r.name,
|
||||
r.addr,
|
||||
r.rtt_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
||||
r.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if let Some(ref pref) = report.preferred_relay {
|
||||
out.push_str(&format!(" Preferred: {pref}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(&format!("\nCompleted in {}ms\n", report.duration_ms));
|
||||
out
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_stun_servers() {
|
||||
let config = NetcheckConfig::default();
|
||||
assert!(!config.stun_config.servers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_produces_output() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Cone,
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: None,
|
||||
relay_latencies: vec![RelayLatency {
|
||||
name: "relay-1".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(25),
|
||||
error: None,
|
||||
}],
|
||||
preferred_relay: Some("relay-1".into()),
|
||||
stun_latency_ms: Some(15),
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: Some("192.168.1.1".into()),
|
||||
duration_ms: 1500,
|
||||
stun_probes: vec![],
|
||||
};
|
||||
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("Cone"));
|
||||
assert!(text.contains("203.0.113.5:4433"));
|
||||
assert!(text.contains("relay-1"));
|
||||
assert!(text.contains("1500ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_serializes_to_json() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Cone,
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: Some(PortMapProtocol::NatPmp),
|
||||
relay_latencies: vec![],
|
||||
preferred_relay: None,
|
||||
stun_latency_ms: Some(25),
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: true,
|
||||
gateway: Some("192.168.1.1".into()),
|
||||
duration_ms: 500,
|
||||
stun_probes: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&report).unwrap();
|
||||
assert!(json.contains("Cone"));
|
||||
assert!(json.contains("203.0.113.5:4433"));
|
||||
assert!(json.contains("NatPmp"));
|
||||
|
||||
// Roundtrip
|
||||
let decoded: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded["ipv4_reachable"], true);
|
||||
assert_eq!(decoded["ipv6_reachable"], false);
|
||||
assert_eq!(decoded["stun_latency_ms"], 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_latency_serializes() {
|
||||
let lat = RelayLatency {
|
||||
name: "eu-west".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(42),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&lat).unwrap();
|
||||
assert!(json.contains("eu-west"));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_empty_relays() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Unknown,
|
||||
reflexive_addr: None,
|
||||
ipv4_reachable: false,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: None,
|
||||
relay_latencies: vec![],
|
||||
preferred_relay: None,
|
||||
stun_latency_ms: None,
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: None,
|
||||
duration_ms: 100,
|
||||
stun_probes: vec![],
|
||||
};
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("Unknown"));
|
||||
assert!(text.contains("(unknown)")); // reflexive addr
|
||||
assert!(text.contains("100ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_with_stun_probes() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::SymmetricPort,
|
||||
reflexive_addr: None,
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: true,
|
||||
hairpin_works: Some(false),
|
||||
port_mapping: Some(PortMapProtocol::UPnP),
|
||||
relay_latencies: vec![
|
||||
RelayLatency {
|
||||
name: "us-east".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(15),
|
||||
error: None,
|
||||
},
|
||||
RelayLatency {
|
||||
name: "eu-west".into(),
|
||||
addr: "10.0.0.2:4433".into(),
|
||||
rtt_ms: None,
|
||||
error: Some("timeout".into()),
|
||||
},
|
||||
],
|
||||
preferred_relay: Some("us-east".into()),
|
||||
stun_latency_ms: Some(20),
|
||||
upnp_available: true,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: Some("192.168.0.1".into()),
|
||||
duration_ms: 3000,
|
||||
stun_probes: vec![reflect::NatProbeResult {
|
||||
relay_name: "stun:google".into(),
|
||||
relay_addr: "74.125.250.129:19302".into(),
|
||||
observed_addr: Some("203.0.113.5:12345".into()),
|
||||
latency_ms: Some(20),
|
||||
error: None,
|
||||
}],
|
||||
};
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("SymmetricPort"));
|
||||
assert!(text.contains("us-east"));
|
||||
assert!(text.contains("eu-west"));
|
||||
assert!(text.contains("Preferred: us-east"));
|
||||
assert!(text.contains("UPnP: yes"));
|
||||
assert!(text.contains("stun:google"));
|
||||
assert!(text.contains("3000ms"));
|
||||
}
|
||||
|
||||
/// Integration test: run actual netcheck (requires network).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn integration_netcheck() {
|
||||
let config = NetcheckConfig::default();
|
||||
let report = run_netcheck(&config).await;
|
||||
println!("{}", format_report(&report));
|
||||
assert!(report.duration_ms > 0);
|
||||
}
|
||||
}
|
||||
1163
crates/wzp-client/src/portmap.rs
Normal file
1163
crates/wzp-client/src/portmap.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -473,6 +473,40 @@ pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced NAT detection that combines relay-based reflection with
|
||||
/// public STUN server probes for more robust classification.
|
||||
///
|
||||
/// Runs both probe sets concurrently:
|
||||
/// 1. Relay probes via `detect_nat_type` (existing behavior)
|
||||
/// 2. Public STUN probes via `probe_stun_servers`
|
||||
///
|
||||
/// Merges all results and classifies. More probes = higher confidence
|
||||
/// in the NAT type classification. Falls back gracefully: if STUN
|
||||
/// servers are unreachable, relay probes still work (and vice versa).
|
||||
pub async fn detect_nat_type_with_stun(
|
||||
relays: Vec<(String, SocketAddr)>,
|
||||
timeout_ms: u64,
|
||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
||||
stun_config: &crate::stun::StunConfig,
|
||||
) -> NatDetection {
|
||||
// Run relay probes and STUN probes concurrently.
|
||||
let relay_fut = detect_nat_type(relays, timeout_ms, shared_endpoint);
|
||||
let stun_fut = crate::stun::probe_stun_servers(stun_config);
|
||||
|
||||
let (relay_detection, stun_probes) = tokio::join!(relay_fut, stun_fut);
|
||||
|
||||
// Merge all probes and re-classify.
|
||||
let mut all_probes = relay_detection.probes;
|
||||
all_probes.extend(stun_probes);
|
||||
|
||||
let (nat_type, consensus_addr) = classify_nat(&all_probes);
|
||||
NatDetection {
|
||||
probes: all_probes,
|
||||
nat_type,
|
||||
consensus_addr,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests for the pure classifier ───────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
339
crates/wzp-client/src/relay_map.rs
Normal file
339
crates/wzp-client/src/relay_map.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
//! Phase 8 (Tailscale-inspired): Relay map for automatic relay
|
||||
//! selection based on latency.
|
||||
//!
|
||||
//! Maintains a sorted list of known relays with their measured
|
||||
//! latencies. Used during call setup to pick the lowest-latency
|
||||
//! relay, and by netcheck to report relay health.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// A known relay endpoint with measured latency.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayEntry {
|
||||
/// Human-readable name (e.g., "us-east", "eu-west").
|
||||
pub name: String,
|
||||
/// Relay address.
|
||||
pub addr: SocketAddr,
|
||||
/// Geographic region (from RegisterPresenceAck).
|
||||
pub region: Option<String>,
|
||||
/// Last measured RTT (ms).
|
||||
pub rtt_ms: Option<u32>,
|
||||
/// When the RTT was last measured.
|
||||
#[serde(skip)]
|
||||
pub last_probed: Option<Instant>,
|
||||
/// Whether this relay is currently reachable.
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
/// Sorted relay map. Entries are ordered by RTT (lowest first).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RelayMap {
|
||||
entries: Vec<RelayEntry>,
|
||||
}
|
||||
|
||||
impl RelayMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a relay entry.
|
||||
pub fn upsert(&mut self, name: &str, addr: SocketAddr, region: Option<String>) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.name = name.to_string();
|
||||
if region.is_some() {
|
||||
entry.region = region;
|
||||
}
|
||||
} else {
|
||||
self.entries.push(RelayEntry {
|
||||
name: name.to_string(),
|
||||
addr,
|
||||
region,
|
||||
rtt_ms: None,
|
||||
last_probed: None,
|
||||
reachable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update RTT measurement for a relay.
|
||||
pub fn update_rtt(&mut self, addr: SocketAddr, rtt_ms: u32) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.rtt_ms = Some(rtt_ms);
|
||||
entry.last_probed = Some(Instant::now());
|
||||
entry.reachable = true;
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Mark a relay as unreachable.
|
||||
pub fn mark_unreachable(&mut self, addr: SocketAddr) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.reachable = false;
|
||||
entry.last_probed = Some(Instant::now());
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Get the preferred (lowest-latency, reachable) relay.
|
||||
pub fn preferred(&self) -> Option<&RelayEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|e| e.reachable && e.rtt_ms.is_some())
|
||||
}
|
||||
|
||||
/// Get all entries, sorted by RTT.
|
||||
pub fn entries(&self) -> &[RelayEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Populate from a `RegisterPresenceAck.available_relays` list.
|
||||
/// Each entry is "name|addr" format.
|
||||
pub fn populate_from_ack(&mut self, relays: &[String], relay_region: Option<&str>) {
|
||||
for entry_str in relays {
|
||||
if let Some((name, addr_str)) = entry_str.split_once('|') {
|
||||
if let Ok(addr) = addr_str.parse::<SocketAddr>() {
|
||||
self.upsert(name, addr, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the ack included a region for the current relay, we
|
||||
// could tag it — but we'd need to know which relay we're
|
||||
// connected to. Left for the caller to handle.
|
||||
let _ = relay_region;
|
||||
}
|
||||
|
||||
/// Check if any entry has a stale probe (older than `max_age`).
|
||||
pub fn needs_reprobe(&self, max_age: Duration) -> bool {
|
||||
self.entries.iter().any(|e| {
|
||||
match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get entries that need reprobing.
|
||||
pub fn stale_entries(&self, max_age: Duration) -> Vec<(String, SocketAddr)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
})
|
||||
.map(|e| (e.name.clone(), e.addr))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.entries.sort_by_key(|e| {
|
||||
if e.reachable {
|
||||
e.rtt_ms.unwrap_or(u32::MAX)
|
||||
} else {
|
||||
u32::MAX
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_lowest_rtt() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
let a3: SocketAddr = "10.0.0.3:4433".parse().unwrap();
|
||||
|
||||
map.upsert("slow", a1, None);
|
||||
map.upsert("fast", a2, None);
|
||||
map.upsert("mid", a3, None);
|
||||
|
||||
map.update_rtt(a1, 200);
|
||||
map.update_rtt(a2, 15);
|
||||
map.update_rtt(a3, 80);
|
||||
|
||||
let pref = map.preferred().unwrap();
|
||||
assert_eq!(pref.addr, a2);
|
||||
assert_eq!(pref.rtt_ms, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_not_preferred() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
|
||||
map.upsert("fast-dead", a1, None);
|
||||
map.upsert("slow-alive", a2, None);
|
||||
|
||||
map.update_rtt(a1, 5);
|
||||
map.update_rtt(a2, 200);
|
||||
map.mark_unreachable(a1);
|
||||
|
||||
let pref = map.preferred().unwrap();
|
||||
assert_eq!(pref.addr, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_from_ack() {
|
||||
let mut map = RelayMap::new();
|
||||
map.populate_from_ack(
|
||||
&[
|
||||
"us-east|203.0.113.5:4433".into(),
|
||||
"eu-west|198.51.100.9:4433".into(),
|
||||
],
|
||||
Some("us-east"),
|
||||
);
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
assert_eq!(map.entries()[0].name, "us-east");
|
||||
assert_eq!(map.entries()[1].name, "eu-west");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_existing() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("old-name", addr, None);
|
||||
map.upsert("new-name", addr, Some("us-west".into()));
|
||||
assert_eq!(map.entries().len(), 1);
|
||||
assert_eq!(map.entries()[0].name, "new-name");
|
||||
assert_eq!(map.entries()[0].region, Some("us-west".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_region_when_none() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, Some("eu-west".into()));
|
||||
map.upsert("relay", addr, None); // region is None
|
||||
// Should keep the original region
|
||||
assert_eq!(map.entries()[0].region, Some("eu-west".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_none_on_empty() {
|
||||
let map = RelayMap::new();
|
||||
assert!(map.preferred().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_none_when_all_unreachable() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, None);
|
||||
// Not update_rtt'd, so reachable=false
|
||||
assert!(map.preferred().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_empty_is_false() {
|
||||
let map = RelayMap::new();
|
||||
// No entries → nothing to reprobe
|
||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_never_probed() {
|
||||
let mut map = RelayMap::new();
|
||||
map.upsert("relay", "10.0.0.1:4433".parse().unwrap(), None);
|
||||
assert!(map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_fresh_is_false() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, None);
|
||||
map.update_rtt(addr, 50);
|
||||
// Just probed, so 60s max_age should not trigger
|
||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_entries_returns_unprobed() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("probed", a1, None);
|
||||
map.upsert("stale", a2, None);
|
||||
map.update_rtt(a1, 50);
|
||||
|
||||
let stale = map.stale_entries(Duration::from_secs(60));
|
||||
assert_eq!(stale.len(), 1);
|
||||
assert_eq!(stale[0].1, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_stability_with_equal_rtt() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("first", a1, None);
|
||||
map.upsert("second", a2, None);
|
||||
map.update_rtt(a1, 50);
|
||||
map.update_rtt(a2, 50);
|
||||
|
||||
// Both have same RTT — sort should be stable (insertion order)
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
// Both are valid preferred relays
|
||||
assert!(map.preferred().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_from_ack_skips_malformed() {
|
||||
let mut map = RelayMap::new();
|
||||
map.populate_from_ack(
|
||||
&[
|
||||
"good|10.0.0.1:4433".into(),
|
||||
"no-pipe-separator".into(),
|
||||
"bad-addr|not-a-socket-addr".into(),
|
||||
"also-good|10.0.0.2:4433".into(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_unreachable_sorts_to_end() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("fast", a1, None);
|
||||
map.upsert("slow", a2, None);
|
||||
map.update_rtt(a1, 10);
|
||||
map.update_rtt(a2, 200);
|
||||
|
||||
assert_eq!(map.preferred().unwrap().addr, a1);
|
||||
|
||||
map.mark_unreachable(a1);
|
||||
assert_eq!(map.preferred().unwrap().addr, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_entry_serializes() {
|
||||
let entry = RelayEntry {
|
||||
name: "test".into(),
|
||||
addr: "10.0.0.1:4433".parse().unwrap(),
|
||||
region: Some("us-east".into()),
|
||||
rtt_ms: Some(42),
|
||||
last_probed: Some(Instant::now()),
|
||||
reachable: true,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("test"));
|
||||
assert!(json.contains("us-east"));
|
||||
assert!(json.contains("42"));
|
||||
// last_probed is #[serde(skip)]
|
||||
assert!(!json.contains("last_probed"));
|
||||
}
|
||||
}
|
||||
1070
crates/wzp-client/src/stun.rs
Normal file
1070
crates/wzp-client/src/stun.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,7 @@ async fn dual_path_direct_wins_on_loopback() {
|
||||
PeerCandidates {
|
||||
reflexive: Some(acceptor_listen_addr),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
@@ -156,6 +157,7 @@ async fn dual_path_relay_wins_when_direct_is_dead() {
|
||||
PeerCandidates {
|
||||
reflexive: Some(dead_peer),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
@@ -195,6 +197,7 @@ async fn dual_path_errors_cleanly_when_both_paths_dead() {
|
||||
PeerCandidates {
|
||||
reflexive: Some(dead_peer),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
dead_relay,
|
||||
"test-room".into(),
|
||||
|
||||
@@ -738,6 +738,13 @@ pub enum SignalMessage {
|
||||
/// Relay's build version (git short hash).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
relay_build: Option<String>,
|
||||
/// Phase 8: relay's geographic region (e.g., "us-east", "eu-west").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
relay_region: Option<String>,
|
||||
/// Phase 8: other relays the client can use, sorted by relay
|
||||
/// mesh proximity. Each entry is "name|addr" (e.g., "eu-west|203.0.113.5:4433").
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
available_relays: Vec<String>,
|
||||
},
|
||||
|
||||
/// Direct call offer routed through the relay to a specific peer.
|
||||
@@ -777,6 +784,12 @@ pub enum SignalMessage {
|
||||
/// the same LAN.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
caller_local_addrs: Vec<String>,
|
||||
/// Phase 8 (Tailscale-inspired): caller's port-mapped external
|
||||
/// address from NAT-PMP/PCP/UPnP. When the router supports
|
||||
/// port mapping, this gives a stable external address even
|
||||
/// behind symmetric NATs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_mapped_addr: Option<String>,
|
||||
/// Build version (git short hash) for debugging.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_build_version: Option<String>,
|
||||
@@ -813,6 +826,10 @@ pub enum SignalMessage {
|
||||
/// `callee_reflexive_addr`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
callee_local_addrs: Vec<String>,
|
||||
/// Phase 8 (Tailscale-inspired): callee's port-mapped external
|
||||
/// address from NAT-PMP/PCP/UPnP.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_mapped_addr: Option<String>,
|
||||
/// Build version (git short hash) for debugging.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_build_version: Option<String>,
|
||||
@@ -844,6 +861,11 @@ pub enum SignalMessage {
|
||||
/// Client-side race tries all of these in parallel.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
peer_local_addrs: Vec<String>,
|
||||
/// Phase 8 (Tailscale-inspired): the OTHER party's port-mapped
|
||||
/// external address from NAT-PMP/PCP/UPnP. Added to the
|
||||
/// candidate dial order between host and reflexive addrs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
peer_mapped_addr: Option<String>,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
@@ -899,6 +921,32 @@ pub enum SignalMessage {
|
||||
race_winner: String,
|
||||
},
|
||||
|
||||
// ── Phase 8: mid-call ICE re-gathering ────────────────────────
|
||||
|
||||
/// Phase 8 (Tailscale-inspired): mid-call candidate update sent
|
||||
/// when a client's network changes (WiFi → cellular, IP change,
|
||||
/// etc.). The relay forwards this to the call peer, who can
|
||||
/// re-race with the new candidates to upgrade or maintain the
|
||||
/// direct path.
|
||||
///
|
||||
/// The `generation` counter is monotonically increasing per call
|
||||
/// — peers ignore updates with a generation <= their last-seen
|
||||
/// generation to handle reordering.
|
||||
CandidateUpdate {
|
||||
call_id: String,
|
||||
/// New server-reflexive address (STUN-discovered or relay-reflected).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reflexive_addr: Option<String>,
|
||||
/// New LAN host addresses.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
local_addrs: Vec<String>,
|
||||
/// New port-mapped address (NAT-PMP/PCP/UPnP).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mapped_addr: Option<String>,
|
||||
/// Monotonic generation counter.
|
||||
generation: u32,
|
||||
},
|
||||
|
||||
// ── Phase 4: cross-relay direct-call signaling ────────────────────
|
||||
|
||||
/// Phase 4: relay-to-relay envelope for forwarding direct-call
|
||||
@@ -1147,6 +1195,7 @@ mod tests {
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
};
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
@@ -1190,6 +1239,7 @@ mod tests {
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
},
|
||||
SignalMessage::CallRinging { call_id: "c1".into() },
|
||||
@@ -1226,6 +1276,7 @@ mod tests {
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json = serde_json::to_string(&offer).unwrap();
|
||||
@@ -1255,6 +1306,7 @@ mod tests {
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json_none = serde_json::to_string(&offer_none).unwrap();
|
||||
@@ -1273,6 +1325,7 @@ mod tests {
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
@@ -1294,6 +1347,7 @@ mod tests {
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: Some("192.0.2.1:4433".into()),
|
||||
peer_local_addrs: Vec::new(),
|
||||
peer_mapped_addr: None,
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
||||
@@ -1763,4 +1817,271 @@ mod tests {
|
||||
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL when disabled");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 8: Tailscale-inspired signal roundtrip tests ──────
|
||||
|
||||
#[test]
|
||||
fn candidate_update_roundtrip() {
|
||||
let msg = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-123".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec![
|
||||
"192.168.1.10:4433".into(),
|
||||
"10.0.0.5:4433".into(),
|
||||
],
|
||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
||||
generation: 7,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CandidateUpdate {
|
||||
call_id,
|
||||
reflexive_addr,
|
||||
local_addrs,
|
||||
mapped_addr,
|
||||
generation,
|
||||
} => {
|
||||
assert_eq!(call_id, "test-123");
|
||||
assert_eq!(reflexive_addr.as_deref(), Some("203.0.113.5:4433"));
|
||||
assert_eq!(local_addrs.len(), 2);
|
||||
assert_eq!(mapped_addr.as_deref(), Some("198.51.100.42:12345"));
|
||||
assert_eq!(generation, 7);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_update_minimal_roundtrip() {
|
||||
let msg = SignalMessage::CandidateUpdate {
|
||||
call_id: "c".into(),
|
||||
reflexive_addr: None,
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
// skip_serializing_if should omit None/empty fields
|
||||
assert!(!json.contains("reflexive_addr"));
|
||||
assert!(!json.contains("local_addrs"));
|
||||
assert!(!json.contains("mapped_addr"));
|
||||
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CandidateUpdate { generation, .. } => {
|
||||
assert_eq!(generation, 0);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offer_with_mapped_addr_roundtrip() {
|
||||
let msg = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("1.2.3.4:5".into()),
|
||||
caller_local_addrs: vec!["10.0.0.1:5".into()],
|
||||
caller_mapped_addr: Some("5.6.7.8:9999".into()),
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("caller_mapped_addr"));
|
||||
assert!(json.contains("5.6.7.8:9999"));
|
||||
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_mapped_addr, ..
|
||||
} => {
|
||||
assert_eq!(caller_mapped_addr.as_deref(), Some("5.6.7.8:9999"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offer_without_mapped_addr_omits_field() {
|
||||
let msg = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: vec![],
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(!json.contains("caller_mapped_addr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_with_mapped_addr_roundtrip() {
|
||||
let msg = SignalMessage::DirectCallAnswer {
|
||||
call_id: "c1".into(),
|
||||
accept_mode: CallAcceptMode::AcceptTrusted,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("1.2.3.4:5".into()),
|
||||
callee_local_addrs: vec![],
|
||||
callee_mapped_addr: Some("9.8.7.6:1111".into()),
|
||||
callee_build_version: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
callee_mapped_addr, ..
|
||||
} => {
|
||||
assert_eq!(callee_mapped_addr.as_deref(), Some("9.8.7.6:1111"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_with_mapped_addr_roundtrip() {
|
||||
let msg = SignalMessage::CallSetup {
|
||||
call_id: "c1".into(),
|
||||
room: "room".into(),
|
||||
relay_addr: "1.2.3.4:5".into(),
|
||||
peer_direct_addr: Some("5.6.7.8:9".into()),
|
||||
peer_local_addrs: vec!["10.0.0.1:9".into()],
|
||||
peer_mapped_addr: Some("11.12.13.14:15".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("peer_mapped_addr"));
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup {
|
||||
peer_mapped_addr, ..
|
||||
} => {
|
||||
assert_eq!(peer_mapped_addr.as_deref(), Some("11.12.13.14:15"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_offer_without_mapped_addr_parses() {
|
||||
// Old client JSON that doesn't have caller_mapped_addr at all
|
||||
let json = r#"{
|
||||
"DirectCallOffer": {
|
||||
"caller_fingerprint": "alice",
|
||||
"target_fingerprint": "bob",
|
||||
"call_id": "c1",
|
||||
"identity_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"ephemeral_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"signature": [],
|
||||
"supported_profiles": [],
|
||||
"caller_reflexive_addr": "1.2.3.4:5"
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_mapped_addr,
|
||||
caller_reflexive_addr,
|
||||
..
|
||||
} => {
|
||||
assert!(caller_mapped_addr.is_none());
|
||||
assert_eq!(caller_reflexive_addr.as_deref(), Some("1.2.3.4:5"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_setup_without_mapped_addr_parses() {
|
||||
let json = r#"{
|
||||
"CallSetup": {
|
||||
"call_id": "c1",
|
||||
"room": "room",
|
||||
"relay_addr": "1.2.3.4:5",
|
||||
"peer_direct_addr": "5.6.7.8:9"
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup {
|
||||
peer_mapped_addr,
|
||||
peer_direct_addr,
|
||||
..
|
||||
} => {
|
||||
assert!(peer_mapped_addr.is_none());
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some("5.6.7.8:9"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_presence_ack_with_new_fields_roundtrip() {
|
||||
let msg = SignalMessage::RegisterPresenceAck {
|
||||
success: true,
|
||||
error: None,
|
||||
relay_build: Some("abc123".into()),
|
||||
relay_region: Some("us-east".into()),
|
||||
available_relays: vec![
|
||||
"eu-west|10.0.0.1:4433".into(),
|
||||
"ap-south|10.0.0.2:4433".into(),
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("relay_region"));
|
||||
assert!(json.contains("us-east"));
|
||||
assert!(json.contains("available_relays"));
|
||||
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::RegisterPresenceAck {
|
||||
relay_region,
|
||||
available_relays,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(relay_region.as_deref(), Some("us-east"));
|
||||
assert_eq!(available_relays.len(), 2);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_presence_ack_backward_compat() {
|
||||
// Old relay JSON without relay_region or available_relays
|
||||
let json = r#"{
|
||||
"RegisterPresenceAck": {
|
||||
"success": true,
|
||||
"relay_build": "old-build"
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::RegisterPresenceAck {
|
||||
relay_region,
|
||||
available_relays,
|
||||
relay_build,
|
||||
..
|
||||
} => {
|
||||
assert!(relay_region.is_none());
|
||||
assert!(available_relays.is_empty());
|
||||
assert_eq!(relay_build.as_deref(), Some("old-build"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ pub struct DirectCall {
|
||||
/// 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.
|
||||
@@ -92,6 +99,8 @@ impl CallRegistry {
|
||||
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()
|
||||
@@ -142,6 +151,22 @@ impl CallRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -340,6 +365,49 @@ mod tests {
|
||||
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
|
||||
|
||||
@@ -87,6 +87,14 @@ pub struct RelayConfig {
|
||||
/// Unlike [[peers]], no url is needed — the peer connects to us.
|
||||
#[serde(default)]
|
||||
pub trusted: Vec<TrustedConfig>,
|
||||
/// Phase 8: geographic region identifier (e.g., "us-east", "eu-west").
|
||||
/// Sent to clients in `RegisterPresenceAck.relay_region` so they can
|
||||
/// build a relay map for automatic selection.
|
||||
pub region: Option<String>,
|
||||
/// Phase 8: externally-advertised address for this relay. Used to
|
||||
/// populate `available_relays` in `RegisterPresenceAck`. If not set,
|
||||
/// `listen_addr` is used.
|
||||
pub advertised_addr: Option<SocketAddr>,
|
||||
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
|
||||
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
|
||||
pub debug_tap: Option<String>,
|
||||
@@ -114,6 +122,8 @@ impl Default for RelayConfig {
|
||||
peers: Vec::new(),
|
||||
global_rooms: Vec::new(),
|
||||
trusted: Vec::new(),
|
||||
region: None,
|
||||
advertised_addr: None,
|
||||
debug_tap: None,
|
||||
event_log: None,
|
||||
}
|
||||
|
||||
@@ -538,6 +538,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
ref caller_mapped_addr,
|
||||
..
|
||||
} => {
|
||||
// Is the target on THIS relay? If not, drop —
|
||||
@@ -557,7 +558,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. Include
|
||||
// Phase 5.5 LAN host candidates too.
|
||||
// Phase 5.5 LAN host candidates + Phase 8
|
||||
// port-mapped addr.
|
||||
{
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.create_call(
|
||||
@@ -567,6 +569,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
|
||||
reg.set_caller_local_addrs(call_id, caller_local_addrs.clone());
|
||||
reg.set_caller_mapped_addr(call_id, caller_mapped_addr.clone());
|
||||
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
|
||||
}
|
||||
// Deliver the offer to the local target.
|
||||
@@ -585,6 +588,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
ref callee_mapped_addr,
|
||||
..
|
||||
} => {
|
||||
// Look up the local caller fp from the registry.
|
||||
@@ -616,14 +620,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Also set peer_relay_fp so the originating
|
||||
// relay knows where to forward MediaPathReport.
|
||||
// host candidates + mapped addr + mark the call
|
||||
// active, then read back everything needed to
|
||||
// cross-wire into the local CallSetup.
|
||||
let room_name = format!("call-{call_id}");
|
||||
let (callee_addr_for_setup, callee_local_for_setup) = {
|
||||
let (callee_addr_for_setup, callee_local_for_setup, callee_mapped_for_setup) = {
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.set_active(call_id, accept_mode, room_name.clone());
|
||||
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
|
||||
@@ -632,10 +633,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
callee_reflexive_addr.clone(),
|
||||
);
|
||||
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
|
||||
reg.set_callee_mapped_addr(call_id, callee_mapped_addr.clone());
|
||||
let c = reg.get(call_id);
|
||||
(
|
||||
c.and_then(|c| c.callee_reflexive_addr.clone()),
|
||||
c.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
|
||||
c.and_then(|c| c.callee_mapped_addr.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -648,19 +651,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Emit the LOCAL CallSetup to our local caller.
|
||||
// relay_addr = our own advertised addr so if P2P
|
||||
// fails the caller will at least dial OUR relay
|
||||
// (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. 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,
|
||||
peer_mapped_addr: callee_mapped_for_setup,
|
||||
};
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub.send_to(&caller_fp, &setup).await;
|
||||
@@ -772,6 +769,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
let signal_hub = signal_hub.clone();
|
||||
let call_registry = call_registry.clone();
|
||||
let advertised_addr_str = advertised_addr_str.clone();
|
||||
// Phase 8: relay region + peer addresses for RegisterPresenceAck
|
||||
let relay_region = config.region.clone();
|
||||
let relay_peers_for_ack: Vec<String> = config.peers.iter()
|
||||
.filter_map(|p| {
|
||||
let label = p.label.as_deref().unwrap_or("peer");
|
||||
Some(format!("{label}|{}", p.url))
|
||||
})
|
||||
.collect();
|
||||
// Phase 4: per-task clone of this relay's federation TLS
|
||||
// fingerprint so the FederatedSignalForward envelopes the
|
||||
// spawned signal handler builds carry `origin_relay_fp`.
|
||||
@@ -1005,6 +1010,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
success: true,
|
||||
error: None,
|
||||
relay_build: Some(BUILD_GIT_HASH.to_string()),
|
||||
relay_region: relay_region.clone(),
|
||||
available_relays: relay_peers_for_ack.clone(),
|
||||
}).await;
|
||||
|
||||
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
|
||||
@@ -1019,12 +1026,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
ref caller_mapped_addr,
|
||||
..
|
||||
} => {
|
||||
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();
|
||||
let caller_mapped_for_registry = caller_mapped_addr.clone();
|
||||
|
||||
// Check if target is online
|
||||
let online = {
|
||||
@@ -1097,6 +1106,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
&call_id,
|
||||
caller_local_for_registry.clone(),
|
||||
);
|
||||
reg.set_caller_mapped_addr(
|
||||
&call_id,
|
||||
caller_mapped_for_registry.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Send ringing to caller immediately
|
||||
@@ -1118,6 +1131,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
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);
|
||||
reg.set_caller_mapped_addr(&call_id, caller_mapped_for_registry);
|
||||
}
|
||||
|
||||
// Forward offer to callee
|
||||
@@ -1139,12 +1153,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
ref accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
ref callee_mapped_addr,
|
||||
..
|
||||
} => {
|
||||
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();
|
||||
let callee_mapped_for_registry = callee_mapped_addr.clone();
|
||||
|
||||
// Phase 4: look up peer fingerprint AND
|
||||
// peer_relay_fp in one lock acquisition.
|
||||
@@ -1207,17 +1223,20 @@ 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, caller_local, callee_local) = {
|
||||
let (caller_addr, callee_addr, caller_local, callee_local, caller_mapped, callee_mapped) = {
|
||||
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());
|
||||
reg.set_callee_mapped_addr(&call_id, callee_mapped_for_registry);
|
||||
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(),
|
||||
call.and_then(|c| c.caller_mapped_addr.clone()),
|
||||
call.and_then(|c| c.callee_mapped_addr.clone()),
|
||||
)
|
||||
};
|
||||
info!(
|
||||
@@ -1266,6 +1285,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
peer_mapped_addr: caller_mapped.clone(),
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
|
||||
@@ -1278,14 +1298,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Send CallSetup to BOTH parties with
|
||||
// cross-wired peer_direct_addr +
|
||||
// peer_local_addrs (Phase 5.5 ICE).
|
||||
// cross-wired candidates (Phase 5.5 ICE
|
||||
// + Phase 8 port-mapped addrs).
|
||||
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(),
|
||||
peer_mapped_addr: callee_mapped,
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
@@ -1293,6 +1314,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
peer_mapped_addr: caller_mapped,
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
|
||||
@@ -1382,6 +1404,45 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 8: forward CandidateUpdate to the
|
||||
// call peer for mid-call ICE re-gathering.
|
||||
// Same forwarding pattern as MediaPathReport.
|
||||
SignalMessage::CandidateUpdate { ref call_id, .. } => {
|
||||
let (peer_fp, peer_relay_fp) = {
|
||||
let reg = call_registry.lock().await;
|
||||
match reg.get(call_id) {
|
||||
Some(c) => (
|
||||
reg.peer_fingerprint(call_id, &client_fp)
|
||||
.map(|s| s.to_string()),
|
||||
c.peer_relay_fp.clone(),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(fp) = peer_fp {
|
||||
if let Some(ref origin_fp) = peer_relay_fp {
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(msg.clone()),
|
||||
origin_relay_fp: tls_fp.clone(),
|
||||
};
|
||||
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
|
||||
warn!(
|
||||
%call_id,
|
||||
%origin_fp,
|
||||
error = %e,
|
||||
"cross-relay CandidateUpdate forward failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&fp, &msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Ping { timestamp_ms } => {
|
||||
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn alice_offer(call_id: &str) -> SignalMessage {
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some(ALICE_ADDR.into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
}
|
||||
}
|
||||
@@ -133,6 +134,7 @@ fn bob_answer(call_id: &str) -> SignalMessage {
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some(BOB_ADDR.into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
}
|
||||
}
|
||||
@@ -178,6 +180,7 @@ fn relay_b_handle_local_answer(
|
||||
relay_addr: RELAY_B_ADDR.into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
peer_mapped_addr: None,
|
||||
};
|
||||
let _ = callee_addr;
|
||||
(forward, setup_for_bob)
|
||||
@@ -219,6 +222,7 @@ fn relay_a_handle_forwarded_answer(
|
||||
relay_addr: RELAY_A_ADDR.into(),
|
||||
peer_direct_addr: callee_reflexive_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
peer_mapped_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ fn handle_answer_and_build_setups(
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: callee_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
peer_mapped_addr: None,
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id,
|
||||
@@ -89,6 +90,7 @@ fn handle_answer_and_build_setups(
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
peer_mapped_addr: None,
|
||||
};
|
||||
(setup_for_caller, setup_for_callee)
|
||||
}
|
||||
@@ -105,6 +107,7 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
}
|
||||
}
|
||||
@@ -123,6 +126,7 @@ fn mk_answer(
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
success: true,
|
||||
error: None,
|
||||
relay_build: None,
|
||||
relay_region: None,
|
||||
available_relays: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::Emitter;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info};
|
||||
|
||||
|
||||
@@ -330,12 +330,16 @@ async fn connect(
|
||||
// Optional so the room-join path (which has no peer addrs)
|
||||
// can omit it entirely — it's only populated on direct calls.
|
||||
peer_local_addrs: Option<Vec<String>>,
|
||||
// Phase 8 (Tailscale-inspired): peer's port-mapped external
|
||||
// address from NAT-PMP/PCP/UPnP, carried in CallSetup.
|
||||
peer_mapped_addr: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
emit_call_debug(&app, "connect:start", serde_json::json!({
|
||||
"relay": relay,
|
||||
"room": room,
|
||||
"peer_direct_addr": peer_direct_addr,
|
||||
"peer_local_addrs": peer_local_addrs,
|
||||
"peer_mapped_addr": peer_mapped_addr,
|
||||
}));
|
||||
let mut engine_lock = state.engine.lock().await;
|
||||
if engine_lock.is_some() {
|
||||
@@ -396,9 +400,14 @@ async fn connect(
|
||||
(Some(r), Some(relay_sockaddr))
|
||||
if peer_addr_parsed.is_some() || !peer_local_parsed.is_empty() =>
|
||||
{
|
||||
// Phase 8: parse peer_mapped_addr from CallSetup
|
||||
let peer_mapped_parsed: Option<std::net::SocketAddr> = peer_mapped_addr
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let candidates = wzp_client::dual_path::PeerCandidates {
|
||||
reflexive: peer_addr_parsed,
|
||||
local: peer_local_parsed.clone(),
|
||||
mapped: peer_mapped_parsed,
|
||||
};
|
||||
tracing::info!(
|
||||
role = ?r,
|
||||
@@ -1149,7 +1158,7 @@ fn do_register_signal(
|
||||
"peer_build": callee_build_version,
|
||||
}));
|
||||
}
|
||||
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, peer_direct_addr, peer_local_addrs })) => {
|
||||
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, peer_direct_addr, peer_local_addrs, peer_mapped_addr })) => {
|
||||
// Phase 3: peer_direct_addr carries the OTHER party's
|
||||
// reflex addr. Phase 5.5: peer_local_addrs carries
|
||||
// their LAN host candidates (usable for same-LAN
|
||||
@@ -1168,6 +1177,7 @@ fn do_register_signal(
|
||||
"relay_addr": relay_addr,
|
||||
"peer_direct_addr": peer_direct_addr,
|
||||
"peer_local_addrs": peer_local_addrs,
|
||||
"peer_mapped_addr": peer_mapped_addr,
|
||||
}));
|
||||
let mut sig = signal_state.lock().await;
|
||||
sig.signal_status = "setup".into();
|
||||
@@ -1180,6 +1190,7 @@ fn do_register_signal(
|
||||
"relay_addr": relay_addr,
|
||||
"peer_direct_addr": peer_direct_addr,
|
||||
"peer_local_addrs": peer_local_addrs,
|
||||
"peer_mapped_addr": peer_mapped_addr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1214,6 +1225,36 @@ fn do_register_signal(
|
||||
let _ = tx.send(direct_ok);
|
||||
}
|
||||
}
|
||||
Ok(Some(SignalMessage::CandidateUpdate { call_id, reflexive_addr, local_addrs, mapped_addr, generation })) => {
|
||||
// Phase 8: peer re-gathered candidates after a
|
||||
// network change. Emit to JS for UI notification
|
||||
// and potential transport re-race.
|
||||
tracing::info!(
|
||||
%call_id,
|
||||
generation,
|
||||
reflexive = ?reflexive_addr,
|
||||
mapped = ?mapped_addr,
|
||||
local_count = local_addrs.len(),
|
||||
"signal: CandidateUpdate from peer"
|
||||
);
|
||||
emit_call_debug(&app_clone, "recv:CandidateUpdate", serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"generation": generation,
|
||||
"reflexive_addr": reflexive_addr,
|
||||
"local_addrs": local_addrs,
|
||||
"mapped_addr": mapped_addr,
|
||||
}));
|
||||
let _ = app_clone.emit("signal-event", serde_json::json!({
|
||||
"type": "candidate_update",
|
||||
"call_id": call_id,
|
||||
"generation": generation,
|
||||
"reflexive_addr": reflexive_addr,
|
||||
"local_addrs": local_addrs,
|
||||
"mapped_addr": mapped_addr,
|
||||
}));
|
||||
// TODO Phase 8: use IceAgent.apply_peer_update() +
|
||||
// race_upgrade() to attempt transport hot-swap
|
||||
}
|
||||
Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => {
|
||||
// "STUN for QUIC" response — the relay told us our
|
||||
// own server-reflexive address. If a Tauri command
|
||||
@@ -1501,6 +1542,35 @@ async fn place_call(
|
||||
"local_addrs": caller_local_addrs,
|
||||
}));
|
||||
|
||||
// Phase 8: attempt port mapping for symmetric NAT traversal.
|
||||
// This is best-effort — if the router doesn't support NAT-PMP/PCP/UPnP,
|
||||
// we fall back to reflexive + host candidates only.
|
||||
let caller_mapped_addr: Option<String> = {
|
||||
let v4_port = state.signal.lock().await.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.local_addr().ok())
|
||||
.map(|la| la.port())
|
||||
.unwrap_or(0);
|
||||
if v4_port > 0 {
|
||||
match wzp_client::portmap::acquire_port_mapping(v4_port, None).await {
|
||||
Ok(mapping) => {
|
||||
let addr = mapping.external_addr.to_string();
|
||||
tracing::info!(%addr, protocol = ?mapping.protocol, "place_call: port mapping acquired");
|
||||
emit_call_debug(&app, "place_call:portmap_ok", serde_json::json!({
|
||||
"addr": addr, "protocol": format!("{:?}", mapping.protocol),
|
||||
}));
|
||||
Some(addr)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "place_call: port mapping unavailable (normal on most networks)");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let sig = state.signal.lock().await;
|
||||
let transport = sig.transport.as_ref().ok_or("not registered")?;
|
||||
let call_id = format!(
|
||||
@@ -1510,7 +1580,7 @@ async fn place_call(
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
tracing::info!(%call_id, %target_fp, reflex = ?own_reflex, "place_call: sending DirectCallOffer");
|
||||
tracing::info!(%call_id, %target_fp, reflex = ?own_reflex, mapped = ?caller_mapped_addr, "place_call: sending DirectCallOffer");
|
||||
transport
|
||||
.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: sig.fingerprint.clone(),
|
||||
@@ -1523,6 +1593,7 @@ async fn place_call(
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
caller_reflexive_addr: own_reflex.clone(),
|
||||
caller_local_addrs: caller_local_addrs.clone(),
|
||||
caller_mapped_addr: caller_mapped_addr.clone(),
|
||||
caller_build_version: Some(GIT_HASH.to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -1625,12 +1696,43 @@ async fn answer_call(
|
||||
"local_addrs": callee_local_addrs,
|
||||
}));
|
||||
|
||||
// Phase 8: attempt port mapping (AcceptTrusted only — privacy mode
|
||||
// keeps the mapped addr hidden too).
|
||||
let callee_mapped_addr: Option<String> =
|
||||
if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
|
||||
let v4_port = state.signal.lock().await.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.local_addr().ok())
|
||||
.map(|la| la.port())
|
||||
.unwrap_or(0);
|
||||
if v4_port > 0 {
|
||||
match wzp_client::portmap::acquire_port_mapping(v4_port, None).await {
|
||||
Ok(mapping) => {
|
||||
tracing::info!(
|
||||
addr = %mapping.external_addr,
|
||||
protocol = ?mapping.protocol,
|
||||
"answer_call: port mapping acquired"
|
||||
);
|
||||
Some(mapping.external_addr.to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "answer_call: port mapping unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let sig = state.signal.lock().await;
|
||||
let transport = sig.transport.as_ref().ok_or_else(|| {
|
||||
tracing::warn!("answer_call: not registered (no transport)");
|
||||
"not registered".to_string()
|
||||
})?;
|
||||
tracing::info!(%call_id, ?accept_mode, reflex = ?own_reflex, "answer_call: sending DirectCallAnswer");
|
||||
tracing::info!(%call_id, ?accept_mode, reflex = ?own_reflex, mapped = ?callee_mapped_addr, "answer_call: sending DirectCallAnswer");
|
||||
transport
|
||||
.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id: call_id.clone(),
|
||||
@@ -1641,6 +1743,7 @@ async fn answer_call(
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
callee_reflexive_addr: own_reflex.clone(),
|
||||
callee_local_addrs: callee_local_addrs.clone(),
|
||||
callee_mapped_addr,
|
||||
callee_build_version: Some(GIT_HASH.to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -1674,6 +1777,12 @@ async fn answer_call(
|
||||
/// unsupported / timed out / transport failed (caller should
|
||||
/// gracefully continue with a relay-only path), or `Err` on
|
||||
/// "not registered" which is a hard precondition failure.
|
||||
///
|
||||
/// Phase 8 (Tailscale-inspired): if relay-based reflection fails,
|
||||
/// falls back to public STUN servers for independent reflexive
|
||||
/// discovery. This handles the case where the relay is overloaded
|
||||
/// or temporarily unreachable for reflect but the call can still
|
||||
/// proceed with STUN-discovered addresses.
|
||||
async fn try_reflect_own_addr(
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -1690,8 +1799,8 @@ async fn try_reflect_own_addr(
|
||||
if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
|
||||
let mut sig = state.signal.lock().await;
|
||||
sig.pending_reflect = None;
|
||||
tracing::warn!(error = %e, "try_reflect_own_addr: send_signal failed, continuing without reflex addr");
|
||||
return Ok(None);
|
||||
tracing::warn!(error = %e, "try_reflect_own_addr: send_signal failed, falling back to STUN");
|
||||
return try_stun_fallback(state).await;
|
||||
}
|
||||
match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
|
||||
Ok(Ok(addr)) => {
|
||||
@@ -1706,13 +1815,42 @@ async fn try_reflect_own_addr(
|
||||
Ok(Some(s))
|
||||
}
|
||||
Ok(Err(_canceled)) => {
|
||||
tracing::warn!("try_reflect_own_addr: oneshot canceled");
|
||||
Ok(None)
|
||||
tracing::warn!("try_reflect_own_addr: oneshot canceled, falling back to STUN");
|
||||
try_stun_fallback(state).await
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
let mut sig = state.signal.lock().await;
|
||||
sig.pending_reflect = None;
|
||||
tracing::warn!("try_reflect_own_addr: 1s timeout (pre-Phase-1 relay?)");
|
||||
tracing::warn!("try_reflect_own_addr: 1s timeout, falling back to STUN");
|
||||
try_stun_fallback(state).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// STUN fallback for reflexive address discovery when relay-based
|
||||
/// reflection fails. Queries public STUN servers independently.
|
||||
async fn try_stun_fallback(
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let stun_config = wzp_client::stun::StunConfig {
|
||||
servers: vec![
|
||||
"stun.l.google.com:19302".into(),
|
||||
"stun1.l.google.com:19302".into(),
|
||||
],
|
||||
timeout: std::time::Duration::from_secs(2),
|
||||
};
|
||||
match wzp_client::stun::discover_reflexive(&stun_config).await {
|
||||
Ok(addr) => {
|
||||
let s = addr.to_string();
|
||||
tracing::info!(addr = %s, "STUN fallback: discovered reflexive address");
|
||||
{
|
||||
let mut sig = state.signal.lock().await;
|
||||
sig.own_reflex_addr = Some(s.clone());
|
||||
}
|
||||
Ok(Some(s))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "STUN fallback also failed, continuing without reflex addr");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -1823,7 +1961,15 @@ async fn detect_nat_type(
|
||||
// 1500ms per probe is generous: a same-host probe is < 10ms,
|
||||
// a cross-continent probe is typically < 300ms, and we want
|
||||
// to tolerate a one-off packet loss during connect.
|
||||
let detection = wzp_client::reflect::detect_nat_type(parsed, 1500, shared_endpoint).await;
|
||||
//
|
||||
// Phase 8 (Tailscale-inspired): also probe public STUN servers
|
||||
// in parallel with relay-based reflection. More probes = higher
|
||||
// confidence in NAT classification. Falls back gracefully if
|
||||
// STUN servers are unreachable.
|
||||
let stun_config = wzp_client::stun::StunConfig::default();
|
||||
let detection = wzp_client::reflect::detect_nat_type_with_stun(
|
||||
parsed, 1500, shared_endpoint, &stun_config,
|
||||
).await;
|
||||
serde_json::to_value(&detection).map_err(|e| format!("serialize: {e}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -1100,3 +1100,82 @@ BT SCO only supports 8/16kHz. When `bt_active=1`, Oboe capture skips `setSampleR
|
||||
### Hangup Signal Fix
|
||||
|
||||
`SignalMessage::Hangup` now carries an optional `call_id` field. The relay uses it to end only the specific call instead of broadcasting to all active calls for the user — preventing a race where a hangup for call 1 kills a newly-placed call 2.
|
||||
|
||||
## Phase 8: Tailscale-Inspired NAT Traversal (2026-04-14)
|
||||
|
||||
Five new modules in `wzp-client` bring NAT traversal capability close to Tailscale's approach:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ wzp-client NAT Traversal Stack │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ stun.rs │ │ portmap.rs │ │ reflect.rs (existing) │ │
|
||||
│ │ RFC 5389 │ │ NAT-PMP │ │ Relay-based STUN │ │
|
||||
│ │ Public │ │ PCP │ │ Multi-relay NAT detect │ │
|
||||
│ │ STUN │ │ UPnP IGD │ │ │ │
|
||||
│ └──────┬──────┘ └──────┬───────┘ └────────────┬─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ ice_agent.rs │ │
|
||||
│ │ Gather / Re- │ │
|
||||
│ │ gather / Apply│ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────┼───────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌───────▼───┐ ┌───▼───┐ ┌───▼──────────┐ │
|
||||
│ │ netcheck │ │ dual_ │ │ relay_map.rs │ │
|
||||
│ │ .rs │ │ path │ │ RTT-sorted │ │
|
||||
│ │ Diagnostic│ │ .rs │ │ relay list │ │
|
||||
│ └───────────┘ │ Race │ └──────────────┘ │
|
||||
│ └───────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Candidate Types
|
||||
|
||||
| Type | Source | Priority | When Used |
|
||||
|------|--------|----------|-----------|
|
||||
| Host | `local_host_candidates()` | 1 (highest) | Same-LAN peers |
|
||||
| Port-mapped | `portmap::acquire_port_mapping()` | 2 | Router supports NAT-PMP/PCP/UPnP |
|
||||
| Server-reflexive | `stun::discover_reflexive()` or relay Reflect | 3 | Cone NAT |
|
||||
| Relay | Relay address (fallback) | 4 (lowest) | Always available |
|
||||
|
||||
### Signal Flow for Mid-Call Re-Gathering
|
||||
|
||||
```
|
||||
Network change (WiFi → cellular)
|
||||
│
|
||||
▼
|
||||
IceAgent::re_gather()
|
||||
├── stun::discover_reflexive()
|
||||
├── portmap::acquire_port_mapping()
|
||||
└── local_host_candidates()
|
||||
│
|
||||
▼
|
||||
SignalMessage::CandidateUpdate { generation: N+1, ... }
|
||||
│
|
||||
▼ (via relay)
|
||||
Peer's IceAgent::apply_peer_update()
|
||||
│
|
||||
▼
|
||||
PeerCandidates { reflexive, local, mapped }
|
||||
│
|
||||
▼
|
||||
dual_path::race() with new candidates (TODO: transport hot-swap)
|
||||
```
|
||||
|
||||
### New SignalMessage Variants & Fields
|
||||
|
||||
| Signal | New Fields | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `DirectCallOffer` | `caller_mapped_addr` | Port-mapped address from NAT-PMP/PCP/UPnP |
|
||||
| `DirectCallAnswer` | `callee_mapped_addr` | Same, callee side |
|
||||
| `CallSetup` | `peer_mapped_addr` | Relay cross-wires mapped addr to peer |
|
||||
| `CandidateUpdate` | (new variant) | Mid-call candidate re-gathering |
|
||||
| `RegisterPresenceAck` | `relay_region`, `available_relays` | Relay mesh metadata for auto-selection |
|
||||
|
||||
All new fields use `#[serde(default, skip_serializing_if)]` for backward compatibility with older clients/relays.
|
||||
|
||||
@@ -105,15 +105,25 @@ Sentinel value `0xFF` means "no change pending". The recv task polls on every re
|
||||
|
||||
~~The Tauri engine doesn't use `AdaptiveQualityController` — quality is resolved once at call start.~~ **Update (2026-04-13):** Desktop now has `AdaptiveQualityController` wired into the recv task with `pending_profile` AtomicU8 bridge. Network monitoring on desktop is now feasible — the blocker was adaptive quality, which is done. Remaining work: platform-specific network change detection (macOS: `SCNetworkReachability` or `NWPathMonitor`; Linux: `netlink` socket).
|
||||
|
||||
### Mid-Call ICE Re-gathering
|
||||
### Mid-Call ICE Re-gathering — PARTIALLY IMPLEMENTED (2026-04-14)
|
||||
|
||||
When the device's IP address changes, ideally we should:
|
||||
1. Re-gather local host candidates (`local_host_candidates()`)
|
||||
2. Re-probe STUN (`probe_reflect_addr()`)
|
||||
3. Send updated candidates to the peer (`CandidateUpdate` signal message)
|
||||
4. Attempt new dual-path race for path upgrade
|
||||
When the device's IP address changes, the system now:
|
||||
1. Re-gather local host candidates (`local_host_candidates()`) ✅
|
||||
2. Re-probe STUN (`stun::discover_reflexive()` + `portmap::acquire_port_mapping()`) ✅
|
||||
3. Send updated candidates to the peer (`CandidateUpdate` signal message) ✅
|
||||
4. Relay forwards `CandidateUpdate` to peer (same pattern as `MediaPathReport`) ✅
|
||||
5. Peer receives and can parse via `IceAgent::apply_peer_update()` ✅
|
||||
6. Attempt new dual-path race for path upgrade — **NOT YET WIRED** (transport hot-swap)
|
||||
|
||||
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready, but the signaling and re-racing logic is not yet implemented.
|
||||
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready.
|
||||
The signaling plane is fully implemented via `IceAgent` + `CandidateUpdate`.
|
||||
Remaining: wire `onIpChanged` → JNI → `pending_ice_regather` AtomicBool → recv task → `ice_agent.re_gather()` → transport swap.
|
||||
|
||||
New modules added in Phase 8 (Tailscale-inspired):
|
||||
- `crates/wzp-client/src/ice_agent.rs` — candidate lifecycle management
|
||||
- `crates/wzp-client/src/stun.rs` — public STUN server probing (independent of relay)
|
||||
- `crates/wzp-client/src/portmap.rs` — NAT-PMP/PCP/UPnP port mapping
|
||||
- `crates/wzp-client/src/netcheck.rs` — comprehensive network diagnostic
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -142,11 +142,17 @@ The existing relay connection carries `IceCandidate` signals. No new infrastruct
|
||||
|-------|-------|--------|--------|
|
||||
| 1 | STUN client + candidate gathering | 2 days | Done |
|
||||
| 2 | QUIC hole punching + identity verification | 3 days | Done |
|
||||
| 3 | Adaptive quality on P2P connection | 2 days | Pending (needs 5-tier classification, task #9) |
|
||||
| 3 | Adaptive quality on P2P connection | 2 days | Done (#23) |
|
||||
| 4 | Hybrid mode (relay + P2P, seamless migration) | 3 days | Done |
|
||||
| 5 | Single-socket Nebula (shared signal+direct endpoint) | 2 days | Done |
|
||||
| 6 | ICE path negotiation + dual-path race | 3 days | Done |
|
||||
| 7 | IPv6 dual-socket | 2 days | Done (but `dual_path.rs` integration tests broken — missing `ipv6_endpoint` arg) |
|
||||
| 8.1 | Public STUN client (RFC 5389) | 1 day | Done |
|
||||
| 8.2 | PCP/PMP/UPnP port mapping | 2 days | Done |
|
||||
| 8.3 | Mid-call ICE re-gathering + CandidateUpdate signal | 2 days | Done (signal plane; transport hot-swap TODO) |
|
||||
| 8.4 | Netcheck diagnostic | 1 day | Done |
|
||||
| 8.5 | Region-based relay selection (data model) | 1 day | Done |
|
||||
| 8.6 | Hard NAT traversal (birthday attack) | — | Deferred |
|
||||
|
||||
## Implementation Status (2026-04-13)
|
||||
|
||||
@@ -162,3 +168,38 @@ P2P adaptive quality (#23) now implemented:
|
||||
- Both peers self-observe network quality from QUIC path stats
|
||||
- Quality reports generated every ~1s and attached to outgoing packets
|
||||
- AdaptiveQualityController drives codec switching on both P2P and relay calls
|
||||
|
||||
## Update (2026-04-14): Phase 8 — Tailscale-Inspired Enhancements
|
||||
|
||||
Added 5 new modules to bring NAT traversal capability close to Tailscale's:
|
||||
|
||||
### Phase 8.1: Public STUN Client (Done)
|
||||
- `stun.rs`: RFC 5389 Binding Request/Response over raw UDP
|
||||
- Independent reflexive discovery via public STUN servers (Google, Cloudflare)
|
||||
- `detect_nat_type_with_stun()` combines relay + STUN probes for higher confidence
|
||||
- STUN fallback in desktop's `try_reflect_own_addr()` when relay reflection fails
|
||||
|
||||
### Phase 8.2: PCP/PMP/UPnP Port Mapping (Done)
|
||||
- `portmap.rs`: NAT-PMP (RFC 6886), PCP (RFC 6887), UPnP IGD
|
||||
- Gateway discovery (macOS + Linux), try NAT-PMP → PCP → UPnP in sequence
|
||||
- New candidate type: `PeerCandidates.mapped` + signal fields `caller_mapped_addr`/`callee_mapped_addr`/`peer_mapped_addr`
|
||||
- Dial order: host → mapped → reflexive (mapped helps on symmetric NATs)
|
||||
|
||||
### Phase 8.3: Mid-Call ICE Re-Gathering (Done — signal plane)
|
||||
- `ice_agent.rs`: `IceAgent` with `gather()`, `re_gather()`, `apply_peer_update()`
|
||||
- `SignalMessage::CandidateUpdate` with monotonic generation counter
|
||||
- Relay forwards `CandidateUpdate` like `MediaPathReport`
|
||||
- Desktop handles and emits to JS frontend
|
||||
- Transport hot-swap: designed but not yet wired into live call engine
|
||||
|
||||
### Phase 8.4: Netcheck Diagnostic (Done)
|
||||
- `netcheck.rs`: comprehensive network diagnostic (NAT type, reflexive addr, IPv4/v6, port mapping, relay latencies)
|
||||
- CLI: `wzp-client --netcheck <relay>`
|
||||
|
||||
### Phase 8.5: Region-Based Relay Selection (Done — data model)
|
||||
- `relay_map.rs`: `RelayMap` sorted by RTT with `preferred()` selection
|
||||
- `RegisterPresenceAck` extended with `relay_region` + `available_relays`
|
||||
|
||||
### Phase 8.6: Hard NAT Traversal (Deferred)
|
||||
- Birthday-attack port prediction deferred — 2-5s probing latency is excessive for VoIP call setup
|
||||
- Phases 8.1-8.2 cover the vast majority of NAT configurations
|
||||
|
||||
@@ -329,3 +329,46 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
|
||||
- APK signing: added zipalign + apksigner pipeline to `build.sh` (was in `build-tauri-android.sh` only)
|
||||
- Keystore persistence: `$BASE_DIR/data/keystore/` cache synced into source tree before build
|
||||
- Fixes: 384MB debug APK uploaded instead of 25MB release; unsigned APK on alt server
|
||||
|
||||
### Phase 8: Tailscale-Inspired STUN/ICE Enhancements (2026-04-14)
|
||||
|
||||
5 new modules in `wzp-client`, 64 new unit tests (363 total across client/proto/relay).
|
||||
|
||||
#### Public STUN Client (`stun.rs`)
|
||||
- Minimal RFC 5389 STUN Binding Request/Response over raw UDP
|
||||
- XOR-MAPPED-ADDRESS (preferred) + MAPPED-ADDRESS (fallback) parsing
|
||||
- Default servers: `stun.l.google.com:19302`, `stun1.l.google.com:19302`, `stun.cloudflare.com:3478`
|
||||
- `discover_reflexive()` — first-success parallel probe across N servers
|
||||
- `probe_stun_servers()` — full results for NAT classification
|
||||
- Integrated into `detect_nat_type_with_stun()` combining relay + STUN probes
|
||||
- Desktop STUN fallback in `try_reflect_own_addr()` when relay reflection fails
|
||||
|
||||
#### PCP/PMP/UPnP Port Mapping (`portmap.rs`)
|
||||
- **NAT-PMP** (RFC 6886): UDP to gateway:5351, external address + port mapping
|
||||
- **PCP** (RFC 6887): PCP MAP opcode, IPv4-mapped IPv6 client address
|
||||
- **UPnP IGD**: SSDP M-SEARCH discovery + SOAP `AddPortMapping`/`GetExternalIPAddress`
|
||||
- Gateway discovery: macOS (`route -n get default`), Linux (`/proc/net/route`)
|
||||
- `acquire_port_mapping()` tries NAT-PMP → PCP → UPnP, first success wins
|
||||
- `release_port_mapping()` + `spawn_refresh()` for lifecycle management
|
||||
- Signal protocol: `caller_mapped_addr`/`callee_mapped_addr` on offer/answer, `peer_mapped_addr` on CallSetup
|
||||
- `PeerCandidates.mapped` — new candidate type in dial order (host → mapped → reflexive)
|
||||
|
||||
#### Mid-Call ICE Re-Gathering (`ice_agent.rs`)
|
||||
- `IceAgent`: owns candidate lifecycle with `gather()`, `re_gather()`, `apply_peer_update()`
|
||||
- Monotonic generation counter prevents stale candidate updates from reordering
|
||||
- `SignalMessage::CandidateUpdate` — new signal for mid-call candidate exchange
|
||||
- Relay forwards `CandidateUpdate` to call peer (same pattern as `MediaPathReport`)
|
||||
- Desktop handles `CandidateUpdate` in signal recv loop, emits to JS frontend
|
||||
- Transport hot-swap architecture designed (TODO: wire into live call engine)
|
||||
|
||||
#### Netcheck Diagnostic (`netcheck.rs`)
|
||||
- `NetcheckReport`: NAT type, reflexive addr, IPv4/v6, port mapping, relay latencies, gateway
|
||||
- `run_netcheck()` — parallel probes for STUN + relay + portmap + IPv6
|
||||
- `format_report()` — human-readable diagnostic output
|
||||
- CLI: `wzp-client --netcheck <relay>` runs diagnostic
|
||||
|
||||
#### Region-Based Relay Selection (`relay_map.rs`)
|
||||
- `RelayMap` sorted by RTT, `preferred()` returns lowest-latency reachable relay
|
||||
- `populate_from_ack()` — parses `RegisterPresenceAck.available_relays`
|
||||
- Stale detection (`needs_reprobe()`, `stale_entries()`)
|
||||
- `RegisterPresenceAck` extended with `relay_region` and `available_relays`
|
||||
|
||||
Reference in New Issue
Block a user