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:
Siavash Sameni
2026-04-11 12:47:12 +04:00
parent 921856eba9
commit 8d903f16c6
6 changed files with 701 additions and 1 deletions

View File

@@ -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).
</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 class="settings-section">
<h3>Recent Rooms</h3>

View File

@@ -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]
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
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,

View File

@@ -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 `<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 () => {
sReflectedAddr.textContent = "querying...";
sReflectBtn.disabled = true;