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:
@@ -116,11 +116,19 @@ async fn probe_reflect_addr_happy_path() {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: two loopback relays → Cone classification
|
||||
// Test 2: two loopback relays → probes succeed, classification is Unknown
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// With the private-IP filter added in the NAT classifier, loopback
|
||||
// reflex addrs (127.0.0.1) are dropped before classification —
|
||||
// they can't possibly indicate public-internet NAT state. So the
|
||||
// test now asserts:
|
||||
// - both probes succeed end-to-end (wire plumbing works)
|
||||
// - both return 127.0.0.1 (same-host is visible)
|
||||
// - the aggregated verdict is Unknown (no public probes)
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn detect_nat_type_two_loopback_relays_is_cone() {
|
||||
async fn detect_nat_type_two_loopback_relays_probes_work_but_classify_unknown() {
|
||||
let (addr_a, _h_a) = spawn_mock_relay().await;
|
||||
let (addr_b, _h_b) = spawn_mock_relay().await;
|
||||
|
||||
@@ -135,24 +143,13 @@ async fn detect_nat_type_two_loopback_relays_is_cone() {
|
||||
|
||||
assert_eq!(detection.probes.len(), 2);
|
||||
for p in &detection.probes {
|
||||
assert!(p.observed_addr.is_some(), "probe {:?} failed: {:?}", p.relay_name, p.error);
|
||||
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()
|
||||
@@ -167,14 +164,15 @@ async fn detect_nat_type_two_loopback_relays_is_cone() {
|
||||
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
|
||||
// Classification: loopback probes are filtered out of the
|
||||
// public-NAT classifier, so with 0 public probes the result
|
||||
// is Unknown.
|
||||
assert_eq!(
|
||||
detection.nat_type,
|
||||
NatType::Unknown,
|
||||
"loopback-only probes must not contribute to public NAT classification"
|
||||
);
|
||||
assert!(detection.consensus_addr.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user