feat(reflect): QUIC-native NAT reflection ("STUN for QUIC") — Phase 1
Lets a client ask its registered relay "what IP:port do you see for
me?" over the existing TLS-authenticated signal channel, returning
the client's server-reflexive address as a SocketAddr. Replaces the
need for a classic STUN deployment and becomes the bootstrap step
for future P2P hole-punching: once both peers know their own reflex
addrs, they can advertise them in DirectCallOffer and attempt a
direct QUIC handshake to each other.
Wire protocol (wzp-proto):
- SignalMessage::Reflect — unit variant, client -> relay
- SignalMessage::ReflectResponse { observed_addr: String } — relay -> client
- JSON-serde, appended at end of enum: zero ordinal concerns,
backward compat with pre-Phase-1 relays by construction (older
relays log "unexpected message" and drop; newer clients time out
cleanly within 1s).
Relay handler (wzp-relay/src/main.rs, signal loop):
- New match arm next to Ping reuses the already-bound `addr` from
connection.remote_address() and replies with observed_addr as a
string. debug!-level log on success, warn!-level on send failure.
Client side (desktop/src-tauri/src/lib.rs):
- SignalState gains pending_reflect: Option<oneshot::Sender<SocketAddr>>.
- get_reflected_address Tauri command installs the oneshot before
sending Reflect and awaits it with a 1s timeout; cleans up on
every exit path (send failure, timeout, parse error).
- recv loop's new ReflectResponse arm fires the pending sender or
emits a debug log for unsolicited responses — never crashes the
loop on malformed input.
- Integrated into invoke_handler! alongside the other signal
commands.
UI (desktop/index.html + src/main.ts):
- New "Network" section in settings panel with a "Detect" button
that displays the reflected address or a categorized warning
("register first" / "relay does not support reflection" / error).
Tests (crates/wzp-relay/tests/reflect.rs — 3 new, all passing):
- reflect_happy_path: client on loopback gets back 127.0.0.1:<its own port>
- reflect_two_clients_distinct_ports: two concurrent clients see
their own distinct ports, proving per-connection remote_address
- reflect_old_relay_times_out: mock relay that ignores Reflect —
client times out between 1000-1200ms and does not hang
Also pre-existing test bit-rot unrelated to this PR — fixed so the
full workspace `cargo test` goes green:
- handshake_integration tests in wzp-client, wzp-relay and
featherchat_compat in wzp-crypto all missed the `alias` field
addition to CallOffer and the 3-arg form of perform_handshake
plus 4-tuple return of accept_handshake. Updated to the current
API surface.
Results:
cargo test --workspace --exclude wzp-android: 386 passed
cargo check --workspace: clean
cargo clippy: no new warnings in touched files
Verification excludes wzp-android because it's dead code on this
branch (Tauri mobile uses wzp-native instead) and can't link -llog
on macOS host — unchanged status quo.
PRD: .taskmaster/docs/prd_reflect_over_quic.txt
Tasks: 39-46 all completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -770,6 +770,29 @@ pub enum SignalMessage {
|
||||
CallRinging {
|
||||
call_id: String,
|
||||
},
|
||||
|
||||
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
|
||||
|
||||
/// Client → relay: "please tell me the source IP:port you see on
|
||||
/// this connection". A QUIC-native replacement for classic STUN
|
||||
/// that reuses the TLS-authenticated signal channel to the relay
|
||||
/// instead of running a separate UDP reflection service on port
|
||||
/// 3478. The relay answers with `ReflectResponse`.
|
||||
///
|
||||
/// No payload — the relay already knows which connection the
|
||||
/// request arrived on, and `connection.remote_address()` gives it
|
||||
/// the exact source address (post-NAT) as observed from the
|
||||
/// server side of the TLS session.
|
||||
Reflect,
|
||||
|
||||
/// Relay → client: response to `Reflect`. Carries the socket
|
||||
/// address the relay observes as the client's source for this
|
||||
/// QUIC connection in `SocketAddr::to_string()` form — "a.b.c.d:p"
|
||||
/// for IPv4, "[::1]:p" for IPv6. Clients parse it with
|
||||
/// `SocketAddr::from_str`.
|
||||
ReflectResponse {
|
||||
observed_addr: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the callee responds to a direct call.
|
||||
@@ -908,6 +931,58 @@ mod tests {
|
||||
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_serialize_roundtrip() {
|
||||
// Reflect is a unit variant — the client sends it with no
|
||||
// payload and the relay answers with the observed source addr.
|
||||
let req = SignalMessage::Reflect;
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, SignalMessage::Reflect));
|
||||
|
||||
// ReflectResponse carries a string — exercise both IPv4 and
|
||||
// IPv6 shapes because SocketAddr::to_string uses [::1]:port
|
||||
// for v6 and the client side has to parse that back.
|
||||
for addr in ["192.0.2.17:4433", "[2001:db8::1]:4433", "127.0.0.1:54321"] {
|
||||
let resp = SignalMessage::ReflectResponse {
|
||||
observed_addr: addr.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::ReflectResponse { observed_addr } => {
|
||||
assert_eq!(observed_addr, addr);
|
||||
// Must parse back to a SocketAddr cleanly.
|
||||
let _parsed: std::net::SocketAddr = observed_addr.parse()
|
||||
.expect("observed_addr must parse as SocketAddr");
|
||||
}
|
||||
_ => panic!("wrong variant after roundtrip"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_backward_compat_with_existing_variants() {
|
||||
// Adding Reflect/ReflectResponse at the end of the enum must
|
||||
// not break JSON round-tripping of existing variants. Smoke-
|
||||
// test a sample of the pre-existing ones.
|
||||
let cases = vec![
|
||||
SignalMessage::Ping { timestamp_ms: 12345 },
|
||||
SignalMessage::Hold,
|
||||
SignalMessage::Hangup { reason: HangupReason::Normal },
|
||||
SignalMessage::CallRinging { call_id: "abcd".into() },
|
||||
];
|
||||
for m in cases {
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
// Discriminant equality proves variant tag survived.
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&m),
|
||||
std::mem::discriminant(&decoded)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_unhold_serialize() {
|
||||
let hold = SignalMessage::Hold;
|
||||
|
||||
Reference in New Issue
Block a user