Skip to main content

nd_300/diagnostics/
route_path.rs

1//! Route path analysis (traceroute) — deep diagnostic.
2//!
3//! One bounded traceroute to a well-known anycast target, parsed into a hop
4//! list with technician-grade analysis: first-hop latency (router health),
5//! the private→public boundary (where the ISP starts), and the largest
6//! latency jump with its segment (LAN / ISP / backbone).
7
8use serde::Serialize;
9
10use super::util;
11
12const TARGET: &str = "1.1.1.1";
13const MAX_HOPS: u32 = 15;
14
15#[derive(Debug, Clone, Serialize)]
16pub struct Hop {
17    pub number: u32,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub ip: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub avg_ms: Option<f64>,
22    pub timed_out: bool,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct LatencyJump {
27    pub from_hop: u32,
28    pub to_hop: u32,
29    pub delta_ms: f64,
30    /// "LAN", "ISP", or "backbone" — where in the path the jump sits.
31    pub segment: String,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct RoutePath {
36    pub target: String,
37    pub hops: Vec<Hop>,
38    pub reached: bool,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub first_hop_ms: Option<f64>,
41    /// First hop whose address is public (the ISP boundary).
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub isp_boundary_hop: Option<u32>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub largest_jump: Option<LatencyJump>,
46    pub assessment: String,
47    /// "ok" | "warn" | "fail" (deep modules don't affect exit codes; this is
48    /// for rendering/JSON consumers).
49    pub level: String,
50}
51
52pub async fn collect() -> Option<RoutePath> {
53    let transcript = run_traceroute().await?;
54    let hops = parse_transcript(&transcript);
55    if hops.is_empty() {
56        return None;
57    }
58    Some(analyze_hops(hops))
59}
60
61/// Run the platform traceroute, bounded by [`util::TRACE`].
62async fn run_traceroute() -> Option<String> {
63    #[cfg(windows)]
64    {
65        let mut cmd = tokio::process::Command::new("tracert");
66        cmd.args(["-d", "-h", &MAX_HOPS.to_string(), "-w", "800", TARGET]);
67        let output = util::run_with_timeout(cmd, util::TRACE).await?;
68        Some(String::from_utf8_lossy(&output.stdout).into_owned())
69    }
70
71    #[cfg(unix)]
72    {
73        let mut cmd = tokio::process::Command::new("traceroute");
74        cmd.args([
75            "-n",
76            "-m",
77            &MAX_HOPS.to_string(),
78            "-q",
79            "2",
80            "-w",
81            "1",
82            TARGET,
83        ]);
84        if let Some(output) = util::run_with_timeout(cmd, util::TRACE).await {
85            if output.status.success() || !output.stdout.is_empty() {
86                return Some(String::from_utf8_lossy(&output.stdout).into_owned());
87            }
88        }
89        // Fallback: tracepath (iputils — nearly always present, unprivileged).
90        let mut cmd = tokio::process::Command::new("tracepath");
91        cmd.args(["-n", "-m", &MAX_HOPS.to_string(), TARGET]);
92        let output = util::run_with_timeout(cmd, util::TRACE).await?;
93        Some(String::from_utf8_lossy(&output.stdout).into_owned())
94    }
95}
96
97/// Parse a tracert/traceroute/tracepath transcript into hops. The three
98/// formats share enough shape for one tolerant parser:
99/// - a leading hop number,
100/// - one or more `N ms` / `N.NN ms` / `<1 ms` time tokens,
101/// - an IPv4/IPv6 address token (tracert puts it last, traceroute first),
102/// - `*` for timed-out probes.
103fn parse_transcript(text: &str) -> Vec<Hop> {
104    let mut hops: Vec<Hop> = Vec::new();
105
106    for line in text.lines() {
107        let trimmed = line.trim();
108        if trimmed.is_empty() {
109            continue;
110        }
111        // Hop lines start with a number.
112        let mut parts = trimmed.split_whitespace();
113        let Some(first) = parts.next() else { continue };
114        let Ok(number) = first.parse::<u32>() else {
115            continue;
116        };
117        if number == 0 || number > MAX_HOPS {
118            continue;
119        }
120        // tracepath prints "1:" / duplicate hop lines; keep the first
121        // occurrence per hop number.
122        if hops.iter().any(|h: &Hop| h.number == number) {
123            continue;
124        }
125
126        let rest: Vec<&str> = trimmed.split_whitespace().skip(1).collect();
127        let mut times: Vec<f64> = Vec::new();
128        let mut ip: Option<String> = None;
129        let mut idx = 0;
130        while idx < rest.len() {
131            let token = rest[idx].trim_end_matches(':').trim_end_matches(',');
132            // "<1" then "ms" (tracert sub-millisecond)
133            if let Some(stripped) = token.strip_prefix('<') {
134                if let Ok(v) = stripped.parse::<f64>() {
135                    times.push(v);
136                }
137            } else if let Ok(v) = token.parse::<f64>() {
138                // A bare number followed by "ms" is a time; otherwise it's
139                // noise (e.g. tracepath's "pmtu 1500").
140                if rest.get(idx + 1).is_some_and(|n| n.starts_with("ms")) {
141                    times.push(v);
142                    idx += 1; // skip the "ms"
143                }
144            } else if let Some(stripped) = token.strip_suffix("ms") {
145                // traceroute sometimes prints "12.3ms" fused.
146                if let Ok(v) = stripped.parse::<f64>() {
147                    times.push(v);
148                }
149            } else if ip.is_none() && looks_like_ip(token) {
150                ip = Some(token.to_string());
151            }
152            idx += 1;
153        }
154
155        let timed_out = times.is_empty() && ip.is_none();
156        let avg_ms = if times.is_empty() {
157            None
158        } else {
159            Some(times.iter().sum::<f64>() / times.len() as f64)
160        };
161
162        hops.push(Hop {
163            number,
164            ip,
165            avg_ms,
166            timed_out,
167        });
168    }
169
170    hops
171}
172
173fn looks_like_ip(token: &str) -> bool {
174    token.parse::<std::net::IpAddr>().is_ok()
175}
176
177fn is_private_ip(ip: &str) -> bool {
178    match ip.parse::<std::net::IpAddr>() {
179        Ok(std::net::IpAddr::V4(v4)) => v4.is_private() || v4.is_link_local() || is_cgnat_v4(&v4),
180        Ok(std::net::IpAddr::V6(v6)) => {
181            // fc00::/7 unique-local, fe80::/10 link-local
182            let seg = v6.segments()[0];
183            (seg & 0xfe00) == 0xfc00 || (seg & 0xffc0) == 0xfe80
184        }
185        Err(_) => false,
186    }
187}
188
189fn is_cgnat_v4(v4: &std::net::Ipv4Addr) -> bool {
190    // 100.64.0.0/10
191    let o = v4.octets();
192    o[0] == 100 && (64..128).contains(&o[1])
193}
194
195/// Pure analysis over the hop list — unit-testable without a network.
196fn analyze_hops(hops: Vec<Hop>) -> RoutePath {
197    let reached = hops
198        .iter()
199        .any(|h| h.ip.as_deref() == Some(TARGET) && !h.timed_out);
200
201    let first_hop_ms = hops.iter().find(|h| h.number == 1).and_then(|h| h.avg_ms);
202
203    // First public-address hop = where the ISP path starts.
204    let isp_boundary_hop = hops
205        .iter()
206        .filter(|h| h.ip.is_some())
207        .find(|h| !is_private_ip(h.ip.as_deref().unwrap_or("")))
208        .map(|h| h.number);
209
210    // Largest latency increase between consecutive responding hops.
211    let responding: Vec<(&Hop, f64)> = hops
212        .iter()
213        .filter_map(|h| h.avg_ms.map(|ms| (h, ms)))
214        .collect();
215    let largest_jump = responding
216        .windows(2)
217        .filter_map(|w| {
218            let (from, from_ms) = w[0];
219            let (to, to_ms) = w[1];
220            let delta = to_ms - from_ms;
221            if delta <= 0.0 {
222                return None;
223            }
224            let segment = match isp_boundary_hop {
225                Some(boundary) if to.number < boundary => "LAN",
226                Some(boundary) if to.number <= boundary + 3 => "ISP",
227                Some(_) => "backbone",
228                None => "LAN",
229            };
230            Some(LatencyJump {
231                from_hop: from.number,
232                to_hop: to.number,
233                delta_ms: delta,
234                segment: segment.to_string(),
235            })
236        })
237        .max_by(|a, b| {
238            a.delta_ms
239                .partial_cmp(&b.delta_ms)
240                .unwrap_or(std::cmp::Ordering::Equal)
241        });
242
243    // Assessment.
244    let (assessment, level) = if !reached {
245        (
246            format!("Target {} not reached within {} hops", TARGET, MAX_HOPS),
247            "fail",
248        )
249    } else if first_hop_ms.is_some_and(|ms| ms > 10.0) {
250        (
251            format!(
252                "First hop (your router) is slow at {:.0}ms — check Wi-Fi signal or router load",
253                first_hop_ms.unwrap_or(0.0)
254            ),
255            "warn",
256        )
257    } else if largest_jump
258        .as_ref()
259        .is_some_and(|j| j.delta_ms > 100.0 && j.segment == "ISP")
260    {
261        (
262            "Large latency jump inside the ISP segment — possible congestion".to_string(),
263            "warn",
264        )
265    } else {
266        ("Path looks healthy".to_string(), "ok")
267    };
268
269    RoutePath {
270        target: TARGET.to_string(),
271        hops,
272        reached,
273        first_hop_ms,
274        isp_boundary_hop,
275        largest_jump,
276        assessment,
277        level: level.to_string(),
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    const TRACERT_TRANSCRIPT: &str = "\
286Tracing route to 1.1.1.1 over a maximum of 15 hops
287
288  1    <1 ms    <1 ms    <1 ms  192.168.1.1
289  2     8 ms     9 ms     8 ms  100.64.0.1
290  3    12 ms    11 ms    13 ms  68.86.92.25
291  4     *        *        *     Request timed out.
292  5    13 ms    14 ms    12 ms  1.1.1.1
293
294Trace complete.";
295
296    const TRACEROUTE_TRANSCRIPT: &str = "\
297traceroute to 1.1.1.1 (1.1.1.1), 15 hops max, 60 byte packets
298 1  192.168.1.1  0.512 ms  0.498 ms
299 2  100.64.0.1  8.123 ms  8.077 ms
300 3  68.86.92.25  12.401 ms  12.388 ms
301 4  * *
302 5  1.1.1.1  13.102 ms  13.097 ms";
303
304    #[test]
305    fn parses_tracert_transcript() {
306        let hops = parse_transcript(TRACERT_TRANSCRIPT);
307        assert_eq!(hops.len(), 5);
308        assert_eq!(hops[0].ip.as_deref(), Some("192.168.1.1"));
309        assert_eq!(hops[0].avg_ms, Some(1.0)); // "<1 ms" × 3
310        assert!(hops[3].timed_out);
311        assert_eq!(hops[4].ip.as_deref(), Some("1.1.1.1"));
312    }
313
314    #[test]
315    fn parses_traceroute_transcript() {
316        let hops = parse_transcript(TRACEROUTE_TRANSCRIPT);
317        assert_eq!(hops.len(), 5);
318        assert_eq!(hops[1].ip.as_deref(), Some("100.64.0.1"));
319        assert!(hops[1].avg_ms.is_some_and(|ms| (ms - 8.1).abs() < 0.1));
320        assert!(hops[3].timed_out);
321    }
322
323    #[test]
324    fn analysis_finds_boundary_and_reachability() {
325        let path = analyze_hops(parse_transcript(TRACERT_TRANSCRIPT));
326        assert!(path.reached);
327        // 192.168.1.1 private, 100.64.0.1 CGNAT (treated private) — first
328        // public hop is hop 3.
329        assert_eq!(path.isp_boundary_hop, Some(3));
330        assert_eq!(path.level, "ok");
331        assert!(path.first_hop_ms.is_some());
332    }
333
334    #[test]
335    fn unreached_target_is_fail_level() {
336        let hops = vec![
337            Hop {
338                number: 1,
339                ip: Some("192.168.1.1".to_string()),
340                avg_ms: Some(1.0),
341                timed_out: false,
342            },
343            Hop {
344                number: 2,
345                ip: None,
346                avg_ms: None,
347                timed_out: true,
348            },
349        ];
350        let path = analyze_hops(hops);
351        assert!(!path.reached);
352        assert_eq!(path.level, "fail");
353    }
354
355    #[test]
356    fn slow_first_hop_warns() {
357        let hops = vec![
358            Hop {
359                number: 1,
360                ip: Some("192.168.1.1".to_string()),
361                avg_ms: Some(35.0),
362                timed_out: false,
363            },
364            Hop {
365                number: 2,
366                ip: Some("1.1.1.1".to_string()),
367                avg_ms: Some(40.0),
368                timed_out: false,
369            },
370        ];
371        let path = analyze_hops(hops);
372        assert_eq!(path.level, "warn");
373        assert!(path.assessment.contains("router"));
374    }
375
376    #[test]
377    fn cgnat_addresses_count_as_private() {
378        assert!(is_private_ip("100.64.0.1"));
379        assert!(is_private_ip("192.168.1.1"));
380        assert!(!is_private_ip("68.86.92.25"));
381        assert!(!is_private_ip("1.1.1.1"));
382    }
383}