//! 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, /// 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, /// Which port mapping protocol is available (if any). pub port_mapping: Option, /// Per-relay latency measurements. pub relay_latencies: Vec, /// Preferred relay (lowest latency). pub preferred_relay: Option, /// STUN latency to first responding server (ms). pub stun_latency_ms: Option, /// 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, /// Total time taken for the diagnostic (ms). pub duration_ms: u32, /// Individual STUN probe results. pub stun_probes: Vec, /// NAT port allocation pattern (sequential vs random). pub port_allocation: Option, } /// Latency to a specific relay. #[derive(Debug, Clone, Serialize)] pub struct RelayLatency { pub name: String, pub addr: String, pub rtt_ms: Option, pub error: Option, } /// 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 { 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 { 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>, ) -> Result { 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); } }