1use 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 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 #[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 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
61async 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 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
97fn 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 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 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 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 if rest.get(idx + 1).is_some_and(|n| n.starts_with("ms")) {
141 times.push(v);
142 idx += 1; }
144 } else if let Some(stripped) = token.strip_suffix("ms") {
145 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 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 let o = v4.octets();
192 o[0] == 100 && (64..128).contains(&o[1])
193}
194
195fn 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 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 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 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)); 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 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}