From d273bf718b7a8a587c06665ace2f057ed3efa451 Mon Sep 17 00:00:00 2001 From: Vasilito Date: Tue, 14 Apr 2026 22:53:04 +0100 Subject: [PATCH] Add redbear-traceroute and redbear-mtr tools Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- local/recipes/system/redbear-mtr/recipe.toml | 8 + .../system/redbear-mtr/source/Cargo.toml | 12 + .../system/redbear-mtr/source/src/main.rs | 233 ++++++++++++ .../system/redbear-traceroute/recipe.toml | 8 + .../redbear-traceroute/source/Cargo.toml | 20 ++ .../redbear-traceroute/source/src/lib.rs | 332 ++++++++++++++++++ .../redbear-traceroute/source/src/main.rs | 150 ++++++++ 7 files changed, 763 insertions(+) create mode 100644 local/recipes/system/redbear-mtr/recipe.toml create mode 100644 local/recipes/system/redbear-mtr/source/Cargo.toml create mode 100644 local/recipes/system/redbear-mtr/source/src/main.rs create mode 100644 local/recipes/system/redbear-traceroute/recipe.toml create mode 100644 local/recipes/system/redbear-traceroute/source/Cargo.toml create mode 100644 local/recipes/system/redbear-traceroute/source/src/lib.rs create mode 100644 local/recipes/system/redbear-traceroute/source/src/main.rs diff --git a/local/recipes/system/redbear-mtr/recipe.toml b/local/recipes/system/redbear-mtr/recipe.toml new file mode 100644 index 00000000..0078e4c5 --- /dev/null +++ b/local/recipes/system/redbear-mtr/recipe.toml @@ -0,0 +1,8 @@ +[source] +path = "source" + +[build] +template = "cargo" + +[package.files] +"/usr/bin/redbear-mtr" = "redbear-mtr" diff --git a/local/recipes/system/redbear-mtr/source/Cargo.toml b/local/recipes/system/redbear-mtr/source/Cargo.toml new file mode 100644 index 00000000..d9e81d61 --- /dev/null +++ b/local/recipes/system/redbear-mtr/source/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "redbear-mtr" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-mtr" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +redbear-traceroute = { path = "../../redbear-traceroute/source" } diff --git a/local/recipes/system/redbear-mtr/source/src/main.rs b/local/recipes/system/redbear-mtr/source/src/main.rs new file mode 100644 index 00000000..1335140b --- /dev/null +++ b/local/recipes/system/redbear-mtr/source/src/main.rs @@ -0,0 +1,233 @@ +use anyhow::{bail, Result}; +use redbear_traceroute::{ + destination_port, format_reply_suffix, probe, resolve_destination, ProbeReply, +}; +use std::env; +use std::net::Ipv4Addr; +use std::time::Duration; + +const DEFAULT_CYCLES: usize = 10; +const DEFAULT_MAX_HOPS: u8 = 30; +const DEFAULT_TIMEOUT_MS: u64 = 1_000; +const DEFAULT_BASE_PORT: u16 = 33_434; + +#[derive(Default)] +struct HopStats { + responder: Option, + sent: usize, + received: usize, + last_ms: Option, + total_ms: f64, + best_ms: Option, + worst_ms: Option, + note: Option<&'static str>, +} + +impl HopStats { + fn record_reply(&mut self, reply: ProbeReply, rtt_ms: f64) { + self.responder = Some(reply.hop()); + self.received += 1; + self.last_ms = Some(rtt_ms); + self.total_ms += rtt_ms; + self.best_ms = Some(self.best_ms.map_or(rtt_ms, |best| best.min(rtt_ms))); + self.worst_ms = Some(self.worst_ms.map_or(rtt_ms, |worst| worst.max(rtt_ms))); + self.note = format_reply_suffix(reply); + } + + fn loss_percent(&self) -> f64 { + if self.sent == 0 { + 0.0 + } else { + ((self.sent - self.received) as f64 / self.sent as f64) * 100.0 + } + } + + fn avg_ms(&self) -> Option { + if self.received == 0 { + None + } else { + Some(self.total_ms / self.received as f64) + } + } +} + +struct Options { + destination: String, + cycles: usize, + max_hops: u8, + timeout: Duration, + base_port: u16, +} + +enum ParseOutcome { + Help(String), + Options(Options), +} + +fn usage(program: &str) -> String { + format!("Usage: {program} [-c cycles] [-m max_hops] [-w timeout_ms] [-p base_port] destination\n\nPath measurement tool built on redbear-traceroute. Real probing is available only when built for Redox.") +} + +fn parse_value( + args: &mut impl Iterator, + flag: &str, +) -> Result +where + T::Err: std::fmt::Display, +{ + let value = args + .next() + .ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))?; + value + .parse::() + .map_err(|err| anyhow::anyhow!("invalid value for {flag}: {err}")) +} + +fn parse_args() -> Result { + let mut args = env::args(); + let program = args.next().unwrap_or_else(|| "redbear-mtr".to_string()); + + let mut destination = None; + let mut cycles = DEFAULT_CYCLES; + let mut max_hops = DEFAULT_MAX_HOPS; + let mut timeout_ms = DEFAULT_TIMEOUT_MS; + let mut base_port = DEFAULT_BASE_PORT; + + let mut rest = args; + while let Some(arg) = rest.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))), + "-c" | "--cycles" => cycles = parse_value(&mut rest, arg.as_str())?, + "-m" | "--max-hops" => max_hops = parse_value(&mut rest, arg.as_str())?, + "-w" | "--timeout-ms" => timeout_ms = parse_value(&mut rest, arg.as_str())?, + "-p" | "--base-port" => base_port = parse_value(&mut rest, arg.as_str())?, + value if value.starts_with('-') => bail!("unknown option {value}\n{}", usage(&program)), + value => { + if destination.replace(value.to_string()).is_some() { + bail!("only one destination may be supplied\n{}", usage(&program)); + } + } + } + } + + let destination = destination.ok_or_else(|| anyhow::anyhow!(usage(&program)))?; + if cycles == 0 { + bail!("cycles must be at least 1"); + } + if max_hops == 0 { + bail!("max_hops must be at least 1"); + } + + Ok(ParseOutcome::Options(Options { + destination, + cycles, + max_hops, + timeout: Duration::from_millis(timeout_ms), + base_port, + })) +} + +fn print_metric(value: Option) { + match value { + Some(value) => print!("{:>7.1}", value), + None => print!("{:>7}", "*"), + } +} + +fn run() -> Result<()> { + let options = match parse_args()? { + ParseOutcome::Help(help) => { + println!("{help}"); + return Ok(()); + } + ParseOutcome::Options(options) => options, + }; + let destination = resolve_destination(&options.destination)?; + let mut hops = (0..usize::from(options.max_hops)) + .map(|_| HopStats::default()) + .collect::>(); + + for cycle in 0..options.cycles { + for ttl in 1..=options.max_hops { + let hop = &mut hops[usize::from(ttl - 1)]; + hop.sent += 1; + + let sequence = cycle * usize::from(options.max_hops) + usize::from(ttl - 1); + let dest_port = destination_port(options.base_port, sequence)?; + let observation = probe(destination, ttl, dest_port, options.timeout)?; + + if let Some(reply) = observation.reply { + hop.record_reply(reply, observation.rtt.as_secs_f64() * 1_000.0); + if reply.status != redbear_traceroute::ProbeStatus::Hop { + break; + } + } + } + } + + let last_hop = hops + .iter() + .rposition(|hop| hop.sent > 0) + .map(|idx| idx + 1) + .unwrap_or(0); + + println!( + "mtr report to {} ({}), {} cycles", + options.destination, destination, options.cycles + ); + println!( + "{:>3} {:<15} {:>6} {:>5} {:>7} {:>7} {:>7} {:>7} Note", + "Hop", "Host", "Loss%", "Snt", "Last", "Avg", "Best", "Wrst" + ); + + for (index, hop) in hops.into_iter().take(last_hop).enumerate() { + let host = hop + .responder + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "???".to_string()); + print!( + "{:>3}. {:<15} {:>5.1}% {:>5}", + index + 1, + host, + hop.loss_percent(), + hop.sent + ); + print_metric(hop.last_ms); + print_metric(hop.avg_ms()); + print_metric(hop.best_ms); + print_metric(hop.worst_ms); + println!(" {}", hop.note.unwrap_or("")); + } + + Ok(()) +} + +fn main() { + if let Err(err) = run() { + eprintln!("redbear-mtr: {err}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::HopStats; + + #[test] + fn loss_percent_is_zero_without_probes() { + let stats = HopStats::default(); + assert_eq!(stats.loss_percent(), 0.0); + assert_eq!(stats.avg_ms(), None); + } + + #[test] + fn avg_and_loss_track_sent_and_received_counts() { + let mut stats = HopStats::default(); + stats.sent = 4; + stats.received = 3; + stats.total_ms = 60.0; + + assert!((stats.loss_percent() - 25.0).abs() < f64::EPSILON); + assert_eq!(stats.avg_ms(), Some(20.0)); + } +} diff --git a/local/recipes/system/redbear-traceroute/recipe.toml b/local/recipes/system/redbear-traceroute/recipe.toml new file mode 100644 index 00000000..52af3ab4 --- /dev/null +++ b/local/recipes/system/redbear-traceroute/recipe.toml @@ -0,0 +1,8 @@ +[source] +path = "source" + +[build] +template = "cargo" + +[package.files] +"/usr/bin/redbear-traceroute" = "redbear-traceroute" diff --git a/local/recipes/system/redbear-traceroute/source/Cargo.toml b/local/recipes/system/redbear-traceroute/source/Cargo.toml new file mode 100644 index 00000000..df247a51 --- /dev/null +++ b/local/recipes/system/redbear-traceroute/source/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "redbear-traceroute" +version = "0.1.0" +edition = "2024" + +[lib] +name = "redbear_traceroute" +path = "src/lib.rs" + +[[bin]] +name = "redbear-traceroute" +path = "src/main.rs" + +[dependencies] +anyhow = "1" + +[target.'cfg(target_os = "redox")'.dependencies] +libc = "0.2" +libredox = "0.1" +syscall = { package = "redox_syscall", version = "0.7", features = ["std"] } diff --git a/local/recipes/system/redbear-traceroute/source/src/lib.rs b/local/recipes/system/redbear-traceroute/source/src/lib.rs new file mode 100644 index 00000000..8c5a1ab1 --- /dev/null +++ b/local/recipes/system/redbear-traceroute/source/src/lib.rs @@ -0,0 +1,332 @@ +use anyhow::{anyhow, bail, Context, Result}; +use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs}; +use std::time::Duration; + +const ICMP_UDP_EVENT_LEN: usize = 12; +const TRACE_KIND_TIME_EXCEEDED: u8 = 1; +const TRACE_KIND_DST_UNREACHABLE: u8 = 2; +#[cfg(target_os = "redox")] +const DEFAULT_PAYLOAD_LEN: usize = 32; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TraceKind { + TimeExceeded, + DestinationUnreachable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TraceEvent { + pub kind: TraceKind, + pub code: u8, + pub responder: Ipv4Addr, + pub source_port: u16, + pub dest_port: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProbeStatus { + Hop, + Reached, + Unreachable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProbeReply { + pub event: TraceEvent, + pub status: ProbeStatus, +} + +impl ProbeReply { + pub fn hop(self) -> Ipv4Addr { + self.event.responder + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ProbeObservation { + pub reply: Option, + pub rtt: Duration, +} + +pub fn resolve_destination(host: &str) -> Result { + match (host, 0) + .to_socket_addrs()? + .find_map(|addr| match addr.ip() { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }) { + Some(ip) => Ok(ip), + None => bail!("{host} did not resolve to an IPv4 destination"), + } +} + +pub fn destination_port(base_port: u16, sequence: usize) -> Result { + let port = u32::from(base_port) + .checked_add(u32::try_from(sequence).context("probe sequence overflow")?) + .ok_or_else(|| anyhow!("destination port overflow"))?; + + u16::try_from(port).map_err(|_| anyhow!("destination port overflow")) +} + +pub fn decode_icmp_udp_event(buf: &[u8]) -> Result { + if buf.len() != ICMP_UDP_EVENT_LEN { + bail!( + "unexpected ICMP traceroute event length: expected {}, got {}", + ICMP_UDP_EVENT_LEN, + buf.len() + ); + } + + let kind = match buf[0] { + TRACE_KIND_TIME_EXCEEDED => TraceKind::TimeExceeded, + TRACE_KIND_DST_UNREACHABLE => TraceKind::DestinationUnreachable, + other => bail!("unknown ICMP traceroute event kind {other}"), + }; + + Ok(TraceEvent { + kind, + code: buf[1], + responder: Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]), + source_port: u16::from_be_bytes([buf[8], buf[9]]), + dest_port: u16::from_be_bytes([buf[10], buf[11]]), + }) +} + +pub fn classify_reply(event: TraceEvent, destination: Ipv4Addr) -> ProbeReply { + let status = match event.kind { + TraceKind::TimeExceeded => ProbeStatus::Hop, + TraceKind::DestinationUnreachable if event.code == 3 && event.responder == destination => { + ProbeStatus::Reached + } + TraceKind::DestinationUnreachable => ProbeStatus::Unreachable, + }; + + ProbeReply { event, status } +} + +pub fn unreachable_label(code: u8) -> &'static str { + match code { + 0 => "!N", + 1 => "!H", + 2 => "!PR", + 3 => "!P", + 4 => "!F", + 5 => "!SR", + 9 => "!X", + 10 => "!XH", + 13 => "!A", + _ => "!U", + } +} + +pub fn format_reply_suffix(reply: ProbeReply) -> Option<&'static str> { + match reply.status { + ProbeStatus::Hop | ProbeStatus::Reached => None, + ProbeStatus::Unreachable => Some(unreachable_label(reply.event.code)), + } +} + +pub fn probe( + destination: Ipv4Addr, + ttl: u8, + dest_port: u16, + timeout: Duration, +) -> Result { + redox_probe(destination, ttl, dest_port, timeout) +} + +#[cfg(target_os = "redox")] +fn redox_probe( + destination: Ipv4Addr, + ttl: u8, + dest_port: u16, + timeout: Duration, +) -> Result { + use libredox::{flag, Fd}; + use std::fs::File; + use std::io::Write; + use std::mem; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; + use std::thread; + use std::time::Instant; + + fn open_udp_socket(destination: Ipv4Addr, dest_port: u16) -> Result { + let socket = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; + if socket < 0 { + return Err(std::io::Error::last_os_error()).context("failed to create UDP socket"); + } + + let socket = unsafe { OwnedFd::from_raw_fd(socket) }; + let mut addr: libc::sockaddr_in = unsafe { mem::zeroed() }; + addr.sin_family = libc::AF_INET as libc::sa_family_t; + addr.sin_port = dest_port.to_be(); + addr.sin_addr = libc::in_addr { + s_addr: u32::from_ne_bytes(destination.octets()), + }; + + let rc = unsafe { + libc::connect( + socket.as_raw_fd(), + &addr as *const libc::sockaddr_in as *const libc::sockaddr, + mem::size_of::() as libc::socklen_t, + ) + }; + if rc < 0 { + return Err(std::io::Error::last_os_error()).context("failed to connect UDP socket"); + } + + Ok(socket) + } + + fn udp_local_port(socket: RawFd) -> Result { + let mut addr: libc::sockaddr_in = unsafe { mem::zeroed() }; + let mut len = mem::size_of::() as libc::socklen_t; + let rc = unsafe { + libc::getsockname( + socket, + &mut addr as *mut libc::sockaddr_in as *mut libc::sockaddr, + &mut len, + ) + }; + if rc < 0 { + return Err(std::io::Error::last_os_error()).context("failed to query UDP source port"); + } + + Ok(u16::from_be(addr.sin_port)) + } + + fn set_hop_limit(socket: RawFd, ttl: u8) -> Result<()> { + let raw = syscall::dup(socket as usize, b"hop_limit") + .map_err(|err| anyhow!("failed to open hop_limit setting: {err}"))?; + let mut hop_limit = unsafe { File::from_raw_fd(raw as RawFd) }; + hop_limit + .write_all(&[ttl]) + .context("failed to set UDP hop_limit") + } + + fn open_icmp_socket(destination: Ipv4Addr, source_port: u16) -> Result { + let path = format!("/scheme/icmp/udp/{destination}/{source_port}"); + Fd::open(&path, flag::O_RDWR | flag::O_NONBLOCK, 0) + .map_err(|err| anyhow!("failed to open {path}: {err}")) + } + + fn send_probe(socket: RawFd, payload: &[u8]) -> Result<()> { + let written = unsafe { libc::send(socket, payload.as_ptr().cast(), payload.len(), 0) }; + if written < 0 { + return Err(std::io::Error::last_os_error()).context("failed to send UDP probe"); + } + if written as usize != payload.len() { + bail!( + "short UDP probe write: expected {}, got {}", + payload.len(), + written + ); + } + Ok(()) + } + + fn wait_for_icmp_event(icmp_fd: &mut Fd, timeout: Duration) -> Result> { + let started = Instant::now(); + let mut raw = [0_u8; ICMP_UDP_EVENT_LEN]; + + loop { + match icmp_fd.read(&mut raw) { + Ok(0) => bail!("ICMP traceroute socket closed unexpectedly"), + Ok(len) if len == raw.len() => return decode_icmp_udp_event(&raw).map(Some), + Ok(len) => bail!( + "unexpected ICMP traceroute event length: expected {}, got {}", + raw.len(), + len + ), + Err(err) if err.is_wouldblock() => { + if started.elapsed() >= timeout { + return Ok(None); + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => return Err(err).context("failed reading ICMP traceroute event"), + } + } + } + + let socket = open_udp_socket(destination, dest_port)?; + let source_port = udp_local_port(socket.as_raw_fd())?; + let mut icmp_fd = open_icmp_socket(destination, source_port)?; + set_hop_limit(socket.as_raw_fd(), ttl)?; + + let payload = vec![0x42_u8; DEFAULT_PAYLOAD_LEN]; + let started = Instant::now(); + send_probe(socket.as_raw_fd(), &payload)?; + let reply = + wait_for_icmp_event(&mut icmp_fd, timeout)?.map(|event| classify_reply(event, destination)); + + Ok(ProbeObservation { + reply, + rtt: started.elapsed(), + }) +} + +#[cfg(not(target_os = "redox"))] +fn redox_probe( + _destination: Ipv4Addr, + _ttl: u8, + _dest_port: u16, + _timeout: Duration, +) -> Result { + bail!("redbear-traceroute probing is only available when built for Redox") +} + +#[cfg(test)] +mod tests { + use super::{ + classify_reply, decode_icmp_udp_event, destination_port, format_reply_suffix, ProbeStatus, + TraceEvent, TraceKind, + }; + use std::net::Ipv4Addr; + + #[test] + fn decodes_time_exceeded_event() { + let raw = [1, 0, 0, 0, 192, 0, 2, 1, 0xa4, 0x10, 0x82, 0x9a]; + let event = decode_icmp_udp_event(&raw).expect("expected event decode"); + + assert_eq!(event.kind, TraceKind::TimeExceeded); + assert_eq!(event.responder, Ipv4Addr::new(192, 0, 2, 1)); + assert_eq!(event.source_port, 42_000); + assert_eq!(event.dest_port, 33_434); + } + + #[test] + fn classifies_target_port_unreachable_as_reached() { + let event = TraceEvent { + kind: TraceKind::DestinationUnreachable, + code: 3, + responder: Ipv4Addr::new(203, 0, 113, 9), + source_port: 42_000, + dest_port: 33_434, + }; + + let reply = classify_reply(event, Ipv4Addr::new(203, 0, 113, 9)); + assert_eq!(reply.status, ProbeStatus::Reached); + assert_eq!(format_reply_suffix(reply), None); + } + + #[test] + fn classifies_non_target_unreachable_as_terminal_error() { + let event = TraceEvent { + kind: TraceKind::DestinationUnreachable, + code: 1, + responder: Ipv4Addr::new(192, 0, 2, 7), + source_port: 42_000, + dest_port: 33_434, + }; + + let reply = classify_reply(event, Ipv4Addr::new(203, 0, 113, 9)); + assert_eq!(reply.status, ProbeStatus::Unreachable); + assert_eq!(format_reply_suffix(reply), Some("!H")); + } + + #[test] + fn destination_port_rejects_overflow() { + assert!(destination_port(u16::MAX, 1).is_err()); + } +} diff --git a/local/recipes/system/redbear-traceroute/source/src/main.rs b/local/recipes/system/redbear-traceroute/source/src/main.rs new file mode 100644 index 00000000..c906a943 --- /dev/null +++ b/local/recipes/system/redbear-traceroute/source/src/main.rs @@ -0,0 +1,150 @@ +use anyhow::{bail, Context, Result}; +use redbear_traceroute::{ + destination_port, format_reply_suffix, probe, resolve_destination, ProbeStatus, +}; +use std::env; +use std::time::Duration; + +const DEFAULT_MAX_HOPS: u8 = 30; +const DEFAULT_QUERIES: usize = 3; +const DEFAULT_TIMEOUT_MS: u64 = 1_000; +const DEFAULT_BASE_PORT: u16 = 33_434; + +struct Options { + destination: String, + max_hops: u8, + queries: usize, + timeout: Duration, + base_port: u16, +} + +enum ParseOutcome { + Help(String), + Options(Options), +} + +fn usage(program: &str) -> String { + format!( + "Usage: {program} [-m max_hops] [-q queries] [-w timeout_ms] [-p base_port] destination\n\nUDP-based traceroute for Red Bear OS. Real probing is available only when built for Redox." + ) +} + +fn parse_value( + args: &mut impl Iterator, + flag: &str, +) -> Result +where + T::Err: std::fmt::Display, +{ + let value = args + .next() + .ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))?; + value + .parse::() + .map_err(|err| anyhow::anyhow!("invalid value for {flag}: {err}")) +} + +fn parse_args() -> Result { + let mut args = env::args(); + let program = args + .next() + .unwrap_or_else(|| "redbear-traceroute".to_string()); + + let mut destination = None; + let mut max_hops = DEFAULT_MAX_HOPS; + let mut queries = DEFAULT_QUERIES; + let mut timeout_ms = DEFAULT_TIMEOUT_MS; + let mut base_port = DEFAULT_BASE_PORT; + + let mut rest = args; + while let Some(arg) = rest.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))), + "-m" | "--max-hops" => max_hops = parse_value(&mut rest, arg.as_str())?, + "-q" | "--queries" => queries = parse_value(&mut rest, arg.as_str())?, + "-w" | "--timeout-ms" => timeout_ms = parse_value(&mut rest, arg.as_str())?, + "-p" | "--base-port" => base_port = parse_value(&mut rest, arg.as_str())?, + value if value.starts_with('-') => bail!("unknown option {value}\n{}", usage(&program)), + value => { + if destination.replace(value.to_string()).is_some() { + bail!("only one destination may be supplied\n{}", usage(&program)); + } + } + } + } + + let destination = destination.ok_or_else(|| anyhow::anyhow!(usage(&program)))?; + if max_hops == 0 { + bail!("max_hops must be at least 1"); + } + if queries == 0 { + bail!("queries must be at least 1"); + } + + Ok(ParseOutcome::Options(Options { + destination, + max_hops, + queries, + timeout: Duration::from_millis(timeout_ms), + base_port, + })) +} + +fn run() -> Result<()> { + let options = match parse_args()? { + ParseOutcome::Help(help) => { + println!("{help}"); + return Ok(()); + } + ParseOutcome::Options(options) => options, + }; + let destination = resolve_destination(&options.destination) + .with_context(|| format!("failed to resolve {}", options.destination))?; + + println!( + "traceroute to {} ({}), {} hops max", + options.destination, destination, options.max_hops + ); + + for ttl in 1..=options.max_hops { + print!("{:>2}", ttl); + let mut stop = false; + + for query in 0..options.queries { + let sequence = (usize::from(ttl) - 1) * options.queries + query; + let dest_port = destination_port(options.base_port, sequence)?; + let observation = probe(destination, ttl, dest_port, options.timeout)?; + + match observation.reply { + Some(reply) => { + let rtt_ms = observation.rtt.as_secs_f64() * 1_000.0; + print!(" {} {:.1}ms", reply.hop(), rtt_ms); + if let Some(suffix) = format_reply_suffix(reply) { + print!(" {suffix}"); + } + if matches!( + reply.status, + ProbeStatus::Reached | ProbeStatus::Unreachable + ) { + stop = true; + } + } + None => print!(" *"), + } + } + + println!(); + if stop { + break; + } + } + + Ok(()) +} + +fn main() { + if let Err(err) = run() { + eprintln!("redbear-traceroute: {err}"); + std::process::exit(1); + } +}