feat(reflect): multi-relay NAT type detection — Phase 2
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) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ pub mod echo_test;
|
|||||||
pub mod featherchat;
|
pub mod featherchat;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod reflect;
|
||||||
pub mod sweep;
|
pub mod sweep;
|
||||||
|
|
||||||
// AudioPlayback: three possible backends depending on feature flags.
|
// AudioPlayback: three possible backends depending on feature flags.
|
||||||
|
|||||||
336
crates/wzp-client/src/reflect.rs
Normal file
336
crates/wzp-client/src/reflect.rs
Normal file
@@ -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<String>,
|
||||||
|
/// End-to-end wall-clock from connect start to ReflectResponse
|
||||||
|
/// received, in milliseconds. `Some` only on success.
|
||||||
|
pub latency_ms: Option<u32>,
|
||||||
|
/// Human-readable error on failure.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated classification over N `NatProbeResult`s.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct NatDetection {
|
||||||
|
pub probes: Vec<NatProbeResult>,
|
||||||
|
pub nat_type: NatType,
|
||||||
|
/// When `nat_type == Cone`, the one address all probes agreed
|
||||||
|
/// on. `None` for every other case.
|
||||||
|
pub consensus_addr: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: "<panicked>".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<String>) {
|
||||||
|
let successes: Vec<SocketAddr> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
228
crates/wzp-relay/tests/multi_reflect.rs
Normal file
228
crates/wzp-relay/tests/multi_reflect.rs
Normal file
@@ -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:<client ephemeral port>
|
||||||
|
//! 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<String> = detection
|
||||||
|
.probes
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
p.observed_addr
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.parse::<SocketAddr>().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());
|
||||||
|
}
|
||||||
@@ -196,6 +196,17 @@
|
|||||||
Asks the registered relay to echo back the IP:port it sees for this
|
Asks the registered relay to echo back the IP:port it sees for this
|
||||||
connection (QUIC-native NAT reflection, replaces STUN).
|
connection (QUIC-native NAT reflection, replaces STUN).
|
||||||
</small>
|
</small>
|
||||||
|
<div class="setting-row" style="margin-top:10px">
|
||||||
|
<span class="setting-label">NAT type</span>
|
||||||
|
<span id="s-nat-type" class="fp-display">(not detected)</span>
|
||||||
|
<button id="s-nat-detect-btn" class="secondary-btn">Detect NAT</button>
|
||||||
|
</div>
|
||||||
|
<div id="s-nat-probes" style="margin-top:6px;font-size:11px;color:var(--text-dim)"></div>
|
||||||
|
<small style="color:var(--text-dim);display:block;margin-top:4px">
|
||||||
|
Probes every configured relay in parallel and compares the results
|
||||||
|
to classify the NAT: cone (P2P viable), symmetric (must relay),
|
||||||
|
multiple, or unknown.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Recent Rooms</h3>
|
<h3>Recent Rooms</h3>
|
||||||
|
|||||||
@@ -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<RelayArg>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
// 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]
|
#[tauri::command]
|
||||||
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
|
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
|
||||||
let sig = state.signal.lock().await;
|
let sig = state.signal.lock().await;
|
||||||
@@ -805,7 +852,7 @@ pub fn run() {
|
|||||||
ping_relay, get_identity, get_app_info,
|
ping_relay, get_identity, get_app_info,
|
||||||
connect, disconnect, toggle_mic, toggle_speaker, get_status,
|
connect, disconnect, toggle_mic, toggle_speaker, get_status,
|
||||||
register_signal, place_call, answer_call, get_signal_status,
|
register_signal, place_call, answer_call, get_signal_status,
|
||||||
get_reflected_address,
|
get_reflected_address, detect_nat_type,
|
||||||
deregister,
|
deregister,
|
||||||
set_speakerphone, is_speakerphone_on,
|
set_speakerphone, is_speakerphone_on,
|
||||||
get_call_history, get_recent_contacts, clear_call_history,
|
get_call_history, get_recent_contacts, clear_call_history,
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
|
|||||||
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
|
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
|
||||||
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
|
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
|
||||||
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
|
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 sAgc = document.getElementById("s-agc") as HTMLInputElement;
|
||||||
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
||||||
const sQualityLabel = document.getElementById("s-quality-label")!;
|
const sQualityLabel = document.getElementById("s-quality-label")!;
|
||||||
@@ -764,6 +767,80 @@ settingsBtnCall.addEventListener("click", openSettings);
|
|||||||
// (otherwise the Rust side returns "not registered"). The button
|
// (otherwise the Rust side returns "not registered"). The button
|
||||||
// shows its working state inline so the user knows it's waiting on
|
// shows its working state inline so the user knows it's waiting on
|
||||||
// the relay rather than the network.
|
// 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 `<div>• ${escapeHtml(p.relay_name)} (${escapeHtml(
|
||||||
|
p.relay_addr
|
||||||
|
)}) → ${escapeHtml(p.observed_addr)} [${p.latency_ms ?? "?"}ms]</div>`;
|
||||||
|
} else {
|
||||||
|
return `<div style="color:var(--yellow)">• ${escapeHtml(
|
||||||
|
p.relay_name
|
||||||
|
)} (${escapeHtml(p.relay_addr)}) → ${escapeHtml(
|
||||||
|
p.error ?? "probe failed"
|
||||||
|
)}</div>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
} catch (e: any) {
|
||||||
|
sNatType.textContent = `⚠ ${String(e)}`;
|
||||||
|
sNatType.style.color = "var(--red)";
|
||||||
|
sNatProbes.innerHTML = "";
|
||||||
|
} finally {
|
||||||
|
sNatDetectBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
sReflectBtn.addEventListener("click", async () => {
|
sReflectBtn.addEventListener("click", async () => {
|
||||||
sReflectedAddr.textContent = "querying...";
|
sReflectedAddr.textContent = "querying...";
|
||||||
sReflectBtn.disabled = true;
|
sReflectBtn.disabled = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user