From 8d903f16c6a97a5bf46ba5deb39827abbfff1e6f Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sat, 11 Apr 2026 12:47:12 +0400 Subject: [PATCH] =?UTF-8?q?feat(reflect):=20multi-relay=20NAT=20type=20det?= =?UTF-8?q?ection=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on Phase 1's SignalMessage::Reflect to probe N relays in parallel through transient QUIC connections and classify the client's NAT type for the future P2P hole-punching path. No wire protocol changes — Phase 1's Reflect/ReflectResponse pair is reused unchanged. New client-side module (crates/wzp-client/src/reflect.rs): - probe_reflect_addr(relay, timeout_ms): opens a throwaway quinn::Endpoint (fresh ephemeral source port per probe, essential for NAT-type detection — sharing one endpoint would make a symmetric NAT look like a cone NAT), connects to _signal, sends RegisterPresence with zero identity, consumes the Ack, sends Reflect, awaits ReflectResponse, cleanly closes. - detect_nat_type(relays, timeout_ms): parallel probes via tokio::task::JoinSet (bounded by slowest probe not sum) and returns a NatDetection with per-probe results + aggregate classification. - classify_nat(probes): pure-function classifier split out for network-free unit tests. Rules: * 0-1 successful probes → Unknown * 2+ successes, same ip same port → Cone (P2P viable) * 2+ successes, same ip diff ports → SymmetricPort (relay) * 2+ successes, different ips → Multiple (treat as symmetric) Tauri command (desktop/src-tauri/src/lib.rs): - detect_nat_type({ relays: [{ name, address }] }) -> NatDetection as JSON. Takes the relay list from JS because localStorage owns the config. Parse-up-front so a malformed entry fails clean instead of as a probe error. 1500ms per-probe timeout. UI (desktop/index.html + src/main.ts): - New "NAT type" row + "Detect NAT" button in the Network settings section. Renders per-probe status (name, address, observed addr, latency, or error) plus the colored verdict: * green Cone — shows consensus addr * amber SymmetricPort / Multiple — must relay * gray Unknown — not enough data Tests: - 7 unit tests in wzp-client/src/reflect.rs covering every classifier branch (empty, 1 success, 2 identical, 2 diff ports, 2 diff ips, success+failure mix, pure-failure). - 3 integration tests in crates/wzp-relay/tests/multi_reflect.rs: * probe_reflect_addr_happy_path — single mock relay end-to-end * detect_nat_type_two_loopback_relays_is_cone — two concurrent relays, asserts both see 127.0.0.1 and classifier returns Cone or SymmetricPort (accepted because the test harness uses fresh ephemeral ports per probe which look like SymmetricPort on single-host loopback) * detect_nat_type_dead_relay_is_unknown — alive + dead port mix, asserts the dead probe surfaces an error string and the aggregator returns Unknown (only 1 success) Full workspace test goes from 386 → 396 passing. PRD: .taskmaster/docs/prd_multi_relay_reflect.txt Tasks: 47-52 all completed Next up: hole-punching (Phase 3) — use the reflected address in DirectCallOffer/Answer and CallSetup so peers attempt a direct QUIC handshake to each other, with relay fallback on timeout. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-client/src/lib.rs | 1 + crates/wzp-client/src/reflect.rs | 336 ++++++++++++++++++++++++ crates/wzp-relay/tests/multi_reflect.rs | 228 ++++++++++++++++ desktop/index.html | 11 + desktop/src-tauri/src/lib.rs | 49 +++- desktop/src/main.ts | 77 ++++++ 6 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 crates/wzp-client/src/reflect.rs create mode 100644 crates/wzp-relay/tests/multi_reflect.rs diff --git a/crates/wzp-client/src/lib.rs b/crates/wzp-client/src/lib.rs index a9179bc..bcc029c 100644 --- a/crates/wzp-client/src/lib.rs +++ b/crates/wzp-client/src/lib.rs @@ -33,6 +33,7 @@ pub mod echo_test; pub mod featherchat; pub mod handshake; pub mod metrics; +pub mod reflect; pub mod sweep; // AudioPlayback: three possible backends depending on feature flags. diff --git a/crates/wzp-client/src/reflect.rs b/crates/wzp-client/src/reflect.rs new file mode 100644 index 0000000..eebb015 --- /dev/null +++ b/crates/wzp-client/src/reflect.rs @@ -0,0 +1,336 @@ +//! Multi-relay NAT reflection ("STUN for QUIC" — Phase 2). +//! +//! Phase 1 (`SignalMessage::Reflect` / `ReflectResponse`) lets a +//! client ask a single relay "what source address do you see for +//! me?". Phase 2 queries N relays in parallel and classifies the +//! results into a NAT type so the future P2P hole-punching path +//! can decide whether a direct QUIC handshake is viable: +//! +//! - All relays return the same `(ip, port)` → **Cone NAT**. +//! Endpoint-independent mapping, P2P hole-punching viable, +//! `consensus_addr` is the one address to advertise. +//! - Same ip, different ports → **Symmetric port-dependent NAT**. +//! The mapping changes per destination, so the advertised addr +//! wouldn't match what a peer actually sees; fall back to +//! relay-mediated path. +//! - Different ips → multi-homed / anycast / broken DNS, treat as +//! `Multiple` and do not attempt P2P. +//! - 0 or 1 successful probes → `Unknown`, not enough data. +//! +//! A probe is a throwaway QUIC signal connection: open endpoint, +//! connect, RegisterPresence (with a zero identity — the relay +//! accepts this exactly like the main signaling path does), send +//! Reflect, read ReflectResponse, close. Each probe gets its own +//! ephemeral quinn::Endpoint so the OS assigns a fresh source port +//! per relay — if we shared one endpoint across probes, a +//! symmetric NAT in front of the client would map every probe to +//! the same port and we couldn't detect it. + +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_transport::{client_config, create_endpoint, QuinnTransport}; + +/// Result of one probe against one relay. Always returned so the +/// UI can render per-relay status even when some fail. +#[derive(Debug, Clone, Serialize)] +pub struct NatProbeResult { + pub relay_name: String, + pub relay_addr: String, + /// `Some` on successful probe, `None` on failure. + pub observed_addr: Option, + /// End-to-end wall-clock from connect start to ReflectResponse + /// received, in milliseconds. `Some` only on success. + pub latency_ms: Option, + /// Human-readable error on failure. + pub error: Option, +} + +/// Aggregated classification over N `NatProbeResult`s. +#[derive(Debug, Clone, Serialize)] +pub struct NatDetection { + pub probes: Vec, + pub nat_type: NatType, + /// When `nat_type == Cone`, the one address all probes agreed + /// on. `None` for every other case. + pub consensus_addr: Option, +} + +/// NAT classification. See module doc for semantics. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub enum NatType { + Cone, + SymmetricPort, + Multiple, + Unknown, +} + +/// Probe a single relay with a throwaway QUIC connection. +/// +/// Each call creates a fresh `quinn::Endpoint` so the OS hands out a +/// fresh ephemeral source port — essential for NAT-type detection +/// because a shared socket would produce the same mapping against +/// every relay and mask symmetric NAT. +pub async fn probe_reflect_addr( + relay: SocketAddr, + timeout_ms: u64, +) -> Result<(SocketAddr, u32), String> { + // Install rustls provider idempotently — a second install on the + // same thread is a no-op. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + let endpoint = create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))?; + + let start = Instant::now(); + let probe = async { + // Open the signal connection. + let conn = + wzp_transport::connect(&endpoint, relay, "_signal", client_config()) + .await + .map_err(|e| format!("connect: {e}"))?; + let transport = QuinnTransport::new(conn); + + // The relay signal handler waits for a RegisterPresence + // before entering its main dispatch loop (see + // wzp-relay/src/main.rs). So a transient probe has to + // register with a zero identity first — the relay accepts + // the empty-signature form exactly as the main signaling + // path does in desktop/src-tauri/src/lib.rs register_signal. + transport + .send_signal(&SignalMessage::RegisterPresence { + identity_pub: [0u8; 32], + signature: vec![], + alias: None, + }) + .await + .map_err(|e| format!("send RegisterPresence: {e}"))?; + // Drain the RegisterPresenceAck so the response to our + // Reflect doesn't land on an unexpected stream order. + match transport.recv_signal().await { + Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {} + Ok(Some(other)) => { + return Err(format!( + "unexpected pre-reflect signal: {:?}", + std::mem::discriminant(&other) + )); + } + Ok(None) => return Err("connection closed before RegisterPresenceAck".into()), + Err(e) => return Err(format!("recv RegisterPresenceAck: {e}")), + } + + // Send Reflect and await response. + transport + .send_signal(&SignalMessage::Reflect) + .await + .map_err(|e| format!("send Reflect: {e}"))?; + + match transport.recv_signal().await { + Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => { + let parsed: SocketAddr = observed_addr + .parse() + .map_err(|e| format!("parse observed_addr {observed_addr:?}: {e}"))?; + let latency_ms = start.elapsed().as_millis() as u32; + + // Clean close so the relay's per-connection cleanup + // runs promptly and we don't leak file descriptors. + let _ = transport.close().await; + + Ok((parsed, latency_ms)) + } + Ok(Some(other)) => Err(format!( + "expected ReflectResponse, got {:?}", + std::mem::discriminant(&other) + )), + Ok(None) => Err("connection closed before ReflectResponse".into()), + Err(e) => Err(format!("recv ReflectResponse: {e}")), + } + }; + + let out = tokio::time::timeout(Duration::from_millis(timeout_ms), probe) + .await + .map_err(|_| format!("probe timeout ({timeout_ms}ms)"))??; + + // Drop the endpoint explicitly AFTER the probe finishes so the + // UDP socket is released before we return. + drop(endpoint); + Ok(out) +} + +/// Detect the client's NAT type by probing N relays in parallel and +/// classifying the returned addresses. Never errors — failing +/// probes surface via `NatProbeResult.error`; aggregate is always +/// returned. +pub async fn detect_nat_type( + relays: Vec<(String, SocketAddr)>, + timeout_ms: u64, +) -> NatDetection { + // Parallel probes via tokio::task::JoinSet so the wall-clock is + // bounded by the slowest probe, not the sum. JoinSet keeps the + // dep surface at just tokio — we already depend on it. + let mut set = tokio::task::JoinSet::new(); + for (name, addr) in relays { + set.spawn(async move { + let result = probe_reflect_addr(addr, timeout_ms).await; + (name, addr, result) + }); + } + + let mut probes = Vec::new(); + while let Some(join_result) = set.join_next().await { + let (name, addr, result) = match join_result { + Ok(tuple) => tuple, + // Task panicked — surface as a synthetic failed probe so + // the aggregate still returns a reasonable shape. This + // shouldn't happen but we don't want one bad probe to + // poison the whole detection. + Err(join_err) => { + probes.push(NatProbeResult { + relay_name: "".into(), + relay_addr: "unknown".into(), + observed_addr: None, + latency_ms: None, + error: Some(format!("probe task panicked: {join_err}")), + }); + continue; + } + }; + probes.push(match result { + Ok((observed, latency_ms)) => NatProbeResult { + relay_name: name, + relay_addr: addr.to_string(), + observed_addr: Some(observed.to_string()), + latency_ms: Some(latency_ms), + error: None, + }, + Err(e) => NatProbeResult { + relay_name: name, + relay_addr: addr.to_string(), + observed_addr: None, + latency_ms: None, + error: Some(e), + }, + }); + } + + let (nat_type, consensus_addr) = classify_nat(&probes); + NatDetection { + probes, + nat_type, + consensus_addr, + } +} + +/// Pure-function NAT classifier — split out for unit testing +/// without touching the network. +pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option) { + let successes: Vec = probes + .iter() + .filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok())) + .collect(); + + if successes.len() < 2 { + return (NatType::Unknown, None); + } + + let first = successes[0]; + let same_ip = successes.iter().all(|a| a.ip() == first.ip()); + if !same_ip { + return (NatType::Multiple, None); + } + + let same_port = successes.iter().all(|a| a.port() == first.port()); + if same_port { + (NatType::Cone, Some(first.to_string())) + } else { + (NatType::SymmetricPort, None) + } +} + +// ── Unit tests for the pure classifier ─────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn mk(addr: Option<&str>) -> NatProbeResult { + NatProbeResult { + relay_name: "test".into(), + relay_addr: "0.0.0.0:0".into(), + observed_addr: addr.map(|s| s.to_string()), + latency_ms: addr.map(|_| 10), + error: None, + } + } + + #[test] + fn classify_empty_is_unknown() { + let (nt, addr) = classify_nat(&[]); + assert_eq!(nt, NatType::Unknown); + assert!(addr.is_none()); + } + + #[test] + fn classify_single_success_is_unknown() { + let probes = vec![mk(Some("192.0.2.1:4433"))]; + let (nt, addr) = classify_nat(&probes); + assert_eq!(nt, NatType::Unknown); + assert!(addr.is_none()); + } + + #[test] + fn classify_two_identical_is_cone() { + let probes = vec![ + mk(Some("192.0.2.1:4433")), + mk(Some("192.0.2.1:4433")), + ]; + let (nt, addr) = classify_nat(&probes); + assert_eq!(nt, NatType::Cone); + assert_eq!(addr.as_deref(), Some("192.0.2.1:4433")); + } + + #[test] + fn classify_same_ip_different_ports_is_symmetric() { + let probes = vec![ + mk(Some("192.0.2.1:4433")), + mk(Some("192.0.2.1:51234")), + ]; + let (nt, addr) = classify_nat(&probes); + assert_eq!(nt, NatType::SymmetricPort); + assert!(addr.is_none()); + } + + #[test] + fn classify_different_ips_is_multiple() { + let probes = vec![ + mk(Some("192.0.2.1:4433")), + mk(Some("198.51.100.9:4433")), + ]; + let (nt, addr) = classify_nat(&probes); + assert_eq!(nt, NatType::Multiple); + assert!(addr.is_none()); + } + + #[test] + fn classify_mix_of_success_and_failure() { + let probes = vec![ + mk(Some("192.0.2.1:4433")), + mk(None), // failed probe + mk(Some("192.0.2.1:4433")), + ]; + let (nt, addr) = classify_nat(&probes); + // Two successes both agree → Cone, ignore the failure row. + assert_eq!(nt, NatType::Cone); + assert_eq!(addr.as_deref(), Some("192.0.2.1:4433")); + } + + #[test] + fn classify_one_success_one_failure_is_unknown() { + let probes = vec![mk(Some("192.0.2.1:4433")), mk(None)]; + let (nt, addr) = classify_nat(&probes); + assert_eq!(nt, NatType::Unknown); + assert!(addr.is_none()); + } +} diff --git a/crates/wzp-relay/tests/multi_reflect.rs b/crates/wzp-relay/tests/multi_reflect.rs new file mode 100644 index 0000000..fd829d1 --- /dev/null +++ b/crates/wzp-relay/tests/multi_reflect.rs @@ -0,0 +1,228 @@ +//! Phase 2 integration tests for multi-relay NAT reflection +//! (PRD: .taskmaster/docs/prd_multi_relay_reflect.txt). +//! +//! These spin up one or two mock relays that implement the full +//! pre-reflect dance — RegisterPresence → RegisterPresenceAck → +//! Reflect → ReflectResponse — which is what the transient +//! probe helper in `wzp_client::reflect::probe_reflect_addr` does +//! against a real relay. +//! +//! Test matrix: +//! 1. `probe_reflect_addr_happy_path` +//! — single mock relay, assert the probe helper returns the +//! observed addr as 127.0.0.1: +//! 2. `detect_nat_type_two_loopback_relays_is_cone` +//! — two mock relays, one client; loopback single-host means +//! every probe sees the same (127.0.0.1, same_port) so the +//! classifier returns `Cone` + a consensus addr +//! 3. `detect_nat_type_dead_relay_is_unknown` +//! — one alive relay + one dead address; aggregator returns +//! `Unknown` with a non-empty `error` field on the failed +//! probe + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use wzp_client::reflect::{detect_nat_type, probe_reflect_addr, NatType}; +use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_transport::{create_endpoint, server_config, QuinnTransport}; + +/// Minimal mock relay that loops accepting connections, handles +/// RegisterPresence + Reflect, and responds correctly. Mirrors the +/// two match arms from `wzp-relay/src/main.rs` that matter here. +/// +/// Each accepted connection gets its own inner task so multiple +/// simultaneous probes work. +async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::ring::default_provider().install_default(); + let (sc, _cert_der) = server_config(); + let bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into(); + let endpoint = create_endpoint(bind, Some(sc)).expect("server endpoint"); + let listen_addr = endpoint.local_addr().expect("local_addr"); + + let handle = tokio::spawn(async move { + loop { + // Accept the next incoming connection. `wzp_transport::accept` + // returns the established `quinn::Connection`. + let conn = match wzp_transport::accept(&endpoint).await { + Ok(c) => c, + Err(_) => break, // endpoint closed + }; + let observed_addr = conn.remote_address(); + let transport = Arc::new(QuinnTransport::new(conn)); + + // Per-connection handler. Keep servicing messages until + // the peer closes so one probe connection can do + // RegisterPresence → Ack → Reflect → Response without + // racing other incoming connections. + let t = transport; + tokio::spawn(async move { + loop { + match t.recv_signal().await { + Ok(Some(SignalMessage::RegisterPresence { .. })) => { + let _ = t + .send_signal(&SignalMessage::RegisterPresenceAck { + success: true, + error: None, + }) + .await; + } + Ok(Some(SignalMessage::Reflect)) => { + let _ = t + .send_signal(&SignalMessage::ReflectResponse { + observed_addr: observed_addr.to_string(), + }) + .await; + } + Ok(Some(_other)) => { /* ignore */ } + Ok(None) => break, + Err(_) => break, + } + } + }); + } + }); + + (listen_addr, handle) +} + +// ----------------------------------------------------------------------- +// Test 1: probe_reflect_addr against a single mock relay +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn probe_reflect_addr_happy_path() { + let (relay_addr, _relay_handle) = spawn_mock_relay().await; + + let (observed, latency_ms) = tokio::time::timeout( + Duration::from_secs(3), + probe_reflect_addr(relay_addr, 2000), + ) + .await + .expect("probe must complete within 3s") + .expect("probe must succeed"); + + assert_eq!( + observed.ip().to_string(), + "127.0.0.1", + "loopback test should see 127.0.0.1" + ); + assert_ne!(observed.port(), 0, "observed port must be non-zero"); + // Latency on same host is dominated by the handshake — generously + // allow up to 2s (the timeout) rather than picking a tight number + // that would be flaky on busy CI runners. + assert!(latency_ms < 2000, "latency {latency_ms}ms too high"); +} + +// ----------------------------------------------------------------------- +// Test 2: two loopback relays → Cone classification +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detect_nat_type_two_loopback_relays_is_cone() { + let (addr_a, _h_a) = spawn_mock_relay().await; + let (addr_b, _h_b) = spawn_mock_relay().await; + + let detection = detect_nat_type( + vec![ + ("RelayA".into(), addr_a), + ("RelayB".into(), addr_b), + ], + 2000, + ) + .await; + + assert_eq!(detection.probes.len(), 2); + for p in &detection.probes { + assert!(p.observed_addr.is_some(), "probe {:?} failed: {:?}", p.relay_name, p.error); + } + + // Loopback single-host: every probe sees 127.0.0.1 and, crucially, + // uses a different ephemeral source port (since probe_reflect_addr + // spins up a fresh quinn::Endpoint per probe). Wait — that makes + // this look like Symmetric to the classifier, not Cone! + // + // The classifier cares about the *observed* addr, which is what + // the relay sees as the client's source. Two different client + // endpoints on loopback → two different observed ports → the + // classifier correctly labels this as SymmetricPort in the test + // environment. That's still a valid verification of the + // plumbing, just not of the Cone classification. + // + // Accept either Cone OR SymmetricPort for this test, then + // assert the more specific invariant that matters: both probes + // returned the same observed IP. + let observed_ips: Vec = detection + .probes + .iter() + .map(|p| { + p.observed_addr + .as_ref() + .and_then(|s| s.parse::().ok()) + .map(|a| a.ip().to_string()) + .unwrap_or_default() + }) + .collect(); + assert_eq!(observed_ips[0], "127.0.0.1"); + assert_eq!(observed_ips[1], "127.0.0.1"); + + // Either classification is valid on loopback (see long comment + // above). Explicitly assert the set so a future refactor that + // accidentally returns `Multiple` or `Unknown` fails the test. + assert!( + matches!(detection.nat_type, NatType::Cone | NatType::SymmetricPort), + "expected Cone or SymmetricPort on loopback, got {:?}", + detection.nat_type + ); +} + +// ----------------------------------------------------------------------- +// Test 3: one alive relay + one dead address → Unknown +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detect_nat_type_dead_relay_is_unknown() { + let (alive_addr, _alive_handle) = spawn_mock_relay().await; + + // Dead relay: a port that nothing is listening on. OS will drop + // the packets, the probe should time out within the 600ms budget + // we give it. Pick a port unlikely to be in use — port 1 on + // loopback works on every OS I care about and fails fast. + let dead_addr: SocketAddr = "127.0.0.1:1".parse().unwrap(); + + let detection = detect_nat_type( + vec![ + ("Alive".into(), alive_addr), + ("Dead".into(), dead_addr), + ], + 600, // tight timeout so the dead probe fails fast + ) + .await; + + assert_eq!(detection.probes.len(), 2); + + // Find the alive and dead probes by name (order of JoinSet + // completions is not guaranteed). + let alive = detection.probes.iter().find(|p| p.relay_name == "Alive").unwrap(); + let dead = detection.probes.iter().find(|p| p.relay_name == "Dead").unwrap(); + + assert!( + alive.observed_addr.is_some(), + "alive probe must succeed: {:?}", + alive.error + ); + assert!( + dead.observed_addr.is_none(), + "dead probe must fail, got addr {:?}", + dead.observed_addr + ); + assert!( + dead.error.is_some(), + "dead probe must surface an error string" + ); + + // With only 1 successful probe, the classifier returns Unknown. + assert_eq!(detection.nat_type, NatType::Unknown); + assert!(detection.consensus_addr.is_none()); +} diff --git a/desktop/index.html b/desktop/index.html index b10ab4a..4ae4bdd 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -196,6 +196,17 @@ Asks the registered relay to echo back the IP:port it sees for this connection (QUIC-native NAT reflection, replaces STUN). +
+ NAT type + (not detected) + +
+
+ + Probes every configured relay in parallel and compares the results + to classify the NAT: cone (P2P viable), symmetric (must relay), + multiple, or unknown. +

Recent Rooms

diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0b20cc6..0d97fce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -719,6 +719,53 @@ async fn get_reflected_address( } } +/// Phase 2 of the "STUN for QUIC" rollout — probe multiple relays +/// in parallel to classify this client's NAT type. See +/// `wzp_client::reflect` for the per-probe logic and the pure +/// classifier. +/// +/// This does NOT touch the registered `SignalState` — each probe +/// opens a fresh throwaway QUIC endpoint so the OS gives it a +/// fresh ephemeral source port. Sharing one endpoint across probes +/// would make a symmetric NAT look like a cone NAT, which is +/// exactly the failure mode we're trying to detect. +/// +/// Takes the relay list from JS because the GUI owns the relay +/// config (localStorage `wzp-settings.relays`). Frontend passes it +/// in; Rust side just does the network work. +#[tauri::command] +async fn detect_nat_type( + relays: Vec, +) -> Result { + // Parse relay args up front so a single malformed entry fails + // the whole call cleanly instead of surfacing as a probe error + // at the end. + let mut parsed = Vec::with_capacity(relays.len()); + for r in relays { + let addr: std::net::SocketAddr = r + .address + .parse() + .map_err(|e| format!("bad relay address {:?}: {e}", r.address))?; + parsed.push((r.name, addr)); + } + + // 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).await; + serde_json::to_value(&detection).map_err(|e| format!("serialize: {e}")) +} + +/// Deserialization shim for the relay list coming from JS. The +/// `wzp-settings.relays` array in localStorage has more fields +/// (rtt, serverFingerprint, knownFingerprint) but we only need +/// name + address here. +#[derive(serde::Deserialize)] +struct RelayArg { + name: String, + address: String, +} + #[tauri::command] async fn get_signal_status(state: tauri::State<'_, Arc>) -> Result { let sig = state.signal.lock().await; @@ -805,7 +852,7 @@ pub fn run() { ping_relay, get_identity, get_app_info, connect, disconnect, toggle_mic, toggle_speaker, get_status, register_signal, place_call, answer_call, get_signal_status, - get_reflected_address, + get_reflected_address, detect_nat_type, deregister, set_speakerphone, is_speakerphone_on, get_call_history, get_recent_contacts, clear_call_history, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 8ed929b..d0f54ef 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -85,6 +85,9 @@ const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement; const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement; const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement; +const sNatType = document.getElementById("s-nat-type") as HTMLSpanElement; +const sNatDetectBtn = document.getElementById("s-nat-detect-btn") as HTMLButtonElement; +const sNatProbes = document.getElementById("s-nat-probes") as HTMLDivElement; const sAgc = document.getElementById("s-agc") as HTMLInputElement; const sQuality = document.getElementById("s-quality") as HTMLInputElement; const sQualityLabel = document.getElementById("s-quality-label")!; @@ -764,6 +767,80 @@ settingsBtnCall.addEventListener("click", openSettings); // (otherwise the Rust side returns "not registered"). The button // shows its working state inline so the user knows it's waiting on // the relay rather than the network. +// Phase 2 multi-relay NAT type detection. Probes every configured +// relay in parallel through transient QUIC connections and +// classifies the result. Green = Cone (P2P viable), +// amber = SymmetricPort (must relay), gray = Multiple / Unknown. +sNatDetectBtn.addEventListener("click", async () => { + const s = loadSettings(); + if (!s.relays || s.relays.length === 0) { + sNatType.textContent = "⚠ no relays configured"; + sNatType.style.color = "var(--yellow)"; + return; + } + sNatType.textContent = "probing..."; + sNatType.style.color = "var(--text)"; + sNatProbes.innerHTML = ""; + sNatDetectBtn.disabled = true; + try { + const detection = await invoke<{ + probes: Array<{ + relay_name: string; + relay_addr: string; + observed_addr: string | null; + latency_ms: number | null; + error: string | null; + }>; + nat_type: "Cone" | "SymmetricPort" | "Multiple" | "Unknown"; + consensus_addr: string | null; + }>("detect_nat_type", { + relays: s.relays.map((r) => ({ name: r.name, address: r.address })), + }); + + const verdictLabel = + detection.nat_type === "Cone" + ? `✓ Cone NAT — P2P viable (${detection.consensus_addr})` + : detection.nat_type === "SymmetricPort" + ? "⚠ Symmetric NAT — must use relay" + : detection.nat_type === "Multiple" + ? "⚠ Multiple IPs — treating as symmetric" + : "? Unknown (not enough successful probes)"; + + const verdictColor = + detection.nat_type === "Cone" + ? "var(--green)" + : detection.nat_type === "SymmetricPort" || + detection.nat_type === "Multiple" + ? "var(--yellow)" + : "var(--text-dim)"; + + sNatType.textContent = verdictLabel; + sNatType.style.color = verdictColor; + + sNatProbes.innerHTML = detection.probes + .map((p) => { + if (p.observed_addr) { + return `
• ${escapeHtml(p.relay_name)} (${escapeHtml( + p.relay_addr + )}) → ${escapeHtml(p.observed_addr)} [${p.latency_ms ?? "?"}ms]
`; + } else { + return `
• ${escapeHtml( + p.relay_name + )} (${escapeHtml(p.relay_addr)}) → ${escapeHtml( + p.error ?? "probe failed" + )}
`; + } + }) + .join(""); + } catch (e: any) { + sNatType.textContent = `⚠ ${String(e)}`; + sNatType.style.color = "var(--red)"; + sNatProbes.innerHTML = ""; + } finally { + sNatDetectBtn.disabled = false; + } +}); + sReflectBtn.addEventListener("click", async () => { sReflectedAddr.textContent = "querying..."; sReflectBtn.disabled = true;