use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceHop {
pub hop_number: u8,
pub address: Option<String>,
pub hostname: Option<String>,
pub latency_ms: f64,
}
pub async fn trace_route(host: &str) -> Result<Vec<TraceHop>> {
let output = tokio::task::spawn_blocking({
let host = host.to_string();
move || {
#[cfg(target_os = "windows")]
let cmd = Command::new("tracert")
.args(["-d", "-h", "30", &host])
.output();
#[cfg(not(target_os = "windows"))]
let cmd = Command::new("traceroute")
.args(["-n", "-m", "30", "-q", "1", &host])
.output();
cmd
}
})
.await??;
if !output.status.success() {
anyhow::bail!(
"Traceroute failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_traceroute_output(&stdout)
}
fn parse_traceroute_output(output: &str) -> Result<Vec<TraceHop>> {
let mut hops = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with("traceroute")
|| line.starts_with("Tracing")
|| line.starts_with("over a maximum")
{
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let hop_number: u8 = match parts[0].parse() {
Ok(n) => n,
Err(_) => continue,
};
if line.contains('*') || line.contains("Request timed out") {
hops.push(TraceHop {
hop_number,
address: None,
hostname: None,
latency_ms: 0.0,
});
continue;
}
let mut address: Option<String> = None;
let mut latency: f64 = 0.0;
for (i, part) in parts.iter().enumerate() {
if is_ip_address(part) {
address = Some(part.to_string());
}
if part.ends_with("ms") || (i + 1 < parts.len() && parts[i + 1] == "ms") {
let latency_str = part.trim_end_matches("ms");
if let Ok(l) = latency_str.parse::<f64>() {
latency = l;
}
}
if *part == "<1" && i + 1 < parts.len() && parts[i + 1] == "ms" {
latency = 0.5;
}
}
if address.is_some() {
hops.push(TraceHop {
hop_number,
address,
hostname: None,
latency_ms: latency,
});
}
}
Ok(hops)
}
fn is_ip_address(s: &str) -> bool {
if s.split('.').count() == 4 {
return s.split('.').all(|part| part.parse::<u8>().is_ok());
}
if s.contains(':') && !s.contains("ms") {
return true;
}
false
}
#[allow(dead_code)]
pub async fn trace_route_http(host: &str) -> Result<Vec<TraceHop>> {
let url = if host.starts_with("http") {
host.to_string()
} else {
format!("https://{}", host)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
let mut hops = Vec::new();
let start = Instant::now();
let _ = client.head(&url).send().await?;
let elapsed = start.elapsed();
hops.push(TraceHop {
hop_number: 1,
address: Some(host.to_string()),
hostname: Some(host.to_string()),
latency_ms: elapsed.as_secs_f64() * 1000.0,
});
Ok(hops)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_ip_address() {
assert!(is_ip_address("192.168.1.1"));
assert!(is_ip_address("8.8.8.8"));
assert!(!is_ip_address("google.com"));
assert!(!is_ip_address("10ms"));
}
#[test]
fn test_parse_traceroute_output() {
let output = r#"
traceroute to google.com (142.250.185.46), 30 hops max, 60 byte packets
1 192.168.1.1 0.5 ms
2 10.0.0.1 5.2 ms
3 * * *
4 142.250.185.46 15.3 ms
"#;
let hops = parse_traceroute_output(output).unwrap();
assert_eq!(hops.len(), 4);
assert_eq!(hops[0].hop_number, 1);
assert_eq!(hops[0].address, Some("192.168.1.1".to_string()));
}
}