fix(reflect): drop LAN/private reflex addrs from NAT classification

Real-world report: a user with one LAN relay + one internet relay
got "Multiple IPs — treating as symmetric" because the LAN relay
saw the client's LAN IP (172.16.81.172) while the internet relay
saw the WAN IP (150.228.49.65). Two observations of "different
public IPs" from the classifier's perspective, but semantically
they describe two different network paths and shouldn't be
compared.

The LAN relay's reflection is always true, just not useful for
public NAT classification: there's no NAT between the client and
the LAN relay, so that path's reflex addr is always the LAN
interface IP regardless of what the public-facing NAT beyond it
looks like.

Fix: new `is_private_or_loopback` helper filters the probe set
before classification. Drops:
 - 127.0.0.0/8 loopback
 - 10/8, 172.16/12, 192.168/16 RFC1918 private
 - 169.254/16 link-local
 - 100.64/10 CGNAT shared-transition (same reasoning: a relay
   that sees the client with a CGNAT addr is on the same carrier
   network and can't describe public NAT state)
 - IPv6 loopback, unspecified, fe80::/10 link-local

Failed probes still filtered out of classification (they were
already) but now dimmed in the UI list instead of highlighted
amber. Same rationale: a momentarily-offline probe target isn't
a warning-worthy state, it's just a fact about the probe run.

UI palette rebalance: only Cone gets green, everything else
neutral text-dim. Wording changed from warning-tone
"⚠ must use relay" to informational "ℹ P2P falls back to relay,
calls still work" — symmetric NAT isn't broken state, it just
means media takes the relay path.

Tests added (4 new in wzp_client::reflect):
- classify_drops_private_ip_probes — LAN + public → Unknown
- classify_drops_loopback_probes — loopback + 2 public → Cone
- classify_drops_cgnat_probes — CGNAT + 2 public same-IP-
  diff-port → SymmetricPort
- classify_two_lan_probes_is_unknown_not_cone — all LAN → Unknown

Existing multi_reflect integration test updated: two loopback
relays now correctly classify as Unknown (because loopback reflex
addrs are filtered) with the plumbing-works invariant preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-11 18:29:09 +04:00
parent da08723fe7
commit 00deb97a5d
3 changed files with 157 additions and 37 deletions

View File

@@ -275,14 +275,63 @@ pub fn determine_role(
}
}
/// Returns `true` if the address is in an RFC1918 / link-local /
/// loopback range and therefore cannot possibly be a post-NAT
/// reflex address from the public internet's point of view.
///
/// A probe against a relay ON THE SAME LAN as the client will
/// naturally report the client's LAN IP back (because there's no
/// NAT between them) — that observation is real but says nothing
/// about the client's public-internet-facing NAT state. Mixing
/// LAN reflex addrs with public-internet reflex addrs in
/// `classify_nat` would always report `Multiple` (different IPs)
/// and falsely warn about symmetric NAT. Filter them out before
/// classifying.
fn is_private_or_loopback(addr: &SocketAddr) -> bool {
match addr.ip() {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|| v4.is_link_local() // 169.254/16
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40) // 100.64/10 CGNAT shared
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
}
}
/// Pure-function NAT classifier — split out for unit testing
/// without touching the network.
///
/// Only considers probes whose reflex addr is a **public-internet**
/// address. LAN / private / loopback reflex addrs are dropped
/// because they reflect the same-network path rather than the
/// real NAT state. CGNAT (100.64/10) is also treated as private
/// because the post-CGNAT address would be what we actually want
/// to classify on — but CGNAT is unreachable from outside the
/// carrier, so a relay seeing the CGNAT addr is on the same
/// carrier network and again not useful for classification.
pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) {
let successes: Vec<SocketAddr> = probes
// First: parse every successful probe's observed addr.
let parsed: Vec<SocketAddr> = probes
.iter()
.filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok()))
.collect();
// Then: drop LAN / private / loopback reflex addrs. Those are
// legitimate observations by same-network relays, but they
// don't contribute to NAT-type classification because the
// client's real public-facing NAT mapping is not involved on
// that path. A relay on the same LAN always sees the client's
// LAN IP, regardless of whether the NAT beyond it is cone or
// symmetric.
let successes: Vec<SocketAddr> = parsed
.into_iter()
.filter(|a| !is_private_or_loopback(a))
.collect();
if successes.len() < 2 {
return (NatType::Unknown, None);
}
@@ -365,6 +414,66 @@ mod tests {
assert!(addr.is_none());
}
#[test]
fn classify_drops_private_ip_probes() {
// One LAN probe + one public probe should behave like a
// single public probe — i.e. Unknown (not enough data to
// classify). This is the common real-world case: the user
// has a LAN relay + an internet relay configured, the LAN
// relay sees the LAN IP, the internet relay sees the WAN
// IP, and the old classifier would flag "Multiple" and
// falsely warn about symmetric NAT.
let probes = vec![
mk(Some("192.168.1.100:4433")), // LAN — must be dropped
mk(Some("203.0.113.5:4433")), // public (TEST-NET-3)
];
let (nt, _) = classify_nat(&probes);
assert_eq!(nt, NatType::Unknown);
}
#[test]
fn classify_drops_loopback_probes() {
let probes = vec![
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:4433")), // public, same addr
];
let (nt, addr) = classify_nat(&probes);
// Two public probes with identical addrs → Cone.
assert_eq!(nt, NatType::Cone);
assert_eq!(addr.as_deref(), Some("203.0.113.5:4433"));
}
#[test]
fn classify_drops_cgnat_probes() {
// 100.64.0.0/10 is the CGNAT shared-transition range.
// Filter treats it like RFC1918 — a relay that sees the
// client with a 100.64/10 addr is on the same CGNAT
// network and can't contribute to public NAT classification.
let probes = vec![
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:12345")), // public, different port
];
let (nt, _) = classify_nat(&probes);
// Two public probes same IP different port → SymmetricPort.
assert_eq!(nt, NatType::SymmetricPort);
}
#[test]
fn classify_two_lan_probes_is_unknown_not_cone() {
// Even if both probes come back from LAN relays, we can't
// say anything useful about the public NAT state. Unknown,
// not Cone.
let probes = vec![
mk(Some("192.168.1.100:4433")),
mk(Some("192.168.1.100:4433")),
];
let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Unknown);
assert!(addr.is_none());
}
#[test]
fn classify_mix_of_success_and_failure() {
let probes = vec![