Phase A of hard NAT traversal (PRD-hard-nat.md):
- PortAllocation enum: PortPreserving / Sequential{delta} / Random / Unknown
- detect_port_allocation(): sequential STUN probes from single socket,
analyzes port sequence for allocation pattern
- classify_port_allocation(): pure function with jitter tolerance,
wraparound handling, 60% threshold for noisy sequences
- predict_ports(): generates target port range from last_port + delta
- HardNatProbe signal message: carries port_sequence, allocation
pattern, external_ip for peer coordination
- Relay forwards HardNatProbe to call peer
- Netcheck gains port_allocation field + format_report display
588 tests pass (17 new), 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
525 lines
17 KiB
Rust
525 lines
17 KiB
Rust
//! Phase 8 (Tailscale-inspired): Comprehensive network diagnostic.
|
|
//!
|
|
//! Probes STUN servers, relay infrastructure, port mapping
|
|
//! capabilities, IPv6 reachability, and NAT hairpinning in parallel
|
|
//! to produce a `NetcheckReport` that captures the client's network
|
|
//! environment at a point in time.
|
|
//!
|
|
//! Used for:
|
|
//! - Troubleshooting connectivity issues
|
|
//! - Automatic relay selection (Phase 5)
|
|
//! - Pre-call NAT assessment
|
|
//! - Quality prediction
|
|
|
|
use std::net::SocketAddr;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::portmap::{self, PortMapProtocol};
|
|
use crate::reflect::{self, NatType};
|
|
use crate::stun::{self, StunConfig};
|
|
|
|
/// Complete network diagnostic report.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct NetcheckReport {
|
|
/// NAT type classification (from combined STUN + relay probes).
|
|
pub nat_type: NatType,
|
|
/// Server-reflexive address (consensus from probes).
|
|
pub reflexive_addr: Option<String>,
|
|
/// Whether IPv4 connectivity is available.
|
|
pub ipv4_reachable: bool,
|
|
/// Whether IPv6 connectivity is available.
|
|
pub ipv6_reachable: bool,
|
|
/// Whether the NAT supports hairpinning (loopback to own
|
|
/// reflexive address).
|
|
pub hairpin_works: Option<bool>,
|
|
/// Which port mapping protocol is available (if any).
|
|
pub port_mapping: Option<PortMapProtocol>,
|
|
/// Per-relay latency measurements.
|
|
pub relay_latencies: Vec<RelayLatency>,
|
|
/// Preferred relay (lowest latency).
|
|
pub preferred_relay: Option<String>,
|
|
/// STUN latency to first responding server (ms).
|
|
pub stun_latency_ms: Option<u32>,
|
|
/// Whether UPnP is available on the gateway.
|
|
pub upnp_available: bool,
|
|
/// Whether PCP is available on the gateway.
|
|
pub pcp_available: bool,
|
|
/// Whether NAT-PMP is available on the gateway.
|
|
pub nat_pmp_available: bool,
|
|
/// Default gateway address.
|
|
pub gateway: Option<String>,
|
|
/// Total time taken for the diagnostic (ms).
|
|
pub duration_ms: u32,
|
|
/// Individual STUN probe results.
|
|
pub stun_probes: Vec<reflect::NatProbeResult>,
|
|
/// NAT port allocation pattern (sequential vs random).
|
|
pub port_allocation: Option<stun::PortAllocation>,
|
|
}
|
|
|
|
/// Latency to a specific relay.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct RelayLatency {
|
|
pub name: String,
|
|
pub addr: String,
|
|
pub rtt_ms: Option<u32>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Configuration for the netcheck run.
|
|
#[derive(Debug, Clone)]
|
|
pub struct NetcheckConfig {
|
|
/// STUN servers to probe.
|
|
pub stun_config: StunConfig,
|
|
/// Relay servers to probe (name, address pairs).
|
|
pub relays: Vec<(String, SocketAddr)>,
|
|
/// Per-probe timeout.
|
|
pub timeout: Duration,
|
|
/// Whether to test port mapping.
|
|
pub test_portmap: bool,
|
|
/// Whether to test IPv6.
|
|
pub test_ipv6: bool,
|
|
/// Local port for port mapping test (0 = skip).
|
|
pub local_port: u16,
|
|
}
|
|
|
|
impl Default for NetcheckConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
stun_config: StunConfig::default(),
|
|
relays: Vec::new(),
|
|
timeout: Duration::from_secs(5),
|
|
test_portmap: true,
|
|
test_ipv6: true,
|
|
local_port: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run a comprehensive network diagnostic.
|
|
///
|
|
/// Probes run in parallel for speed — the total time is bounded
|
|
/// by the slowest individual probe, not the sum.
|
|
pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
|
let start = Instant::now();
|
|
|
|
// Run all probes in parallel.
|
|
let stun_fut = stun::probe_stun_servers(&config.stun_config);
|
|
let relay_fut = probe_relays(&config.relays, config.timeout);
|
|
let portmap_fut = probe_portmap(config.test_portmap, config.local_port);
|
|
let gateway_fut = portmap::default_gateway();
|
|
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
|
|
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
|
|
|
|
let (stun_probes, relay_latencies, portmap_result, gateway_result, ipv6_reachable, port_alloc_result) =
|
|
tokio::join!(stun_fut, relay_fut, portmap_fut, gateway_result_fut(gateway_fut), ipv6_fut, port_alloc_fut);
|
|
|
|
// Classify NAT from STUN probes.
|
|
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
|
|
|
|
// Determine STUN latency (first successful probe).
|
|
let stun_latency_ms = stun_probes
|
|
.iter()
|
|
.filter_map(|p| p.latency_ms)
|
|
.min();
|
|
|
|
// IPv4 reachable if any STUN probe succeeded.
|
|
let ipv4_reachable = stun_probes
|
|
.iter()
|
|
.any(|p| p.observed_addr.is_some());
|
|
|
|
// Preferred relay = lowest RTT.
|
|
let preferred_relay = relay_latencies
|
|
.iter()
|
|
.filter_map(|r| r.rtt_ms.map(|rtt| (r.name.clone(), rtt)))
|
|
.min_by_key(|(_, rtt)| *rtt)
|
|
.map(|(name, _)| name);
|
|
|
|
// Port mapping availability.
|
|
let (port_mapping, nat_pmp_available, pcp_available, upnp_available) = match portmap_result {
|
|
Some(mapping) => {
|
|
let proto = mapping.protocol;
|
|
(
|
|
Some(proto),
|
|
proto == PortMapProtocol::NatPmp,
|
|
proto == PortMapProtocol::Pcp,
|
|
proto == PortMapProtocol::UPnP,
|
|
)
|
|
}
|
|
None => (None, false, false, false),
|
|
};
|
|
|
|
let gateway = match gateway_result {
|
|
Ok(gw) => Some(gw.to_string()),
|
|
Err(_) => None,
|
|
};
|
|
|
|
NetcheckReport {
|
|
nat_type,
|
|
reflexive_addr: consensus_addr,
|
|
ipv4_reachable,
|
|
ipv6_reachable,
|
|
hairpin_works: None, // TODO: implement hairpin test
|
|
port_mapping,
|
|
relay_latencies,
|
|
preferred_relay,
|
|
stun_latency_ms,
|
|
upnp_available,
|
|
pcp_available,
|
|
nat_pmp_available,
|
|
gateway,
|
|
duration_ms: start.elapsed().as_millis() as u32,
|
|
stun_probes,
|
|
port_allocation: Some(port_alloc_result.allocation),
|
|
}
|
|
}
|
|
|
|
/// Probe relay latencies via reflect.
|
|
async fn probe_relays(
|
|
relays: &[(String, SocketAddr)],
|
|
timeout: Duration,
|
|
) -> Vec<RelayLatency> {
|
|
if relays.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let timeout_ms = timeout.as_millis() as u64;
|
|
let mut set = tokio::task::JoinSet::new();
|
|
|
|
for (name, addr) in relays {
|
|
let name = name.clone();
|
|
let addr = *addr;
|
|
set.spawn(async move {
|
|
let start = Instant::now();
|
|
match reflect::probe_reflect_addr(addr, timeout_ms, None).await {
|
|
Ok((_observed, _latency)) => RelayLatency {
|
|
name,
|
|
addr: addr.to_string(),
|
|
rtt_ms: Some(start.elapsed().as_millis() as u32),
|
|
error: None,
|
|
},
|
|
Err(e) => RelayLatency {
|
|
name,
|
|
addr: addr.to_string(),
|
|
rtt_ms: None,
|
|
error: Some(e),
|
|
},
|
|
}
|
|
});
|
|
}
|
|
|
|
let mut results = Vec::with_capacity(relays.len());
|
|
while let Some(join_result) = set.join_next().await {
|
|
match join_result {
|
|
Ok(r) => results.push(r),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
|
|
// Sort by RTT (lowest first).
|
|
results.sort_by_key(|r| r.rtt_ms.unwrap_or(u32::MAX));
|
|
results
|
|
}
|
|
|
|
/// Attempt port mapping and return the mapping if successful.
|
|
async fn probe_portmap(
|
|
enabled: bool,
|
|
local_port: u16,
|
|
) -> Option<portmap::PortMapping> {
|
|
if !enabled || local_port == 0 {
|
|
return None;
|
|
}
|
|
portmap::acquire_port_mapping(local_port, None).await.ok()
|
|
}
|
|
|
|
/// Wrap the gateway future to handle the Result.
|
|
async fn gateway_result_fut(
|
|
fut: impl std::future::Future<Output = Result<std::net::Ipv4Addr, portmap::PortMapError>>,
|
|
) -> Result<std::net::Ipv4Addr, portmap::PortMapError> {
|
|
fut.await
|
|
}
|
|
|
|
/// Test IPv6 connectivity by attempting to bind and send on an IPv6 socket.
|
|
async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
|
|
if !enabled {
|
|
return false;
|
|
}
|
|
|
|
// Try to resolve and connect to an IPv6 STUN server.
|
|
let result = tokio::time::timeout(timeout, async {
|
|
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
|
|
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
|
|
// and we can send a packet, IPv6 is working.
|
|
let addr = stun::resolve_stun_server("stun.l.google.com:19302").await.ok()?;
|
|
if addr.is_ipv6() {
|
|
sock.send_to(&[0u8; 1], addr).await.ok()?;
|
|
Some(true)
|
|
} else {
|
|
// Server resolved to IPv4 — try binding to [::] at least
|
|
Some(false)
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Some(true)) => true,
|
|
_ => {
|
|
// Fallback: can we at least bind an IPv6 socket?
|
|
tokio::net::UdpSocket::bind("[::]:0").await.is_ok()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format a netcheck report as a human-readable string.
|
|
pub fn format_report(report: &NetcheckReport) -> String {
|
|
let mut out = String::new();
|
|
|
|
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
|
|
out.push_str(&format!(
|
|
"NAT Type: {:?}\n",
|
|
report.nat_type
|
|
));
|
|
out.push_str(&format!(
|
|
"Reflexive Addr: {}\n",
|
|
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
|
|
));
|
|
out.push_str(&format!(
|
|
"IPv4: {}\n",
|
|
if report.ipv4_reachable { "yes" } else { "no" }
|
|
));
|
|
out.push_str(&format!(
|
|
"IPv6: {}\n",
|
|
if report.ipv6_reachable { "yes" } else { "no" }
|
|
));
|
|
out.push_str(&format!(
|
|
"Gateway: {}\n",
|
|
report.gateway.as_deref().unwrap_or("(unknown)")
|
|
));
|
|
|
|
if let Some(ref alloc) = report.port_allocation {
|
|
out.push_str(&format!(
|
|
"Port Alloc: {alloc}\n"
|
|
));
|
|
}
|
|
|
|
out.push_str(&format!("\n--- Port Mapping ---\n"));
|
|
out.push_str(&format!(
|
|
"NAT-PMP: {} PCP: {} UPnP: {}\n",
|
|
if report.nat_pmp_available { "yes" } else { "no" },
|
|
if report.pcp_available { "yes" } else { "no" },
|
|
if report.upnp_available { "yes" } else { "no" },
|
|
));
|
|
if let Some(proto) = &report.port_mapping {
|
|
out.push_str(&format!("Active mapping: {:?}\n", proto));
|
|
}
|
|
|
|
if !report.stun_probes.is_empty() {
|
|
out.push_str(&format!("\n--- STUN Probes ---\n"));
|
|
for p in &report.stun_probes {
|
|
out.push_str(&format!(
|
|
" {} → {} ({}ms){}\n",
|
|
p.relay_name,
|
|
p.observed_addr.as_deref().unwrap_or("failed"),
|
|
p.latency_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
|
p.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
|
));
|
|
}
|
|
}
|
|
|
|
if !report.relay_latencies.is_empty() {
|
|
out.push_str(&format!("\n--- Relay Latencies ---\n"));
|
|
for r in &report.relay_latencies {
|
|
out.push_str(&format!(
|
|
" {} ({}) → {}ms{}\n",
|
|
r.name,
|
|
r.addr,
|
|
r.rtt_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
|
r.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
|
));
|
|
}
|
|
if let Some(ref pref) = report.preferred_relay {
|
|
out.push_str(&format!(" Preferred: {pref}\n"));
|
|
}
|
|
}
|
|
|
|
out.push_str(&format!("\nCompleted in {}ms\n", report.duration_ms));
|
|
out
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_config_has_stun_servers() {
|
|
let config = NetcheckConfig::default();
|
|
assert!(!config.stun_config.servers.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn format_report_produces_output() {
|
|
let report = NetcheckReport {
|
|
nat_type: NatType::Cone,
|
|
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
ipv4_reachable: true,
|
|
ipv6_reachable: false,
|
|
hairpin_works: None,
|
|
port_mapping: None,
|
|
relay_latencies: vec![RelayLatency {
|
|
name: "relay-1".into(),
|
|
addr: "10.0.0.1:4433".into(),
|
|
rtt_ms: Some(25),
|
|
error: None,
|
|
}],
|
|
preferred_relay: Some("relay-1".into()),
|
|
stun_latency_ms: Some(15),
|
|
upnp_available: false,
|
|
pcp_available: false,
|
|
nat_pmp_available: false,
|
|
gateway: Some("192.168.1.1".into()),
|
|
duration_ms: 1500,
|
|
stun_probes: vec![],
|
|
port_allocation: None,
|
|
};
|
|
|
|
let text = format_report(&report);
|
|
assert!(text.contains("Cone"));
|
|
assert!(text.contains("203.0.113.5:4433"));
|
|
assert!(text.contains("relay-1"));
|
|
assert!(text.contains("1500ms"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_serializes_to_json() {
|
|
let report = NetcheckReport {
|
|
nat_type: NatType::Cone,
|
|
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
ipv4_reachable: true,
|
|
ipv6_reachable: false,
|
|
hairpin_works: None,
|
|
port_mapping: Some(PortMapProtocol::NatPmp),
|
|
relay_latencies: vec![],
|
|
preferred_relay: None,
|
|
stun_latency_ms: Some(25),
|
|
upnp_available: false,
|
|
pcp_available: false,
|
|
nat_pmp_available: true,
|
|
gateway: Some("192.168.1.1".into()),
|
|
duration_ms: 500,
|
|
stun_probes: vec![],
|
|
port_allocation: Some(stun::PortAllocation::Sequential { delta: 1 }),
|
|
};
|
|
let json = serde_json::to_string(&report).unwrap();
|
|
assert!(json.contains("Cone"));
|
|
assert!(json.contains("203.0.113.5:4433"));
|
|
assert!(json.contains("NatPmp"));
|
|
|
|
// Roundtrip
|
|
let decoded: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(decoded["ipv4_reachable"], true);
|
|
assert_eq!(decoded["ipv6_reachable"], false);
|
|
assert_eq!(decoded["stun_latency_ms"], 25);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_latency_serializes() {
|
|
let lat = RelayLatency {
|
|
name: "eu-west".into(),
|
|
addr: "10.0.0.1:4433".into(),
|
|
rtt_ms: Some(42),
|
|
error: None,
|
|
};
|
|
let json = serde_json::to_string(&lat).unwrap();
|
|
assert!(json.contains("eu-west"));
|
|
assert!(json.contains("42"));
|
|
}
|
|
|
|
#[test]
|
|
fn format_report_empty_relays() {
|
|
let report = NetcheckReport {
|
|
nat_type: NatType::Unknown,
|
|
reflexive_addr: None,
|
|
ipv4_reachable: false,
|
|
ipv6_reachable: false,
|
|
hairpin_works: None,
|
|
port_mapping: None,
|
|
relay_latencies: vec![],
|
|
preferred_relay: None,
|
|
stun_latency_ms: None,
|
|
upnp_available: false,
|
|
pcp_available: false,
|
|
nat_pmp_available: false,
|
|
gateway: None,
|
|
duration_ms: 100,
|
|
stun_probes: vec![],
|
|
port_allocation: None,
|
|
};
|
|
let text = format_report(&report);
|
|
assert!(text.contains("Unknown"));
|
|
assert!(text.contains("(unknown)")); // reflexive addr
|
|
assert!(text.contains("100ms"));
|
|
}
|
|
|
|
#[test]
|
|
fn format_report_with_stun_probes() {
|
|
let report = NetcheckReport {
|
|
nat_type: NatType::SymmetricPort,
|
|
reflexive_addr: None,
|
|
ipv4_reachable: true,
|
|
ipv6_reachable: true,
|
|
hairpin_works: Some(false),
|
|
port_mapping: Some(PortMapProtocol::UPnP),
|
|
relay_latencies: vec![
|
|
RelayLatency {
|
|
name: "us-east".into(),
|
|
addr: "10.0.0.1:4433".into(),
|
|
rtt_ms: Some(15),
|
|
error: None,
|
|
},
|
|
RelayLatency {
|
|
name: "eu-west".into(),
|
|
addr: "10.0.0.2:4433".into(),
|
|
rtt_ms: None,
|
|
error: Some("timeout".into()),
|
|
},
|
|
],
|
|
preferred_relay: Some("us-east".into()),
|
|
stun_latency_ms: Some(20),
|
|
upnp_available: true,
|
|
pcp_available: false,
|
|
nat_pmp_available: false,
|
|
gateway: Some("192.168.0.1".into()),
|
|
duration_ms: 3000,
|
|
stun_probes: vec![reflect::NatProbeResult {
|
|
relay_name: "stun:google".into(),
|
|
relay_addr: "74.125.250.129:19302".into(),
|
|
observed_addr: Some("203.0.113.5:12345".into()),
|
|
latency_ms: Some(20),
|
|
error: None,
|
|
}],
|
|
port_allocation: Some(stun::PortAllocation::Random),
|
|
};
|
|
let text = format_report(&report);
|
|
assert!(text.contains("SymmetricPort"));
|
|
assert!(text.contains("us-east"));
|
|
assert!(text.contains("eu-west"));
|
|
assert!(text.contains("Preferred: us-east"));
|
|
assert!(text.contains("UPnP: yes"));
|
|
assert!(text.contains("stun:google"));
|
|
assert!(text.contains("3000ms"));
|
|
}
|
|
|
|
/// Integration test: run actual netcheck (requires network).
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn integration_netcheck() {
|
|
let config = NetcheckConfig::default();
|
|
let report = run_netcheck(&config).await;
|
|
println!("{}", format_report(&report));
|
|
assert!(report.duration_ms > 0);
|
|
}
|
|
}
|