#![allow(clippy::single_match)]
#![allow(clippy::nonminimal_bool)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::needless_pass_by_value)]
use clap::Parser;
use ftr::{ProbeProtocol, SocketMode, TracerouteConfigBuilder, TracerouteError, TracerouteResult};
use std::net::IpAddr;
use std::time::{Duration, Instant};
fn get_version() -> &'static str {
if cfg!(debug_assertions) {
concat!(env!("CARGO_PKG_VERSION"), "-UNRELEASED")
} else {
env!("CARGO_PKG_VERSION")
}
}
#[derive(Parser, Debug)]
#[clap(author, version, about = "Fast parallel ICMP traceroute with ASN lookup", long_about = None)]
struct Args {
host: String,
#[clap(short, long, default_value_t = 1)]
start_ttl: u8,
#[clap(short = 'm', long, default_value_t = 30)]
max_hops: u8,
#[clap(long, default_value_t = 1000)]
probe_timeout_ms: u64,
#[clap(short = 'i', long, default_value_t = 0)]
send_launch_interval_ms: u64,
#[clap(short = 'W', long, default_value_t = 3000)]
overall_timeout_ms: u64,
#[clap(long)]
no_enrich: bool,
#[clap(long)]
no_rdns: bool,
#[clap(long, value_enum)]
protocol: Option<ProtocolArg>,
#[clap(long, value_enum)]
socket_mode: Option<SocketModeArg>,
#[clap(short = 'q', long, default_value_t = 1)]
queries: u8,
#[clap(long)]
json: bool,
#[clap(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[clap(short, long, default_value_t = 33434)]
port: u16,
#[clap(long)]
public_ip: Option<String>,
#[clap(long)]
stun_server: Option<String>,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum ProtocolArg {
Icmp,
Udp,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum SocketModeArg {
Raw,
Dgram,
}
#[derive(Debug, serde::Serialize)]
struct JsonHop {
ttl: u8,
segment: Option<String>,
address: Option<String>,
hostname: Option<String>,
asn_info: Option<ftr::AsnInfo>,
rtt_ms: Option<f64>,
}
#[derive(Debug, serde::Serialize)]
struct JsonOutput {
version: String,
target: String,
target_ip: String,
public_ip: Option<String>,
isp: Option<JsonIsp>,
destination_asn: Option<JsonAsn>,
hops: Vec<JsonHop>,
protocol: String,
socket_mode: String,
}
#[derive(Debug, serde::Serialize)]
struct JsonAsn {
asn: u32,
name: String,
country_code: String,
}
#[derive(Debug, serde::Serialize)]
struct JsonIsp {
asn: String,
name: String,
hostname: Option<String>,
}
fn main() {
let process_start = Instant::now();
let args: Vec<String> = std::env::args().collect();
if args.len() == 2 && (args[1] == "--help" || args[1] == "-h") {
let _ = Args::parse();
return;
}
if args.len() == 2 && (args[1] == "--version" || args[1] == "-V") {
println!("ftr {}", get_version());
return;
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create Tokio runtime");
let result = runtime.block_on(async_main(process_start));
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
async fn async_main(_process_start: Instant) -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let ftr_instance = ftr::Ftr::new();
if args.public_ip.is_none() {
if let Some(stun_server) = &args.stun_server {
std::env::set_var("FTR_STUN_SERVER", stun_server);
}
}
if args.start_ttl < 1 {
eprintln!("Error: start-ttl must be at least 1");
std::process::exit(1);
}
if args.probe_timeout_ms == 0 {
eprintln!("Error: probe-timeout-ms must be greater than 0");
std::process::exit(1);
}
if !ftr::socket::utils::is_root() && !ftr::socket::utils::has_non_root_capability() {
eprintln!(
"Error: ftr requires root privileges on {}",
std::env::consts::OS
);
eprintln!("This platform does not support unprivileged traceroute.");
eprintln!(
"Please run with sudo: sudo {}",
std::env::args().collect::<Vec<_>>().join(" ")
);
#[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
eprintln!(
"Or make the binary setuid root: sudo chown root:wheel ftr && sudo chmod u+s ftr"
);
std::process::exit(1);
}
let preferred_protocol = args.protocol.map(|p| match p {
ProtocolArg::Icmp => ProbeProtocol::Icmp,
ProtocolArg::Udp => ProbeProtocol::Udp,
});
let preferred_mode = args.socket_mode.map(|m| match m {
SocketModeArg::Raw => SocketMode::Raw,
SocketModeArg::Dgram => SocketMode::Dgram,
});
let target_ip = resolve_target(&args.host).await?;
{
let target_ip_clone = target_ip;
let no_rdns = args.no_rdns;
tokio::spawn(async move {
if !no_rdns {
let _ = ftr::dns::resolve_ptr(target_ip_clone).await;
}
});
}
let public_ip = if let Some(ip_str) = &args.public_ip {
match ip_str.parse::<IpAddr>() {
Ok(ip) => Some(ip),
Err(_) => {
eprintln!("Error: Invalid public IP address: {}", ip_str);
std::process::exit(1);
}
}
} else {
None
};
let mut builder = TracerouteConfigBuilder::new()
.target(&args.host)
.target_ip(target_ip)
.start_ttl(args.start_ttl)
.max_hops(args.max_hops)
.probe_timeout(Duration::from_millis(args.probe_timeout_ms))
.send_interval(Duration::from_millis(args.send_launch_interval_ms))
.overall_timeout(Duration::from_millis(args.overall_timeout_ms))
.queries_per_hop(args.queries)
.enable_asn_lookup(!args.no_enrich)
.enable_rdns(!args.no_rdns)
.verbose(args.verbose)
.port(args.port);
if let Some(ip) = public_ip {
builder = builder.public_ip(ip);
}
let config = builder.build();
let config = match config {
Ok(mut cfg) => {
cfg.protocol = preferred_protocol;
cfg.socket_mode = preferred_mode;
#[cfg(target_os = "windows")]
if args.probe_timeout_ms < 100 && (!args.no_enrich || !args.no_rdns) {
eprintln!(
"Warning: On Windows, probe timeouts < 100ms with enrichment enabled may cause"
);
eprintln!(
" unreliable results. Consider using --probe-timeout-ms 100 or higher,"
);
eprintln!(" or disable enrichment with --no-enrich --no-rdns");
eprintln!();
}
cfg
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
if args.port != 33434 && preferred_protocol == Some(ProbeProtocol::Icmp) {
eprintln!(
"Warning: Port {} specified but will be ignored for ICMP protocol",
args.port
);
}
if !args.json {
println!(
"ftr to {} ({}), {} max hops, {}ms probe timeout, {}ms overall timeout{}",
args.host,
target_ip,
args.max_hops,
args.probe_timeout_ms,
args.overall_timeout_ms,
if args.no_enrich {
" (enrichment disabled)"
} else {
""
}
);
if !args.no_enrich {
println!(
"\nPerforming ASN lookups{} and classifying segments...",
if args.no_rdns {
""
} else {
", reverse DNS lookups"
}
);
} else {
println!("\nTraceroute path (raw):");
}
}
let result = match ftr_instance.trace_with_config(config).await {
Ok(result) => result,
Err(TracerouteError::InsufficientPermissions {
required,
suggestion,
}) => {
eprintln!("\nError: Insufficient permissions");
eprintln!("Required: {}", required);
eprintln!("Suggestion: {}", suggestion);
eprintln!(
"\nTo run with elevated privileges: sudo {}",
std::env::args().collect::<Vec<_>>().join(" ")
);
std::process::exit(1);
}
Err(TracerouteError::NotImplemented { feature }) => {
eprintln!("\nError: {} is not yet implemented", feature);
eprintln!("This feature is planned for a future release.");
std::process::exit(1);
}
Err(TracerouteError::Ipv6NotSupported) => {
eprintln!("\nError: IPv6 targets are not yet supported");
eprintln!("Please use an IPv4 address or hostname that resolves to IPv4.");
std::process::exit(1);
}
Err(TracerouteError::ResolutionError(msg)) => {
eprintln!("\nError: {}", msg);
eprintln!("Please check the hostname and your network connection.");
std::process::exit(1);
}
Err(TracerouteError::ConfigError(msg)) => {
eprintln!("\nError: Invalid configuration - {}", msg);
eprintln!("Run 'ftr --help' for usage information.");
std::process::exit(1);
}
Err(e) => {
eprintln!("\nError: {}", e);
std::process::exit(1);
}
};
if args.json {
display_json_results(result)?;
} else {
display_text_results(result, args.no_enrich, args.no_rdns);
}
std::process::exit(0);
}
async fn resolve_target(host: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(ip);
}
if host == "localhost" {
return Ok(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
}
let addrs = ftr::dns::resolve_a(host)
.await
.map_err(|e| format!("Error resolving host: {e}"))?;
Ok(IpAddr::V4(addrs[0]))
}
fn display_json_results(result: TracerouteResult) -> Result<(), Box<dyn std::error::Error>> {
let mut json_output = JsonOutput {
version: get_version().to_string(),
target: result.target.clone(),
target_ip: result.target_ip.to_string(),
public_ip: result.isp_info.as_ref().map(|i| i.public_ip.to_string()),
isp: result.isp_info.as_ref().map(|i| JsonIsp {
asn: i.asn.to_string(),
name: i.name.clone(),
hostname: i.hostname.clone(),
}),
destination_asn: result.destination_asn.as_ref().map(|asn| JsonAsn {
asn: asn.asn,
name: asn.name.clone(),
country_code: asn.country_code.clone(),
}),
hops: Vec::new(),
protocol: result.protocol_used.description().to_string(),
socket_mode: result.socket_mode_used.description().to_string(),
};
for hop in result.hops.iter() {
let segment = match hop.segment {
ftr::SegmentType::Lan => Some("LAN".to_string()),
ftr::SegmentType::Isp => Some("ISP".to_string()),
ftr::SegmentType::Transit => Some("TRANSIT".to_string()),
ftr::SegmentType::Destination => Some("DESTINATION".to_string()),
ftr::SegmentType::Unknown => None,
};
json_output.hops.push(JsonHop {
ttl: hop.ttl,
segment,
address: hop.addr.map(|a| a.to_string()),
hostname: hop.hostname.clone(),
asn_info: hop.asn_info.clone(),
rtt_ms: hop.rtt_ms().map(|ms| (ms * 10.0).round() / 10.0), });
}
println!("{}", serde_json::to_string_pretty(&json_output)?);
Ok(())
}
fn display_text_results(result: TracerouteResult, no_enrich: bool, no_rdns: bool) {
let enrichment_disabled = no_enrich;
let mut last_responsive_ttl = 0u8;
for hop in result.hops.iter() {
if hop.addr.is_some() {
last_responsive_ttl = hop.ttl;
}
}
for hop in result.hops.iter() {
if hop.addr.is_none() {
if hop.ttl <= last_responsive_ttl {
println!("{:2}", hop.ttl);
}
} else {
let addr_str = hop.addr.map_or("*".to_string(), |a| a.to_string());
let rtt_str = hop
.rtt_ms()
.map_or("*".to_string(), |r| format!("{:.3} ms", r));
let host_display = if let (false, Some(hostname)) = (no_rdns, &hop.hostname) {
if hop.addr.is_some() {
format!("{} ({})", hostname, addr_str)
} else {
hostname.clone()
}
} else {
addr_str.clone()
};
let asn_str = if let Some(asn_info) = &hop.asn_info {
if asn_info.asn != 0 {
format!(
" [AS{} - {}, {}]",
asn_info.asn, asn_info.name, asn_info.country_code
)
} else {
format!(" [{}]", asn_info.name)
}
} else {
String::new()
};
if enrichment_disabled {
println!("{:2} {} {}", hop.ttl, host_display, rtt_str);
} else {
println!(
"{:2} [{}] {} {}{}",
hop.ttl, hop.segment, host_display, rtt_str, asn_str
);
}
}
}
if !result.destination_reached
&& last_responsive_ttl > 0
&& last_responsive_ttl < result.max_ttl().unwrap_or(30)
{
println!(
"\n[No further hops responded; max TTL was {}]",
result.max_ttl().unwrap_or(30)
);
}
if let Some(isp_info) = &result.isp_info {
if let (false, Some(hostname)) = (no_rdns, &isp_info.hostname) {
println!(
"\nDetected public IP: {} ({})",
isp_info.public_ip, hostname
);
} else {
println!("\nDetected public IP: {}", isp_info.public_ip);
}
println!("Detected ISP: AS{} ({})", isp_info.asn, isp_info.name);
}
if let Some(ref dest_asn) = result.destination_asn {
println!(
"Destination ASN: AS{} ({}, {})",
dest_asn.asn, dest_asn.name, dest_asn.country_code
);
}
}
#[cfg(test)]
#[path = "main_tests.rs"]
mod main_tests;
#[cfg(test)]
#[path = "main_v6_tests.rs"]
mod main_v6_tests;