Same-LAN P2P was failing because MikroTik masquerade (like most
consumer NATs) doesn't support NAT hairpinning — the advertised
WAN reflex addr is unreachable from a peer on the same LAN as
the advertiser. Phase 5 got us Cone NAT classification and fixed
the measurement artifact, but same-LAN direct dials still had
nowhere to land.
Phase 5.5 adds ICE-style host candidates: each client enumerates
its LAN-local network interface addresses, includes them in the
DirectCallOffer/Answer alongside the reflex addr, and the
dual-path race fans out to ALL peer candidates in parallel.
Same-LAN peers find each other via their RFC1918 IPv4 + ULA /
global-unicast IPv6 addresses without touching the NAT at all.
Dual-stack IPv6 is in scope from the start — on modern ISPs
(including Starlink) the v6 path often works even when v4
hairpinning doesn't, because there's no NAT on the v6 side.
## Changes
### `wzp_client::reflect::local_host_candidates(port)` (new)
Enumerates network interfaces via `if-addrs` and returns
SocketAddrs paired with the caller's port. Filters:
- IPv4: RFC1918 (10/8, 172.16/12, 192.168/16) + CGNAT (100.64/10)
- IPv6: global unicast (2000::/3) + ULA (fc00::/7)
- Skipped: loopback, link-local (169.254, fe80::), public v4
(already covered by reflex-addr), unspecified
Safe from any thread, one `getifaddrs(3)` syscall.
### Wire protocol (wzp-proto/packet.rs)
Three new `#[serde(default, skip_serializing_if = "Vec::is_empty")]`
fields, backward-compat with pre-5.5 clients/relays by
construction:
- `DirectCallOffer.caller_local_addrs: Vec<String>`
- `DirectCallAnswer.callee_local_addrs: Vec<String>`
- `CallSetup.peer_local_addrs: Vec<String>`
### Call registry (wzp-relay/call_registry.rs)
`DirectCall` gains `caller_local_addrs` + `callee_local_addrs`
Vec<String> fields. New `set_caller_local_addrs` /
`set_callee_local_addrs` setters. Follow the same pattern as
the reflex addr fields.
### Relay cross-wiring (wzp-relay/main.rs)
Both the local-call and cross-relay-federation paths now track
the local_addrs through the registry and inject them into the
CallSetup's peer_local_addrs. Cross-wiring is identical to the
existing peer_direct_addr logic — each party's CallSetup
carries the OTHER party's LAN candidates.
### Client side (desktop/src-tauri/lib.rs)
- `place_call`: gathers local host candidates via
`local_host_candidates(signal_endpoint.local_addr().port())`
and includes them in `DirectCallOffer.caller_local_addrs`.
The port match is critical — it's the Phase 5 shared signal
socket, so incoming dials to these addrs land on the same
endpoint that's already listening.
- `answer_call`: same, AcceptTrusted only (privacy mode keeps
LAN addrs hidden too, for consistency with the reflex addr).
- `connect` Tauri command: new `peer_local_addrs: Vec<String>`
arg. Builds a `PeerCandidates` bundle and passes it to the
dual-path race.
- Recv loop's CallSetup handler: destructures + forwards the
new field to JS via the signal-event payload.
### `dual_path::race` (wzp-client/dual_path.rs)
Signature change: takes `PeerCandidates` (reflex + local Vec)
instead of a single SocketAddr. The D-role branch now fans out
N parallel dials via `tokio::task::JoinSet` — one per candidate
— and the first successful dial wins (losers are aborted
immediately via `set.abort_all()`). Only when ALL candidates
have failed do we return Err; individual candidate failures are
just traced at debug level and the race waits for the others.
LAN host candidates are tried BEFORE the reflex addr in
`PeerCandidates::dial_order()` — they're faster when they work,
and the reflex addr is the fallback for the not-on-same-LAN
case.
### JS side (desktop/main.ts)
`connect` invoke now passes `peerLocalAddrs: data.peer_local_addrs ?? []`
alongside the existing `peerDirectAddr`.
### Tests
All existing test callsites updated for the new Vec<String>
fields (defaults to Vec::new() in tests — they don't exercise
the multi-candidate path). `dual_path.rs` integration tests
wrap the single `dead_peer` / `acceptor_listen_addr` in a
`PeerCandidates { reflexive: Some(_), local: Vec::new() }`.
Full workspace test: 423 passing (same as before 5.5).
## Expected behavior on the reporter's setup
Two phones behind MikroTik, both on the same LAN:
place_call:host_candidates {"local_addrs": ["192.168.88.21:XXX", "2001:...:YY:XXX"]}
recv:DirectCallAnswer {"callee_local_addrs": ["192.168.88.22:ZZZ", "2001:...:WW:ZZZ"]}
recv:CallSetup {"peer_direct_addr":"150.228.49.65:NN",
"peer_local_addrs":["192.168.88.22:ZZZ","2001:...:WW:ZZZ"]}
connect:dual_path_race_start {"peer_reflex":"...","peer_local":[...]}
dual_path: direct dial succeeded on candidate 0 ← LAN v4 wins
connect:dual_path_race_won {"path":"Direct"}
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
112 lines
4.6 KiB
TOML
112 lines
4.6 KiB
TOML
[package]
|
|
name = "wzp-client"
|
|
version.workspace = true
|
|
edition.workspace = true
|
|
license.workspace = true
|
|
rust-version.workspace = true
|
|
description = "WarzonePhone client library — for Android (JNI) and Windows desktop"
|
|
|
|
[dependencies]
|
|
wzp-proto = { workspace = true }
|
|
wzp-codec = { workspace = true }
|
|
wzp-fec = { workspace = true }
|
|
wzp-crypto = { workspace = true }
|
|
wzp-transport = { workspace = true }
|
|
tokio = { workspace = true }
|
|
tracing = { workspace = true }
|
|
tracing-subscriber = { workspace = true }
|
|
async-trait = { workspace = true }
|
|
bytes = { workspace = true }
|
|
anyhow = "1"
|
|
serde = { workspace = true }
|
|
serde_json = "1"
|
|
chrono = "0.4"
|
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
|
cpal = { version = "0.15", optional = true }
|
|
libc = "0.2"
|
|
# Phase 5.5 — LAN host-candidate ICE: enumerate local network
|
|
# interface addresses for inclusion in DirectCallOffer/Answer so
|
|
# peers on the same LAN can direct-connect without NAT hairpinning
|
|
# through the WAN reflex addr (which many consumer NATs, including
|
|
# MikroTik's default masquerade, don't support).
|
|
if-addrs = "0.13"
|
|
|
|
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
|
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
|
# pulling in a crate that can only link against Apple frameworks.
|
|
[target.'cfg(target_os = "macos")'.dependencies]
|
|
coreaudio-rs = { version = "0.11", optional = true }
|
|
|
|
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
|
|
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
|
|
# only the audio + COM subfeatures we need — the crate is organized as
|
|
# a massive optional-feature tree, so enabling just these keeps compile
|
|
# times reasonable (~5s for these features vs ~60s for the full crate).
|
|
[target.'cfg(target_os = "windows")'.dependencies]
|
|
windows = { version = "0.58", optional = true, features = [
|
|
"Win32_Foundation",
|
|
"Win32_Media_Audio",
|
|
"Win32_Security",
|
|
"Win32_System_Com",
|
|
"Win32_System_Com_StructuredStorage",
|
|
"Win32_System_Threading",
|
|
"Win32_System_Variant",
|
|
] }
|
|
|
|
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
|
|
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
|
|
# webrtc-audio-processing` crate, which links against Debian's
|
|
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
|
|
#
|
|
# Note: we attempted the 2.x line with its `bundled` sub-feature first
|
|
# (which would give us AEC3 instead of AEC2), but both the crates.io
|
|
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
|
|
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
|
|
# --reconfigure unconditionally even on first-run empty build dirs,
|
|
# causing the bundled build to fail with "Directory does not contain a
|
|
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
|
|
# this entirely by linking the apt-provided library. AEC2 is older than
|
|
# AEC3 but still the same algorithm family — this is what PulseAudio's
|
|
# module-echo-cancel and PipeWire's filter-chain use by default on
|
|
# current Debian-family distros.
|
|
[target.'cfg(target_os = "linux")'.dependencies]
|
|
webrtc-audio-processing = { version = "0.3", optional = true }
|
|
|
|
[features]
|
|
default = []
|
|
audio = ["cpal"]
|
|
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
|
|
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
|
|
# module is also #[cfg(target_os = "macos")] in lib.rs).
|
|
vpio = ["dep:coreaudio-rs"]
|
|
# windows-aec enables a direct WASAPI capture backend that opens the
|
|
# microphone under AudioCategory_Communications, turning on Windows's
|
|
# OS-level communications audio processing (AEC + noise suppression +
|
|
# AGC). The `windows` dep is itself target-gated to Windows above, so
|
|
# enabling this feature on non-Windows targets is a no-op (the
|
|
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
|
|
windows-aec = ["dep:windows"]
|
|
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
|
|
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
|
|
# Teams) in-process, using the playback PCM as the reference signal for
|
|
# echo cancellation. The webrtc-audio-processing dep is target-gated to
|
|
# Linux above, so enabling this feature on non-Linux targets is a no-op
|
|
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
|
|
# lib.rs).
|
|
linux-aec = ["dep:webrtc-audio-processing"]
|
|
|
|
[[bin]]
|
|
name = "wzp-client"
|
|
path = "src/cli.rs"
|
|
|
|
[[bin]]
|
|
name = "wzp-bench"
|
|
path = "src/bench_cli.rs"
|
|
|
|
[dev-dependencies]
|
|
tokio = { workspace = true }
|
|
wzp-relay = { path = "../wzp-relay" }
|
|
wzp-crypto = { workspace = true }
|
|
wzp-proto = { workspace = true }
|
|
async-trait = { workspace = true }
|