feat(nat): Tailscale-inspired STUN/ICE + port mapping + mid-call re-gathering (#28)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 23s
Build Release Binaries / build-amd64 (push) Failing after 6m8s

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:
Siavash Sameni
2026-04-14 10:17:17 +04:00
parent 9377a9009c
commit 8fcf1be341
26 changed files with 4555 additions and 44 deletions

1
Cargo.lock generated
View File

@@ -8006,6 +8006,7 @@ dependencies = [
"crossterm",
"if-addrs",
"libc",
"rand 0.8.5",
"ratatui",
"rustls",
"serde",

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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
}
}

View 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"),
}
}
}

View File

@@ -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.

View 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)]

View 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"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(),

View File

@@ -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"),
}
}
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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;
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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;
}

View File

@@ -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};

View File

@@ -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}"))
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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`