Skip to main content

nd_300/diagnostics/
mod.rs

1pub mod adapter_hw_stats;
2pub mod adapters;
3pub mod arp;
4pub mod bufferbloat;
5pub mod captive_portal;
6pub mod connection_states;
7pub mod connections;
8pub mod dhcp;
9pub mod dns;
10pub mod dns_benchmark;
11pub mod dns_cache;
12pub mod firewall;
13pub mod gateway;
14pub mod interfaces;
15pub mod ipv6;
16pub mod latency;
17pub mod listening_ports;
18pub mod mtu;
19pub mod nat;
20pub mod ntp;
21pub mod packet_loss;
22pub mod ping;
23pub mod ports;
24pub mod protocol_stats;
25pub mod proxy;
26pub mod public_ip;
27pub mod reverse_dns;
28pub mod route_path;
29pub mod routing_table;
30pub mod shared_cache;
31pub mod speed;
32pub mod tls_inspection;
33pub mod traffic_counters;
34pub mod util;
35pub mod vpn;
36pub mod wifi;
37
38use serde::Serialize;
39
40use crate::config::Config;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub enum DiagnosticStatus {
44    Ok,
45    Warn,
46    Fail,
47    Skip,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct DiagnosticResult {
52    pub category: String,
53    pub status: DiagnosticStatus,
54    pub summary: String,
55    pub details: Option<String>,
56    /// True when this row was fabricated because the check did not finish
57    /// before the wall-clock cap — it is evidence of overall slowness, not of
58    /// this specific subsystem failing. The fix flow's triage skips these
59    /// rows; the exit code still treats them as Fail. (Additive, v3.4.0+.)
60    #[serde(skip_serializing_if = "std::ops::Not::not")]
61    pub timed_out: bool,
62}
63
64impl DiagnosticResult {
65    pub fn ok(category: impl Into<String>, summary: impl Into<String>) -> Self {
66        Self {
67            category: category.into(),
68            status: DiagnosticStatus::Ok,
69            summary: summary.into(),
70            details: None,
71            timed_out: false,
72        }
73    }
74
75    pub fn warn(category: impl Into<String>, summary: impl Into<String>) -> Self {
76        Self {
77            category: category.into(),
78            status: DiagnosticStatus::Warn,
79            summary: summary.into(),
80            details: None,
81            timed_out: false,
82        }
83    }
84
85    pub fn fail(category: impl Into<String>, summary: impl Into<String>) -> Self {
86        Self {
87            category: category.into(),
88            status: DiagnosticStatus::Fail,
89            summary: summary.into(),
90            details: None,
91            timed_out: false,
92        }
93    }
94
95    pub fn skip(category: impl Into<String>, summary: impl Into<String>) -> Self {
96        Self {
97            category: category.into(),
98            status: DiagnosticStatus::Skip,
99            summary: summary.into(),
100            details: None,
101            timed_out: false,
102        }
103    }
104
105    /// A fabricated Fail row for a check that did not finish before the
106    /// wall-clock cap. Marked `timed_out` so consumers (the fix flow's
107    /// triage, JSON scripters) can tell it apart from a real finding.
108    pub fn timed_out_fail(category: impl Into<String>) -> Self {
109        Self {
110            category: category.into(),
111            status: DiagnosticStatus::Fail,
112            summary: "Timed out — network severely degraded".to_string(),
113            details: None,
114            timed_out: true,
115        }
116    }
117
118    pub fn with_details(mut self, details: impl Into<String>) -> Self {
119        self.details = Some(details.into());
120        self
121    }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct DiagnosticResults {
126    pub timestamp: String,
127    pub adapters: DiagnosticResult,
128    pub interfaces: DiagnosticResult,
129    pub gateway: DiagnosticResult,
130    pub dns: DiagnosticResult,
131    pub public_ip: DiagnosticResult,
132    pub latency: DiagnosticResult,
133    pub speed: DiagnosticResult,
134    pub ports: DiagnosticResult,
135
136    // Detailed data for rendering
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub interface_details: Option<Vec<interfaces::InterfaceInfo>>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub adapter_details: Option<Vec<adapters::AdapterInfo>>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub gateway_details: Option<gateway::GatewayInfo>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub dns_details: Option<dns::DnsInfo>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub public_ip_details: Option<public_ip::PublicIpInfo>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub latency_details: Option<Vec<latency::LatencyResult>>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub speed_details: Option<crate::speedtest::SpeedTestResult>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub port_details: Option<Vec<ports::PortResult>>,
153
154    // Technician-mode deep diagnostics
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub technician: Option<TechnicianResults>,
157
158    /// True when the wall-clock cap fired mid-run: the rows above are partial
159    /// (completed checks are real; unfinished ones carry fabricated
160    /// `timed_out` Fail rows). (Additive, v3.4.0+.)
161    #[serde(skip_serializing_if = "std::ops::Not::not")]
162    pub timed_out: bool,
163}
164
165#[derive(Debug, Clone, Serialize)]
166pub struct TechnicianResults {
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub arp_table: Option<Vec<arp::ArpEntry>>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub routing_table: Option<Vec<routing_table::RouteEntry>>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub active_connections: Option<Vec<connections::ConnectionEntry>>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub listening_ports: Option<Vec<listening_ports::ListeningPort>>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub dhcp_info: Option<Vec<dhcp::DhcpLease>>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub protocol_stats: Option<protocol_stats::ProtocolStatistics>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub adapter_hw_stats: Option<Vec<adapter_hw_stats::AdapterHwStat>>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub proxy_config: Option<proxy::ProxyConfig>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub vpn_info: Option<Vec<vpn::VpnAdapter>>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub firewall_info: Option<firewall::FirewallInfo>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub dns_cache: Option<Vec<dns_cache::DnsCacheEntry>>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub ipv6_info: Option<ipv6::Ipv6Info>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub mtu_info: Option<Vec<mtu::MtuInfo>>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub connection_states: Option<connection_states::ConnectionStates>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub bufferbloat: Option<bufferbloat::BufferbloatResult>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub reverse_dns: Option<Vec<reverse_dns::ReverseDnsEntry>>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub tls_inspection: Option<tls_inspection::TlsInspectionResult>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub traffic_counters: Option<Vec<traffic_counters::TrafficCounter>>,
203
204    // ── Exhaustive tech mode additions (v3.4.0+) ──
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub route_path: Option<route_path::RoutePath>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub packet_loss: Option<Vec<packet_loss::LossResult>>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub nat_analysis: Option<nat::NatAnalysis>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub wifi: Option<Vec<wifi::WifiLink>>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub dns_benchmark: Option<dns_benchmark::DnsBenchmark>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub captive_portal: Option<captive_portal::CaptivePortalResult>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub clock_sync: Option<ntp::ClockSync>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub path_mtu: Option<mtu::PathMtu>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub arp_health: Option<arp::ArpHealth>,
223}
224
225/// Wall-clock cap for a full `run_all` pass.
226///
227/// Diagnostics shell out and resolve hostnames, which on a badly-broken
228/// network could still take longer than is useful even though each individual
229/// call is bounded. The cap must scale with `--speed-duration`: the
230/// diagnostic speed test runs the CF + NDT7 providers sequentially, each
231/// doing a download AND an upload for `speed_duration` seconds per direction,
232/// so the legitimate speed-test floor is ~`4 * speed_duration` (2 providers ×
233/// 2 directions). A fixed 90s would falsely truncate a deliberately long
234/// speed test (e.g. `--speed-duration 60`). 30s of headroom covers
235/// discovery/latency probes and the non-speed diagnostics; a 90s floor keeps
236/// the default/`--fast` behavior unchanged.
237pub fn run_all_cap(config: &Config) -> std::time::Duration {
238    const RUN_ALL_CAP_FLOOR: std::time::Duration = std::time::Duration::from_secs(90);
239    const RUN_ALL_CAP_HEADROOM: u64 = 30;
240    /// Extra budget for technician mode's long deep probes: the concurrent
241    /// T1 set (≤~30s internal budgets) + traceroute/sustained-loss pair
242    /// (≤60s) + the real bufferbloat test (≤35s), plus slack. Every probe is
243    /// individually bounded, so the cap stays a pure anti-hang backstop.
244    const TECH_DEEP_BUDGET_SECS: u64 = 150;
245
246    let base = if config.skip_speed {
247        RUN_ALL_CAP_FLOOR
248    } else {
249        std::time::Duration::from_secs(4 * config.speed_duration + RUN_ALL_CAP_HEADROOM)
250            .max(RUN_ALL_CAP_FLOOR)
251    };
252
253    if config.is_tech_mode() {
254        base + std::time::Duration::from_secs(TECH_DEEP_BUDGET_SECS)
255    } else {
256        base
257    }
258}
259
260pub async fn run_all(config: &Config, cap: std::time::Duration) -> DiagnosticResults {
261    let deadline = tokio::time::Instant::now() + cap;
262    let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
263    let mut timed_out = false;
264
265    // Spawn the core checks so that, if the deadline fires, the ones that
266    // already finished can still be harvested — the user gets partial results
267    // instead of nothing.
268    let mut adapters_h = tokio::spawn(adapters::check());
269    let mut interfaces_h = tokio::spawn(interfaces::check());
270    let mut gateway_h = tokio::spawn(gateway::check());
271    let mut dns_h = tokio::spawn(dns::check());
272    let mut public_ip_h = tokio::spawn(public_ip::check());
273    let mut latency_h = tokio::spawn(latency::check());
274    let mut ports_h = tokio::spawn(ports::check());
275
276    // Race the joined set against the deadline. `&mut JoinHandle` is a future
277    // (JoinHandle: Unpin), so on the deadline branch the handles are still
278    // owned and can be harvested individually below.
279    let completed = tokio::select! {
280        results = async {
281            tokio::join!(
282                &mut adapters_h,
283                &mut interfaces_h,
284                &mut gateway_h,
285                &mut dns_h,
286                &mut public_ip_h,
287                &mut latency_h,
288                &mut ports_h,
289            )
290        } => Some(results),
291        _ = tokio::time::sleep_until(deadline) => None,
292    };
293
294    let (
295        (adapters_result, adapter_details),
296        (interfaces_result, interface_details),
297        (gateway_result, gateway_details),
298        (dns_result, dns_details),
299        (public_ip_result, public_ip_details),
300        (latency_result, latency_details),
301        (ports_result, port_details),
302    ) = match completed {
303        Some((a, i, g, d, p, l, po)) => (
304            // A JoinError here means the check panicked; surface it as a
305            // timed-out-style fabricated row rather than crashing the run.
306            a.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Adapters"), Vec::new())),
307            i.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Network"), Vec::new())),
308            g.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Gateway"), None)),
309            d.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("DNS"), None)),
310            p.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Internet"), None)),
311            l.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Latency"), Vec::new())),
312            po.unwrap_or_else(|_| (DiagnosticResult::timed_out_fail("Ports"), Vec::new())),
313        ),
314        None => {
315            timed_out = true;
316            (
317                util::harvest_or(
318                    adapters_h,
319                    (DiagnosticResult::timed_out_fail("Adapters"), Vec::new()),
320                )
321                .await,
322                util::harvest_or(
323                    interfaces_h,
324                    (DiagnosticResult::timed_out_fail("Network"), Vec::new()),
325                )
326                .await,
327                util::harvest_or(
328                    gateway_h,
329                    (DiagnosticResult::timed_out_fail("Gateway"), None),
330                )
331                .await,
332                util::harvest_or(dns_h, (DiagnosticResult::timed_out_fail("DNS"), None)).await,
333                util::harvest_or(
334                    public_ip_h,
335                    (DiagnosticResult::timed_out_fail("Internet"), None),
336                )
337                .await,
338                util::harvest_or(
339                    latency_h,
340                    (DiagnosticResult::timed_out_fail("Latency"), Vec::new()),
341                )
342                .await,
343                util::harvest_or(
344                    ports_h,
345                    (DiagnosticResult::timed_out_fail("Ports"), Vec::new()),
346                )
347                .await,
348            )
349        }
350    };
351
352    // Run speed test sequentially (it needs to saturate bandwidth), bounded
353    // by whatever budget remains.
354    let now = tokio::time::Instant::now();
355    let (speed_result, speed_details) = if config.skip_speed {
356        (
357            DiagnosticResult::skip("Speed", "Speed test skipped (--fast)"),
358            None,
359        )
360    } else if timed_out || now >= deadline {
361        timed_out = true;
362        (
363            DiagnosticResult::skip("Speed", "Skipped — diagnostics timed out"),
364            None,
365        )
366    } else {
367        match tokio::time::timeout_at(deadline, speed::check(config)).await {
368            Ok(outcome) => outcome,
369            Err(_) => {
370                timed_out = true;
371                (DiagnosticResult::timed_out_fail("Speed"), None)
372            }
373        }
374    };
375
376    // Enrich adapter details with driver info in tech mode only (WMI query),
377    // and run the deep diagnostics with the remaining budget.
378    let mut adapter_details = adapter_details;
379    let technician = if config.is_tech_mode() {
380        if tokio::time::Instant::now() >= deadline {
381            timed_out = true;
382            None
383        } else {
384            let tech = tokio::time::timeout_at(
385                deadline,
386                Box::pin(async {
387                    adapters::enrich_driver_info(&mut adapter_details).await;
388                    run_technician_diagnostics(config, public_ip_details.as_ref()).await
389                }),
390            )
391            .await;
392            match tech {
393                Ok(t) => Some(t),
394                Err(_) => {
395                    timed_out = true;
396                    None
397                }
398            }
399        }
400    } else {
401        None
402    };
403
404    DiagnosticResults {
405        timestamp,
406        adapters: adapters_result,
407        interfaces: interfaces_result,
408        gateway: gateway_result,
409        dns: dns_result,
410        public_ip: public_ip_result,
411        latency: latency_result,
412        speed: speed_result,
413        ports: ports_result,
414        interface_details: Some(interface_details),
415        adapter_details: Some(adapter_details),
416        gateway_details,
417        dns_details,
418        public_ip_details,
419        latency_details: Some(latency_details),
420        speed_details,
421        port_details: Some(port_details),
422        technician,
423        timed_out,
424    }
425}
426
427async fn run_technician_diagnostics(
428    config: &Config,
429    public_ip: Option<&public_ip::PublicIpInfo>,
430) -> TechnicianResults {
431    if config.verbose {
432        eprintln!("[verbose] Running technician deep diagnostics...");
433    }
434
435    // Pre-fetch shared data to avoid duplicate subprocess calls
436    let cache = shared_cache::SharedCache::build_for_tech_mode().await;
437
438    // ── T1: concurrent, bandwidth-light modules ─────────────────────
439    // Each module future is boxed: a flat join! of 21 inlined async state
440    // machines is large enough to overflow the main thread's stack on
441    // Windows (observed as STATUS_STACK_OVERFLOW); boxing keeps the joined
442    // future itself small.
443    let (
444        arp_table,
445        routing,
446        conns,
447        listeners,
448        dhcp_info,
449        proto_stats,
450        hw_stats,
451        proxy_cfg,
452        vpn_adapters,
453        fw_info,
454        dns_c,
455        ipv6_i,
456        mtu_i,
457        conn_states,
458        rdns,
459        tls_insp,
460        traffic,
461        wifi_links,
462        dns_bench,
463        portal,
464        clock,
465    ) = tokio::join!(
466        Box::pin(arp::collect()),
467        Box::pin(routing_table::collect()),
468        Box::pin(connections::collect_with_cache(&cache)),
469        Box::pin(listening_ports::collect_with_cache(&cache)),
470        Box::pin(dhcp::collect_with_cache(&cache)),
471        Box::pin(protocol_stats::collect()),
472        Box::pin(adapter_hw_stats::collect_with_cache(&cache)),
473        Box::pin(proxy::collect()),
474        Box::pin(vpn::collect_with_cache(&cache)),
475        Box::pin(firewall::collect()),
476        Box::pin(dns_cache::collect()),
477        Box::pin(ipv6::collect_with_cache(&cache)),
478        Box::pin(mtu::collect()),
479        Box::pin(connection_states::collect_with_cache(&cache)),
480        Box::pin(reverse_dns::collect_with_cache(&cache)),
481        Box::pin(tls_inspection::collect()),
482        Box::pin(traffic_counters::collect_with_cache(&cache)),
483        Box::pin(wifi::collect()),
484        Box::pin(dns_benchmark::collect()),
485        Box::pin(captive_portal::collect()),
486        Box::pin(ntp::collect()),
487    );
488
489    // ── T2: long, latency-sensitive probes — isolated from T1's
490    // subprocess spawn-storm so ping timings aren't scheduling-jittered,
491    // and from bufferbloat's saturation below.
492    let (route, loss, path_mtu) = tokio::join!(
493        Box::pin(route_path::collect()),
494        Box::pin(packet_loss::collect()),
495        Box::pin(mtu::probe_path_mtu()),
496    );
497
498    // ── T2b: derived analyses — pure post-processing, ~0ms ──────────
499    let nat_analysis = nat::analyze(
500        cache.gateway_ip.as_deref(),
501        public_ip,
502        route.as_ref().map(|r| r.hops.as_slice()),
503    );
504    let arp_health = arp::assess_health(arp_table.as_deref(), cache.gateway_ip.as_deref());
505
506    // ── T3: bufferbloat LAST — it deliberately saturates the link and
507    // would corrupt T2's loss/latency numbers if overlapped.
508    let bufferbloat = Box::pin(bufferbloat::collect(config)).await;
509
510    TechnicianResults {
511        arp_table,
512        routing_table: routing,
513        active_connections: conns,
514        listening_ports: listeners,
515        dhcp_info,
516        protocol_stats: proto_stats,
517        adapter_hw_stats: hw_stats,
518        proxy_config: proxy_cfg,
519        vpn_info: vpn_adapters,
520        firewall_info: fw_info,
521        dns_cache: dns_c,
522        ipv6_info: ipv6_i,
523        mtu_info: mtu_i,
524        connection_states: conn_states,
525        bufferbloat,
526        reverse_dns: rdns,
527        tls_inspection: tls_insp,
528        traffic_counters: traffic,
529        route_path: route,
530        packet_loss: loss,
531        nat_analysis,
532        wifi: wifi_links,
533        dns_benchmark: dns_bench,
534        captive_portal: portal,
535        clock_sync: clock,
536        path_mtu,
537        arp_health,
538    }
539}