use clap::{Parser, Subcommand}; use serde::Serialize; use std::net::ToSocketAddrs; use std::path::PathBuf; use wtfnet_core::{ init_logging, CommandEnvelope, CommandInfo, ErrItem, ExitKind, LogFormat, LogLevel, LoggingConfig, Meta, }; use wtfnet_platform::{Platform, PlatformError}; #[derive(Parser, Debug)] #[command( name = "wtfn", version, about = "WTFnet CLI toolbox", arg_required_else_help = true )] struct Cli { #[arg(long)] json: bool, #[arg(long)] pretty: bool, #[arg(long)] no_color: bool, #[arg(long)] quiet: bool, #[arg(short = 'v', action = clap::ArgAction::Count)] verbose: u8, #[arg(long)] log_level: Option, #[arg(long)] log_format: Option, #[arg(long)] log_file: Option, #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { Sys { #[command(subcommand)] command: SysCommand, }, Ports { #[command(subcommand)] command: PortsCommand, }, Neigh { #[command(subcommand)] command: NeighCommand, }, Cert { #[command(subcommand)] command: CertCommand, }, Geoip { #[command(subcommand)] command: GeoIpCommand, }, Probe { #[command(subcommand)] command: ProbeCommand, }, Dns { #[command(subcommand)] command: DnsCommand, }, Calc { #[command(subcommand)] command: CalcCommand, }, Http { #[command(subcommand)] command: HttpCommand, }, Tls { #[command(subcommand)] command: TlsCommand, }, Discover { #[command(subcommand)] command: DiscoverCommand, }, Diag(DiagArgs), } #[derive(Subcommand, Debug)] enum SysCommand { Ifaces, Ip(SysIpArgs), Route(SysRouteArgs), Dns, } #[derive(Subcommand, Debug)] enum PortsCommand { Listen(PortsListenArgs), Who(PortsWhoArgs), } #[derive(Subcommand, Debug)] enum NeighCommand { List(NeighListArgs), } #[derive(Subcommand, Debug)] enum CertCommand { Roots, } #[derive(Subcommand, Debug)] enum GeoIpCommand { Lookup(GeoIpLookupArgs), Status, } #[derive(Subcommand, Debug)] enum ProbeCommand { Ping(ProbePingArgs), Tcping(ProbeTcpingArgs), Trace(ProbeTraceArgs), } #[derive(Subcommand, Debug)] enum DnsCommand { Query(DnsQueryArgs), Detect(DnsDetectArgs), Watch(DnsWatchArgs), } #[derive(Subcommand, Debug)] enum CalcCommand { Subnet(CalcSubnetArgs), Contains(CalcContainsArgs), Overlap(CalcOverlapArgs), Summarize(CalcSummarizeArgs), } #[derive(Subcommand, Debug)] enum HttpCommand { Head(HttpRequestArgs), Get(HttpRequestArgs), } #[derive(Subcommand, Debug)] enum TlsCommand { Handshake(TlsArgs), Cert(TlsArgs), Verify(TlsArgs), Alpn(TlsArgs), } #[derive(Subcommand, Debug)] enum DiscoverCommand { Mdns(DiscoverMdnsArgs), Ssdp(DiscoverSsdpArgs), } #[derive(Parser, Debug, Clone)] struct SysIpArgs { #[arg(long)] all: bool, #[arg(long)] iface: Option, } #[derive(Parser, Debug, Clone)] struct SysRouteArgs { #[arg(long)] ipv4: bool, #[arg(long)] ipv6: bool, #[arg(long)] to: Option, } #[derive(Parser, Debug, Clone)] struct PortsListenArgs { #[arg(long)] tcp: bool, #[arg(long)] udp: bool, #[arg(long)] port: Option, } #[derive(Parser, Debug, Clone)] struct PortsWhoArgs { target: String, } #[derive(Parser, Debug, Clone)] struct NeighListArgs { #[arg(long)] ipv4: bool, #[arg(long)] ipv6: bool, #[arg(long)] iface: Option, } #[derive(Parser, Debug, Clone)] struct GeoIpLookupArgs { target: String, } #[derive(Parser, Debug, Clone)] struct ProbePingArgs { target: String, #[arg(long, default_value_t = 4)] count: u32, #[arg(long, default_value_t = 800)] timeout_ms: u64, #[arg(long, default_value_t = 200)] interval_ms: u64, #[arg(long)] no_geoip: bool, } #[derive(Parser, Debug, Clone)] struct ProbeTcpingArgs { target: String, #[arg(long, default_value_t = 4)] count: u32, #[arg(long, default_value_t = 800)] timeout_ms: u64, #[arg(long)] socks5: Option, #[arg(long)] prefer_ipv4: bool, #[arg(long)] no_geoip: bool, } #[derive(Parser, Debug, Clone)] struct ProbeTraceArgs { target: String, #[arg(long, default_value_t = 30)] max_hops: u8, #[arg(long, default_value_t = 800)] timeout_ms: u64, #[arg(long)] udp: bool, #[arg(long, default_value_t = 33434)] port: u16, #[arg(long)] no_geoip: bool, } #[derive(Parser, Debug, Clone)] struct DnsQueryArgs { domain: String, record_type: String, #[arg(long)] server: Option, #[arg(long, default_value = "udp")] transport: String, #[arg(long)] tls_name: Option, #[arg(long)] socks5: Option, #[arg(long)] prefer_ipv4: bool, #[arg(long, default_value_t = 2000)] timeout_ms: u64, } #[derive(Parser, Debug, Clone)] struct DnsDetectArgs { domain: String, #[arg(long)] servers: Option, #[arg(long, default_value = "udp")] transport: String, #[arg(long)] tls_name: Option, #[arg(long)] socks5: Option, #[arg(long)] prefer_ipv4: bool, #[arg(long, default_value_t = 3)] repeat: u32, #[arg(long, default_value_t = 2000)] timeout_ms: u64, } #[derive(Parser, Debug, Clone)] struct DnsWatchArgs { #[arg(long, default_value = "30s")] duration: String, #[arg(long)] iface: Option, #[arg(long)] filter: Option, } #[derive(Parser, Debug, Clone)] struct CalcSubnetArgs { input: Vec, } #[derive(Parser, Debug, Clone)] struct CalcContainsArgs { a: String, b: String, } #[derive(Parser, Debug, Clone)] struct CalcOverlapArgs { a: String, b: String, } #[derive(Parser, Debug, Clone)] struct CalcSummarizeArgs { cidrs: Vec, } #[derive(Parser, Debug, Clone)] struct HttpRequestArgs { url: String, #[arg(long, default_value_t = 3000)] timeout_ms: u64, #[arg(long)] follow_redirects: Option, #[arg(long)] show_headers: bool, #[arg(long)] show_body: bool, #[arg(long, default_value_t = 8192)] max_body_bytes: usize, #[arg(long)] http1_only: bool, #[arg(long)] http2_only: bool, #[arg(long)] geoip: bool, #[arg(long)] socks5: Option, } #[derive(Parser, Debug, Clone)] struct TlsArgs { target: String, #[arg(long)] sni: Option, #[arg(long)] alpn: Option, #[arg(long, default_value_t = 3000)] timeout_ms: u64, #[arg(long)] insecure: bool, #[arg(long)] socks5: Option, #[arg(long)] prefer_ipv4: bool, } #[derive(Parser, Debug, Clone)] struct DiscoverMdnsArgs { #[arg(long, default_value = "3s")] duration: String, #[arg(long)] service: Option, } #[derive(Parser, Debug, Clone)] struct DiscoverSsdpArgs { #[arg(long, default_value = "3s")] duration: String, } #[derive(Parser, Debug, Clone)] struct DiagArgs { #[arg(long)] out: Option, #[arg(long)] bundle: Option, #[arg(long)] dns_detect: Option, #[arg(long, default_value_t = 2000)] dns_timeout_ms: u64, #[arg(long, default_value_t = 3)] dns_repeat: u32, } #[derive(Debug, Clone, Serialize)] struct DnsAnswerGeoIp { pub name: String, pub record_type: String, pub ttl: u32, pub data: String, pub geoip: Option, } #[derive(Debug, Clone, Serialize)] struct DnsQueryReportGeoIp { pub domain: String, pub record_type: String, pub transport: String, pub server: Option, pub server_name: Option, pub server_geoip: Option, pub proxy: Option, pub rcode: String, pub answers: Vec, pub duration_ms: u128, } #[derive(Debug, Clone, Serialize)] struct DnsDetectResultGeoIp { pub verdict: String, pub evidence: Vec, pub results: Vec, } #[derive(Debug, Serialize)] struct CalcContainsReport { pub a: String, pub b: String, pub contains: bool, } #[derive(Debug, Serialize)] struct CalcOverlapReport { pub a: String, pub b: String, pub overlap: bool, } #[derive(Debug, Serialize)] struct CalcSummarizeReport { pub inputs: Vec, pub result: Vec, } #[derive(Debug, Serialize)] struct HttpReportGeoIp { pub url: String, pub final_url: Option, pub method: String, pub status: Option, pub http_version: Option, pub resolved_ips: Vec, pub geoip: Vec, pub headers: Vec<(String, String)>, pub body: Option, pub timing: wtfnet_http::HttpTiming, } #[tokio::main] async fn main() { let cli = Cli::parse(); let config = logging_config_from_cli(&cli); if let Err(err) = init_logging(&config) { eprintln!("failed to initialize logging: {err}"); std::process::exit(ExitKind::Failed.code()); } let exit_code = match &cli.command { Commands::Sys { command: SysCommand::Ifaces, } => handle_sys_ifaces(&cli).await, Commands::Sys { command: SysCommand::Ip(args), } => handle_sys_ip(&cli, args.clone()).await, Commands::Sys { command: SysCommand::Route(args), } => handle_sys_route(&cli, args.clone()).await, Commands::Sys { command: SysCommand::Dns, } => handle_sys_dns(&cli).await, Commands::Ports { command: PortsCommand::Listen(args), } => handle_ports_listen(&cli, args.clone()).await, Commands::Ports { command: PortsCommand::Who(args), } => handle_ports_who(&cli, args.clone()).await, Commands::Neigh { command: NeighCommand::List(args), } => handle_neigh_list(&cli, args.clone()).await, Commands::Cert { command: CertCommand::Roots, } => handle_cert_roots(&cli).await, Commands::Geoip { command: GeoIpCommand::Lookup(args), } => handle_geoip_lookup(&cli, args.clone()).await, Commands::Geoip { command: GeoIpCommand::Status, } => handle_geoip_status(&cli).await, Commands::Probe { command: ProbeCommand::Ping(args), } => handle_probe_ping(&cli, args.clone()).await, Commands::Probe { command: ProbeCommand::Tcping(args), } => handle_probe_tcping(&cli, args.clone()).await, Commands::Probe { command: ProbeCommand::Trace(args), } => handle_probe_trace(&cli, args.clone()).await, Commands::Dns { command: DnsCommand::Query(args), } => handle_dns_query(&cli, args.clone()).await, Commands::Dns { command: DnsCommand::Detect(args), } => handle_dns_detect(&cli, args.clone()).await, Commands::Dns { command: DnsCommand::Watch(args), } => handle_dns_watch(&cli, args.clone()).await, Commands::Calc { command: CalcCommand::Subnet(args), } => handle_calc_subnet(&cli, args.clone()).await, Commands::Calc { command: CalcCommand::Contains(args), } => handle_calc_contains(&cli, args.clone()).await, Commands::Calc { command: CalcCommand::Overlap(args), } => handle_calc_overlap(&cli, args.clone()).await, Commands::Calc { command: CalcCommand::Summarize(args), } => handle_calc_summarize(&cli, args.clone()).await, Commands::Http { command: HttpCommand::Head(args), } => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Head).await, Commands::Http { command: HttpCommand::Get(args), } => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Get).await, Commands::Tls { command: TlsCommand::Handshake(args), } => handle_tls_handshake(&cli, args.clone()).await, Commands::Tls { command: TlsCommand::Cert(args), } => handle_tls_cert(&cli, args.clone()).await, Commands::Tls { command: TlsCommand::Verify(args), } => handle_tls_verify(&cli, args.clone()).await, Commands::Tls { command: TlsCommand::Alpn(args), } => handle_tls_alpn(&cli, args.clone()).await, Commands::Discover { command: DiscoverCommand::Mdns(args), } => handle_discover_mdns(&cli, args.clone()).await, Commands::Discover { command: DiscoverCommand::Ssdp(args), } => handle_discover_ssdp(&cli, args.clone()).await, Commands::Diag(args) => handle_diag(&cli, args.clone()).await, }; std::process::exit(exit_code); } fn platform() -> Platform { #[cfg(windows)] { return wtfnet_platform_windows::platform(); } #[cfg(target_os = "linux")] { return wtfnet_platform_linux::platform(); } #[cfg(not(any(windows, target_os = "linux")))] { panic!("unsupported platform"); } } async fn handle_sys_ifaces(cli: &Cli) -> i32 { let result = platform().sys.interfaces().await; match result { Ok(interfaces) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("sys ifaces", Vec::new()); let envelope = CommandEnvelope::new(meta, command, interfaces); let json = if cli.pretty { serde_json::to_string_pretty(&envelope) } else { serde_json::to_string(&envelope) }; match json { Ok(payload) => { println!("{payload}"); ExitKind::Ok.code() } Err(err) => { eprintln!("failed to serialize json: {err}"); ExitKind::Failed.code() } } } else { for iface in interfaces { println!("{}", iface.name); if let Some(index) = iface.index { println!(" index: {index}"); } if let Some(mac) = iface.mac { println!(" mac: {mac}"); } if let Some(mtu) = iface.mtu { println!(" mtu: {mtu}"); } if let Some(is_up) = iface.is_up { println!(" state: {}", if is_up { "up" } else { "down" }); } for addr in iface.addresses { let prefix = addr .prefix_len .map(|value| format!("/{value}")) .unwrap_or_default(); if let Some(scope) = addr.scope { println!(" addr: {}{} ({})", addr.ip, prefix, scope); } else { println!(" addr: {}{}", addr.ip, prefix); } } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_sys_ip(cli: &Cli, args: SysIpArgs) -> i32 { let result = platform().sys.interfaces().await; match result { Ok(interfaces) => { let filtered = filter_interfaces_for_ip(interfaces, &args); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = Vec::new(); if args.all { command_args.push("--all".to_string()); } if let Some(iface) = args.iface.as_ref() { command_args.push("--iface".to_string()); command_args.push(iface.clone()); } let command = CommandInfo::new("sys ip", command_args); let envelope = CommandEnvelope::new(meta, command, filtered); emit_json(cli, &envelope) } else { for iface in filtered { println!("{}", iface.name); for addr in iface.addresses { let prefix = addr .prefix_len .map(|value| format!("/{value}")) .unwrap_or_default(); if let Some(scope) = addr.scope { println!(" addr: {}{} ({})", addr.ip, prefix, scope); } else { println!(" addr: {}{}", addr.ip, prefix); } } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_sys_route(cli: &Cli, args: SysRouteArgs) -> i32 { let result = platform().sys.routes().await; match result { Ok(routes) => { let filtered = filter_routes(routes, &args); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = Vec::new(); if args.ipv4 { command_args.push("--ipv4".to_string()); } if args.ipv6 { command_args.push("--ipv6".to_string()); } if let Some(target) = args.to.as_ref() { command_args.push("--to".to_string()); command_args.push(target.clone()); } let command = CommandInfo::new("sys route", command_args); let envelope = CommandEnvelope::new(meta, command, filtered); emit_json(cli, &envelope) } else { for route in filtered { let gateway = route.gateway.unwrap_or_else(|| "-".to_string()); let iface = route.interface.unwrap_or_else(|| "-".to_string()); if let Some(metric) = route.metric { println!( "{} via {} dev {} metric {}", route.destination, gateway, iface, metric ); } else { println!("{} via {} dev {}", route.destination, gateway, iface); } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_sys_dns(cli: &Cli) -> i32 { let result = platform().sys.dns_config().await; match result { Ok(snapshot) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("sys dns", Vec::new()); let envelope = CommandEnvelope::new(meta, command, snapshot); emit_json(cli, &envelope) } else { println!("servers:"); if snapshot.servers.is_empty() { println!(" -"); } else { for server in snapshot.servers { println!(" {server}"); } } println!("search:"); if snapshot.search_domains.is_empty() { println!(" -"); } else { for domain in snapshot.search_domains { println!(" {domain}"); } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_ports_listen(cli: &Cli, args: PortsListenArgs) -> i32 { let result = platform().ports.listening().await; match result { Ok(sockets) => { let filtered = filter_ports(sockets, &args); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = Vec::new(); if args.tcp { command_args.push("--tcp".to_string()); } if args.udp { command_args.push("--udp".to_string()); } if let Some(port) = args.port { command_args.push("--port".to_string()); command_args.push(port.to_string()); } let command = CommandInfo::new("ports listen", command_args); let envelope = CommandEnvelope::new(meta, command, filtered); emit_json(cli, &envelope) } else { for socket in filtered { if let Some(state) = socket.state.as_ref() { println!( "{} {} {} pid={}", socket.proto, socket.local_addr, state, socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) ); } else { println!( "{} {} pid={}", socket.proto, socket.local_addr, socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) ); } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_ports_who(cli: &Cli, args: PortsWhoArgs) -> i32 { let port = match parse_port_arg(&args.target) { Some(port) => port, None => { eprintln!("invalid port: {}", args.target); return ExitKind::Usage.code(); } }; let result = platform().ports.who_owns(port).await; match result { Ok(sockets) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("ports who", vec![args.target]); let envelope = CommandEnvelope::new(meta, command, sockets); emit_json(cli, &envelope) } else { for socket in sockets { println!( "{} {} pid={}", socket.proto, socket.local_addr, socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) ); } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_neigh_list(cli: &Cli, args: NeighListArgs) -> i32 { let result = platform().neigh.neighbors().await; match result { Ok(neighbors) => { let filtered = filter_neighbors(neighbors, &args); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = Vec::new(); if args.ipv4 { command_args.push("--ipv4".to_string()); } if args.ipv6 { command_args.push("--ipv6".to_string()); } if let Some(iface) = args.iface.as_ref() { command_args.push("--iface".to_string()); command_args.push(iface.clone()); } let command = CommandInfo::new("neigh list", command_args); let envelope = CommandEnvelope::new(meta, command, filtered); emit_json(cli, &envelope) } else { for entry in filtered { let mac = entry.mac.unwrap_or_else(|| "-".to_string()); let iface = entry.interface.unwrap_or_else(|| "-".to_string()); if let Some(state) = entry.state { println!("{} {} {} {}", entry.ip, mac, iface, state); } else { println!("{} {} {}", entry.ip, mac, iface); } } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_cert_roots(cli: &Cli) -> i32 { let result = platform().cert.trusted_roots().await; match result { Ok(roots) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("cert roots", Vec::new()); let envelope = CommandEnvelope::new(meta, command, roots); emit_json(cli, &envelope) } else { for root in roots { println!("subject: {}", root.subject); println!("issuer: {}", root.issuer); println!("valid: {} -> {}", root.not_before, root.not_after); println!("sha256: {}", root.sha256); println!("---"); } ExitKind::Ok.code() } } Err(err) => emit_platform_error(cli, err), } } async fn handle_geoip_lookup(cli: &Cli, args: GeoIpLookupArgs) -> i32 { let ip = match args.target.parse::() { Ok(ip) => ip, Err(_) => { eprintln!("invalid ip: {}", args.target); return ExitKind::Usage.code(); } }; let service = geoip_service(); let record = service.lookup(ip); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("geoip", vec![args.target]); let envelope = CommandEnvelope::new(meta, command, record); emit_json(cli, &envelope) } else { println!("ip: {}", record.ip); if let Some(country) = record.country { println!( "country: {} {}", country.name.unwrap_or_else(|| "-".to_string()), country.iso_code.unwrap_or_else(|| "-".to_string()) ); } else { println!("country: -"); } if let Some(asn) = record.asn { println!( "asn: {} {}", asn.number .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()), asn.organization.unwrap_or_else(|| "-".to_string()) ); } else { println!("asn: -"); } ExitKind::Ok.code() } } async fn handle_geoip_status(cli: &Cli) -> i32 { let service = geoip_service(); let status = service.status(); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("geoip status", Vec::new()); let envelope = CommandEnvelope::new(meta, command, status); emit_json(cli, &envelope) } else { println!( "country db: {}", status.country_db.unwrap_or_else(|| "-".to_string()) ); println!( "asn db: {}", status.asn_db.unwrap_or_else(|| "-".to_string()) ); println!( "loaded: country={} asn={}", status.country_loaded, status.asn_loaded ); ExitKind::Ok.code() } } async fn handle_probe_ping(cli: &Cli, args: ProbePingArgs) -> i32 { match wtfnet_probe::ping( &args.target, args.count, args.timeout_ms, args.interval_ms, ) .await { Ok(mut report) => { if !args.no_geoip { enrich_ping_geoip(&mut report); } if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("probe ping", vec![args.target]); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!( "target: {} ({})", report.target, report.ip.unwrap_or_else(|| "-".to_string()) ); if let Some(geoip) = report.geoip.as_ref() { println!("geoip: {}", format_geoip(geoip)); } for result in report.results { if let Some(rtt) = result.rtt_ms { println!("seq={} rtt={}ms", result.seq, rtt); } else { println!( "seq={} error={}", result.seq, result.error.unwrap_or_else(|| "-".to_string()) ); } } print_summary(&report.summary); ExitKind::Ok.code() } } Err(err) => { eprintln!("ping failed: {err}"); ExitKind::Failed.code() } } } async fn handle_probe_tcping(cli: &Cli, args: ProbeTcpingArgs) -> i32 { let (host, port) = match split_host_port(&args.target) { Some(value) => value, None => { eprintln!("invalid target: {}", args.target); return ExitKind::Usage.code(); } }; match wtfnet_probe::tcp_ping( &host, port, args.count, args.timeout_ms, args.socks5.as_deref(), args.prefer_ipv4, ) .await { Ok(mut report) => { if !args.no_geoip { enrich_tcp_geoip(&mut report); } if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = vec![args.target]; if let Some(proxy) = args.socks5 { command_args.push("--socks5".to_string()); command_args.push(proxy); } if args.prefer_ipv4 { command_args.push("--prefer-ipv4".to_string()); } let command = CommandInfo::new("probe tcping", command_args); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!( "target: {} ({})", report.target, report.ip.unwrap_or_else(|| "-".to_string()) ); if let Some(geoip) = report.geoip.as_ref() { println!("geoip: {}", format_geoip(geoip)); } for result in report.results { if let Some(rtt) = result.rtt_ms { println!("seq={} rtt={}ms", result.seq, rtt); } else { println!( "seq={} error={}", result.seq, result.error.unwrap_or_else(|| "-".to_string()) ); } } print_summary(&report.summary); ExitKind::Ok.code() } } Err(err) => { eprintln!("tcping failed: {err}"); ExitKind::Failed.code() } } } async fn handle_probe_trace(cli: &Cli, args: ProbeTraceArgs) -> i32 { let (host, port) = match split_host_port(&args.target).or_else(|| { if args.udp { Some((args.target.clone(), args.port)) } else { None } }) { Some(value) => value, None => { eprintln!("invalid target: {}", args.target); return ExitKind::Usage.code(); } }; let result = if args.udp { wtfnet_probe::udp_trace(&host, port, args.max_hops, args.timeout_ms).await } else { wtfnet_probe::tcp_trace(&host, port, args.max_hops, args.timeout_ms).await }; match result { Ok(mut report) => { if !args.no_geoip { enrich_trace_geoip(&mut report); } if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("probe trace", vec![args.target]); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!( "target: {} ({})", report.target, report.ip.unwrap_or_else(|| "-".to_string()) ); if let Some(geoip) = report.geoip.as_ref() { println!("geoip: {}", format_geoip(geoip)); } for hop in report.hops { let geoip = hop.geoip.as_ref().map(format_geoip); println!( "ttl={} addr={} rtt={}ms {}{}", hop.ttl, hop.addr.unwrap_or_else(|| "*".to_string()), hop.rtt_ms .map(|v| v.to_string()) .unwrap_or_else(|| "-".to_string()), hop.note.unwrap_or_default(), geoip .map(|value| format!(" geoip={value}")) .unwrap_or_default() ); } ExitKind::Ok.code() } } Err(err) => { eprintln!("trace failed: {err}"); ExitKind::Failed.code() } } } fn filter_interfaces_for_ip( interfaces: Vec, args: &SysIpArgs, ) -> Vec { let mut filtered = Vec::new(); for mut iface in interfaces { if let Some(filter) = args.iface.as_ref() { if iface.name != *filter { continue; } } if !args.all { iface.addresses.retain(|addr| !is_loopback_ip(&addr.ip)); } if iface.addresses.is_empty() { continue; } filtered.push(iface); } filtered } fn filter_routes( routes: Vec, args: &SysRouteArgs, ) -> Vec { let mut filtered = Vec::new(); for route in routes { if args.ipv4 && !is_ipv4_route(&route.destination) { continue; } if args.ipv6 && !is_ipv6_route(&route.destination) { continue; } if let Some(target) = args.to.as_ref() { if route.destination != *target { continue; } } filtered.push(route); } filtered } fn is_loopback_ip(value: &str) -> bool { value .parse::() .map(|ip| ip.is_loopback()) .unwrap_or(false) } fn is_ipv4_route(value: &str) -> bool { value.parse::().is_ok() } fn is_ipv6_route(value: &str) -> bool { value.parse::().is_ok() } fn filter_ports( sockets: Vec, args: &PortsListenArgs, ) -> Vec { let mut filtered = Vec::new(); for socket in sockets { if args.tcp && socket.proto != "tcp" { continue; } if args.udp && socket.proto != "udp" { continue; } if let Some(port) = args.port { if extract_port(&socket.local_addr) != Some(port) { continue; } } filtered.push(socket); } filtered } fn filter_neighbors( neighbors: Vec, args: &NeighListArgs, ) -> Vec { let mut filtered = Vec::new(); for entry in neighbors { if args.ipv4 && !is_ipv4_addr(&entry.ip) { continue; } if args.ipv6 && !is_ipv6_addr(&entry.ip) { continue; } if let Some(iface) = args.iface.as_ref() { if entry.interface.as_deref() != Some(iface.as_str()) { continue; } } filtered.push(entry); } filtered } fn is_ipv4_addr(value: &str) -> bool { value.parse::().is_ok() } fn is_ipv6_addr(value: &str) -> bool { value.parse::().is_ok() } fn extract_port(value: &str) -> Option { if let Some(pos) = value.rfind(':') { return value[pos + 1..].parse::().ok(); } None } fn parse_port_arg(value: &str) -> Option { if let Ok(port) = value.parse::() { return Some(port); } extract_port(value) } fn geoip_service() -> wtfnet_geoip::GeoIpService { let country = geoip_db_path("NETTOOL_GEOIP_COUNTRY_DB", "GeoLite2-Country.mmdb"); let asn = geoip_db_path("NETTOOL_GEOIP_ASN_DB", "GeoLite2-ASN.mmdb"); wtfnet_geoip::GeoIpService::new(country, asn) } fn geoip_db_path(env_key: &str, filename: &str) -> Option { if let Ok(value) = std::env::var(env_key) { let value = value.trim(); if !value.is_empty() { return Some(std::path::PathBuf::from(value)); } } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { let candidate = dir.join("data").join(filename); if candidate.exists() { return Some(candidate); } } } let candidate = std::path::PathBuf::from("data").join(filename); if candidate.exists() { return Some(candidate); } None } fn split_host_port(value: &str) -> Option<(String, u16)> { if let Some((host, port)) = value.rsplit_once(':') { let port = port.parse::().ok()?; return Some((host.to_string(), port)); } None } fn print_summary(summary: &wtfnet_probe::PingSummary) { println!( "sent={} received={} loss={:.1}%", summary.sent, summary.received, summary.loss_pct ); if let (Some(min), Some(avg), Some(max)) = (summary.min_ms, summary.avg_ms, summary.max_ms) { println!("min/avg/max={}ms/{:.1}ms/{}ms", min, avg, max); } } async fn handle_dns_query(cli: &Cli, args: DnsQueryArgs) -> i32 { let transport = match args.transport.parse::() { Ok(value) => value, Err(err) => { eprintln!("invalid transport: {err}"); return ExitKind::Usage.code(); } }; let server = match args.server.as_deref() { Some(value) => match parse_dns_server_target( value, transport, args.tls_name.as_deref(), args.prefer_ipv4, ) { Ok(addr) => Some(addr), Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } }, None => None, }; match wtfnet_dns::query( &args.domain, &args.record_type, server, transport, args.socks5.clone(), args.timeout_ms, ) .await { Ok(report) => { let service = geoip_service(); let enriched = enrich_dns_query_geoip(&report, &service); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new( "dns query", vec![args.domain.clone(), args.record_type.clone()], ); let envelope = CommandEnvelope::new(meta, command, enriched); emit_json(cli, &envelope) } else { println!("rcode: {}", enriched.rcode); println!("transport: {}", enriched.transport); if let Some(proxy) = enriched.proxy.as_ref() { println!("proxy: {proxy}"); } println!( "server: {}", enriched.server.clone().unwrap_or_else(|| "-".to_string()) ); if let Some(name) = enriched.server_name.as_ref() { println!("server name: {name}"); } if let Some(geoip) = enriched.server_geoip.as_ref() { println!("server geoip: {}", format_geoip(geoip)); } for answer in enriched.answers { let geoip = answer .geoip .as_ref() .map(format_geoip) .unwrap_or_else(|| "-".to_string()); println!( "{} {} {} {} geoip={}", answer.name, answer.ttl, answer.record_type, answer.data, geoip ); } ExitKind::Ok.code() } } Err(err) => { eprintln!("dns query failed: {err}"); ExitKind::Failed.code() } } } async fn handle_dns_detect(cli: &Cli, args: DnsDetectArgs) -> i32 { let transport = match args.transport.parse::() { Ok(value) => value, Err(err) => { eprintln!("invalid transport: {err}"); return ExitKind::Usage.code(); } }; let servers = if let Some(raw) = args.servers.as_deref() { let parsed = raw .split(',') .filter(|value| !value.trim().is_empty()) .map(|value| { parse_dns_server_target( value.trim(), transport, args.tls_name.as_deref(), args.prefer_ipv4, ) }) .collect::, _>>(); match parsed { Ok(values) => values, Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } } } else { wtfnet_dns::default_detect_servers(transport) }; match wtfnet_dns::detect( &args.domain, &servers, transport, args.socks5.clone(), args.repeat, args.timeout_ms, ) .await { Ok(report) => { let service = geoip_service(); let enriched = enrich_dns_detect_geoip(&report, &service); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("dns detect", vec![args.domain.clone()]); let envelope = CommandEnvelope::new(meta, command, enriched); emit_json(cli, &envelope) } else { println!("verdict: {}", enriched.verdict); println!("transport: {}", transport); if let Some(proxy) = args.socks5.as_ref() { println!("proxy: {proxy}"); } for ev in enriched.evidence { println!("evidence: {} {}", ev.code, ev.message); } for result in enriched.results { println!( "{} {} {} {}", result.server.clone().unwrap_or_else(|| "-".to_string()), result.rcode, result.duration_ms, format_answers_geoip(&result.answers) ); if let Some(geoip) = result.server_geoip.as_ref() { println!(" server geoip: {}", format_geoip(geoip)); } if let Some(name) = result.server_name.as_ref() { println!(" server name: {name}"); } } ExitKind::Ok.code() } } Err(err) => { eprintln!("dns detect failed: {err}"); ExitKind::Failed.code() } } } async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 { let duration_ms = match parse_duration_ms(&args.duration) { Ok(value) => value, Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } }; let options = wtfnet_dns::DnsWatchOptions { iface: args.iface.clone(), duration_ms, filter: args.filter.clone(), }; match wtfnet_dns::watch(options).await { Ok(report) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = vec!["--duration".to_string(), args.duration]; if let Some(iface) = args.iface { command_args.push("--iface".to_string()); command_args.push(iface); } if let Some(filter) = args.filter { command_args.push("--filter".to_string()); command_args.push(filter); } let command = CommandInfo::new("dns watch", command_args); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!( "iface: {} duration_ms: {} filter: {}", report.iface.unwrap_or_else(|| "-".to_string()), report.duration_ms, report.filter.unwrap_or_else(|| "-".to_string()) ); for event in report.events { let answers = if event.answers.is_empty() { "-".to_string() } else { event.answers.join(",") }; println!( "t={}ms {} -> {} {} {} rcode={} answers={}", event.timestamp_ms, event.src, event.dst, event.query_type, event.query_name, event.rcode, answers ); } ExitKind::Ok.code() } } Err(err) => { eprintln!("dns watch failed: {err}"); ExitKind::Failed.code() } } } async fn handle_calc_subnet(cli: &Cli, args: CalcSubnetArgs) -> i32 { let input = match normalize_subnet_input(&args.input) { Ok(value) => value, Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } }; match wtfnet_calc::subnet_info(&input) { Ok(info) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("calc subnet", vec![input]); let envelope = CommandEnvelope::new(meta, command, info); emit_json(cli, &envelope) } else { println!("cidr: {}", info.cidr); println!("network: {}", info.network); if let Some(broadcast) = info.broadcast.as_ref() { println!("broadcast: {broadcast}"); } println!("netmask: {}", info.netmask); println!("hostmask: {}", info.hostmask); println!("prefix: {}", info.prefix_len); println!("total: {}", info.total_addresses); println!("usable: {}", info.usable_addresses); if let Some(first) = info.first_host.as_ref() { println!("first: {first}"); } if let Some(last) = info.last_host.as_ref() { println!("last: {last}"); } ExitKind::Ok.code() } } Err(err) => { eprintln!("calc subnet failed: {err}"); ExitKind::Usage.code() } } } async fn handle_calc_contains(cli: &Cli, args: CalcContainsArgs) -> i32 { match wtfnet_calc::contains(&args.a, &args.b) { Ok(result) => { let report = CalcContainsReport { a: args.a.clone(), b: args.b.clone(), contains: result, }; if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("calc contains", vec![args.a, args.b]); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!("{}", if result { "yes" } else { "no" }); ExitKind::Ok.code() } } Err(err) => { eprintln!("calc contains failed: {err}"); ExitKind::Usage.code() } } } async fn handle_calc_overlap(cli: &Cli, args: CalcOverlapArgs) -> i32 { match wtfnet_calc::overlap(&args.a, &args.b) { Ok(result) => { let report = CalcOverlapReport { a: args.a.clone(), b: args.b.clone(), overlap: result, }; if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("calc overlap", vec![args.a, args.b]); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { println!("{}", if result { "yes" } else { "no" }); ExitKind::Ok.code() } } Err(err) => { eprintln!("calc overlap failed: {err}"); ExitKind::Usage.code() } } } async fn handle_calc_summarize(cli: &Cli, args: CalcSummarizeArgs) -> i32 { if args.cidrs.is_empty() { eprintln!("calc summarize requires at least one CIDR"); return ExitKind::Usage.code(); } match wtfnet_calc::summarize(&args.cidrs) { Ok(result) => { let result = result.iter().map(|net| net.to_string()).collect::>(); let report = CalcSummarizeReport { inputs: args.cidrs.clone(), result, }; if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("calc summarize", args.cidrs); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { for entry in report.result { println!("{entry}"); } ExitKind::Ok.code() } } Err(err) => { eprintln!("calc summarize failed: {err}"); ExitKind::Usage.code() } } } fn parse_dns_server_target( value: &str, transport: wtfnet_dns::DnsTransport, tls_name: Option<&str>, prefer_ipv4: bool, ) -> Result { let default_port = match transport { wtfnet_dns::DnsTransport::Udp | wtfnet_dns::DnsTransport::Tcp => 53, wtfnet_dns::DnsTransport::Dot => 853, wtfnet_dns::DnsTransport::Doh => 443, }; if let Ok(addr) = value.parse::() { let name = tls_name .map(|value| value.to_string()) .or_else(|| derive_tls_name(value)); return Ok(wtfnet_dns::DnsServerTarget { addr, name }); } if let Ok(ip) = value.parse::() { let addr = std::net::SocketAddr::new(ip, default_port); let name = tls_name.map(|value| value.to_string()); return Ok(wtfnet_dns::DnsServerTarget { addr, name }); } let (host, port) = split_host_port_with_default(value, default_port)?; let addr = resolve_host_port(&host, port, prefer_ipv4) .map_err(|_| format!("invalid server address: {value}"))? .ok_or_else(|| format!("unable to resolve server: {value}"))?; let name = tls_name .map(|value| value.to_string()) .or_else(|| derive_tls_name(&host)); if matches!( transport, wtfnet_dns::DnsTransport::Dot | wtfnet_dns::DnsTransport::Doh ) && name.is_none() { return Err(format!( "tls name is required for transport {}", transport )); } Ok(wtfnet_dns::DnsServerTarget { addr, name }) } fn split_host_port_with_default(value: &str, default_port: u16) -> Result<(String, u16), String> { if let Some(addr) = value.strip_prefix('[') { if let Some(end) = addr.find(']') { let host = &addr[..end]; let port = addr[end + 1..] .strip_prefix(':') .and_then(|value| value.parse::().ok()) .unwrap_or(default_port); return Ok((host.to_string(), port)); } } if let Some((host, port)) = value.rsplit_once(':') { if host.contains(':') { return Ok((value.to_string(), default_port)); } let port = port .parse::() .map_err(|_| format!("invalid port in server: {value}"))?; return Ok((host.to_string(), port)); } Ok((value.to_string(), default_port)) } fn resolve_host_port( host: &str, port: u16, prefer_ipv4: bool, ) -> Result, std::io::Error> { let mut iter = (host, port).to_socket_addrs()?; if prefer_ipv4 { let mut fallback = None; for addr in iter.by_ref() { if addr.is_ipv4() { return Ok(Some(addr)); } if fallback.is_none() { fallback = Some(addr); } } Ok(fallback) } else { Ok(iter.next()) } } async fn handle_http_request( cli: &Cli, args: HttpRequestArgs, method: wtfnet_http::HttpMethod, ) -> i32 { let opts = wtfnet_http::HttpRequestOptions { method, timeout_ms: args.timeout_ms, follow_redirects: args.follow_redirects, max_body_bytes: args.max_body_bytes, show_headers: args.show_headers, show_body: args.show_body, http1_only: args.http1_only, http2_only: args.http2_only, proxy: args.socks5.clone(), }; match wtfnet_http::request(&args.url, opts).await { Ok(report) => { let enriched = if args.geoip { let service = geoip_service(); let geoip = report .resolved_ips .iter() .filter_map(|value| value.parse::().ok()) .map(|ip| service.lookup(ip)) .collect::>(); HttpReportGeoIp { url: report.url.clone(), final_url: report.final_url.clone(), method: report.method.clone(), status: report.status, http_version: report.http_version.clone(), resolved_ips: report.resolved_ips.clone(), geoip, headers: report.headers.clone(), body: report.body.clone(), timing: report.timing.clone(), } } else { HttpReportGeoIp { url: report.url.clone(), final_url: report.final_url.clone(), method: report.method.clone(), status: report.status, http_version: report.http_version.clone(), resolved_ips: report.resolved_ips.clone(), geoip: Vec::new(), headers: report.headers.clone(), body: report.body.clone(), timing: report.timing.clone(), } }; if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("http request", vec![args.url]); let envelope = CommandEnvelope::new(meta, command, enriched); emit_json(cli, &envelope) } else { println!( "status: {}", report .status .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()) ); if let Some(url) = report.final_url.as_ref() { println!("final_url: {url}"); } if let Some(version) = report.http_version.as_ref() { println!("version: {version}"); } if !report.resolved_ips.is_empty() { println!("resolved: {}", report.resolved_ips.join(", ")); } println!("total_ms: {}", report.timing.total_ms); if let Some(ms) = report.timing.dns_ms { println!("dns_ms: {ms}"); } if let Some(ms) = report.timing.connect_ms { println!("connect_ms: {ms}"); } if let Some(ms) = report.timing.tls_ms { println!("tls_ms: {ms}"); } if let Some(ms) = report.timing.ttfb_ms { println!("ttfb_ms: {ms}"); } if args.geoip && !enriched.geoip.is_empty() { for entry in &enriched.geoip { println!("geoip {}: {}", entry.ip, format_geoip(entry)); } } if !report.headers.is_empty() { println!("headers:"); for (name, value) in report.headers { println!(" {name}: {value}"); } } if let Some(body) = report.body.as_ref() { println!("body:"); println!("{body}"); } ExitKind::Ok.code() } } Err(err) => { eprintln!("http request failed: {err}"); ExitKind::Failed.code() } } } async fn handle_tls_handshake(cli: &Cli, args: TlsArgs) -> i32 { let options = build_tls_options(&args); match wtfnet_tls::handshake(&args.target, options).await { Ok(report) => emit_tls_report(cli, "tls handshake", report), Err(err) => { eprintln!("tls handshake failed: {err}"); ExitKind::Failed.code() } } } async fn handle_tls_cert(cli: &Cli, args: TlsArgs) -> i32 { let options = build_tls_options(&args); match wtfnet_tls::certs(&args.target, options).await { Ok(report) => emit_tls_report(cli, "tls cert", report), Err(err) => { eprintln!("tls cert failed: {err}"); ExitKind::Failed.code() } } } async fn handle_tls_verify(cli: &Cli, args: TlsArgs) -> i32 { let options = build_tls_options(&args); match wtfnet_tls::verify(&args.target, options).await { Ok(report) => emit_tls_report(cli, "tls verify", report), Err(err) => { eprintln!("tls verify failed: {err}"); ExitKind::Failed.code() } } } async fn handle_tls_alpn(cli: &Cli, args: TlsArgs) -> i32 { let options = build_tls_options(&args); match wtfnet_tls::alpn(&args.target, options).await { Ok(report) => emit_tls_report(cli, "tls alpn", report), Err(err) => { eprintln!("tls alpn failed: {err}"); ExitKind::Failed.code() } } } fn build_tls_options(args: &TlsArgs) -> wtfnet_tls::TlsOptions { wtfnet_tls::TlsOptions { sni: args.sni.clone(), alpn: parse_alpn(args.alpn.as_deref()), timeout_ms: args.timeout_ms, insecure: args.insecure, socks5: args.socks5.clone(), prefer_ipv4: args.prefer_ipv4, } } fn parse_alpn(value: Option<&str>) -> Vec { let Some(value) = value else { return Vec::new() }; value .split(',') .map(|part| part.trim()) .filter(|part| !part.is_empty()) .map(|part| part.to_string()) .collect() } fn emit_tls_report(cli: &Cli, name: &str, report: T) -> i32 { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new(name, Vec::new()); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { let value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null); println!("{}", serde_json::to_string_pretty(&value).unwrap_or_default()); ExitKind::Ok.code() } } async fn handle_discover_mdns(cli: &Cli, args: DiscoverMdnsArgs) -> i32 { let duration_ms = match parse_duration_ms(&args.duration) { Ok(value) => value, Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } }; let options = wtfnet_discover::MdnsOptions { duration_ms, service_type: args.service.clone(), }; match wtfnet_discover::mdns_discover(options).await { Ok(report) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let mut command_args = vec!["--duration".to_string(), args.duration]; if let Some(service) = args.service { command_args.push("--service".to_string()); command_args.push(service); } let command = CommandInfo::new("discover mdns", command_args); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { for service in report.services { println!("{}", service.fullname); println!(" type: {}", service.service_type); if let Some(hostname) = service.hostname { println!(" host: {hostname}"); } if !service.addresses.is_empty() { println!(" addrs: {}", service.addresses.join(", ")); } if let Some(port) = service.port { println!(" port: {port}"); } if !service.properties.is_empty() { println!(" props:"); for (key, value) in service.properties { println!(" {key}={value}"); } } } ExitKind::Ok.code() } } Err(err) => { eprintln!("mdns discover failed: {err}"); ExitKind::Failed.code() } } } async fn handle_discover_ssdp(cli: &Cli, args: DiscoverSsdpArgs) -> i32 { let duration_ms = match parse_duration_ms(&args.duration) { Ok(value) => value, Err(err) => { eprintln!("{err}"); return ExitKind::Usage.code(); } }; let options = wtfnet_discover::SsdpOptions { duration_ms }; match wtfnet_discover::ssdp_discover(options).await { Ok(report) => { if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new( "discover ssdp", vec!["--duration".to_string(), args.duration], ); let envelope = CommandEnvelope::new(meta, command, report); emit_json(cli, &envelope) } else { for service in report.services { println!("from: {}", service.from); if let Some(st) = service.st { println!(" st: {st}"); } if let Some(usn) = service.usn { println!(" usn: {usn}"); } if let Some(location) = service.location { println!(" location: {location}"); } if let Some(server) = service.server { println!(" server: {server}"); } } ExitKind::Ok.code() } } Err(err) => { eprintln!("ssdp discover failed: {err}"); ExitKind::Failed.code() } } } async fn handle_diag(cli: &Cli, args: DiagArgs) -> i32 { let options = wtfnet_diag::DiagOptions { dns_detect_domain: args.dns_detect.clone(), dns_detect_timeout_ms: args.dns_timeout_ms, dns_detect_repeat: args.dns_repeat, }; let platform = platform(); let report = match wtfnet_diag::run(&platform, options).await { Ok(value) => value, Err(err) => { eprintln!("diag failed: {err}"); return ExitKind::Failed.code(); } }; let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("diag", Vec::new()); let envelope = CommandEnvelope::new(meta.clone(), command, report.clone()); let json = if cli.pretty { serde_json::to_string_pretty(&envelope) } else { serde_json::to_string(&envelope) }; if let Some(out) = args.out.as_ref() { if let Ok(payload) = json.as_ref() { if let Err(err) = std::fs::write(out, payload) { eprintln!("failed to write diag output: {err}"); return ExitKind::Failed.code(); } } } if let Some(bundle) = args.bundle.as_ref() { let meta_json = serde_json::to_value(&meta).unwrap_or_else(|_| serde_json::Value::Null); let report_json = serde_json::to_value(&report).unwrap_or_else(|_| serde_json::Value::Null); if let Err(err) = wtfnet_diag::write_bundle(bundle, &meta_json, &report_json) { eprintln!("failed to write bundle: {err}"); return ExitKind::Failed.code(); } } if cli.json { match json { Ok(payload) => { println!("{payload}"); ExitKind::Ok.code() } Err(err) => { eprintln!("failed to serialize json: {err}"); ExitKind::Failed.code() } } } else { println!( "ifaces: {} routes: {} ports: {} neigh: {} warnings: {}", report .interfaces .as_ref() .map(|value| value.len().to_string()) .unwrap_or_else(|| "-".to_string()), report .routes .as_ref() .map(|value| value.len().to_string()) .unwrap_or_else(|| "-".to_string()), report .ports_listen .as_ref() .map(|value| value.len().to_string()) .unwrap_or_else(|| "-".to_string()), report .neighbors .as_ref() .map(|value| value.len().to_string()) .unwrap_or_else(|| "-".to_string()), report.warnings.len() ); ExitKind::Ok.code() } } fn derive_tls_name(value: &str) -> Option { if let Ok(_addr) = value.parse::() { return None; } if let Ok(_ip) = value.parse::() { return None; } let host = if let Some(addr) = value.strip_prefix('[') { if let Some(end) = addr.find(']') { &addr[..end] } else { value } } else if let Some((host, _)) = value.rsplit_once(':') { host } else { value }; if host.parse::().is_ok() { None } else { Some(host.to_string()) } } fn normalize_subnet_input(values: &[String]) -> Result { match values.len() { 1 => Ok(values[0].clone()), 2 => Ok(format!("{} {}", values[0], values[1])), _ => Err("expected: or ".to_string()), } } fn parse_duration_ms(value: &str) -> Result { let raw = value.trim(); if raw.is_empty() { return Err("duration is empty".to_string()); } let (number, unit) = if raw.ends_with("ms") { (&raw[..raw.len() - 2], "ms") } else if raw.ends_with('s') { (&raw[..raw.len() - 1], "s") } else { (raw, "ms") }; let base: u64 = number .parse() .map_err(|_| format!("invalid duration: {value}"))?; let ms = match unit { "ms" => base, "s" => base.saturating_mul(1000), _ => base, }; Ok(ms) } fn format_answers_geoip(answers: &[DnsAnswerGeoIp]) -> String { if answers.is_empty() { return "-".to_string(); } answers .iter() .map(|answer| { let geoip = answer .geoip .as_ref() .map(format_geoip) .unwrap_or_else(|| "-".to_string()); format!("{}(geoip={})", answer.data, geoip) }) .collect::>() .join(",") } fn enrich_dns_query_geoip( report: &wtfnet_dns::DnsQueryReport, service: &wtfnet_geoip::GeoIpService, ) -> DnsQueryReportGeoIp { let server_geoip = report .server .as_deref() .and_then(|value| parse_ip_from_server(value)) .map(|ip| service.lookup(ip)); let answers = report .answers .iter() .map(|answer| DnsAnswerGeoIp { name: answer.name.clone(), record_type: answer.record_type.clone(), ttl: answer.ttl, data: answer.data.clone(), geoip: parse_ip_from_server(&answer.data).map(|ip| service.lookup(ip)), }) .collect::>(); DnsQueryReportGeoIp { domain: report.domain.clone(), record_type: report.record_type.clone(), transport: report.transport.clone(), server: report.server.clone(), server_name: report.server_name.clone(), server_geoip, proxy: report.proxy.clone(), rcode: report.rcode.clone(), answers, duration_ms: report.duration_ms, } } fn enrich_dns_detect_geoip( report: &wtfnet_dns::DnsDetectResult, service: &wtfnet_geoip::GeoIpService, ) -> DnsDetectResultGeoIp { let results = report .results .iter() .map(|result| enrich_dns_query_geoip(result, service)) .collect::>(); DnsDetectResultGeoIp { verdict: report.verdict.clone(), evidence: report.evidence.clone(), results, } } fn parse_ip_from_server(value: &str) -> Option { if let Ok(addr) = value.parse::() { return Some(addr.ip()); } value.parse::().ok() } fn enrich_ping_geoip(report: &mut wtfnet_probe::PingReport) { let ip = match report.ip.as_deref() { Some(ip) => ip, None => return, }; if let Ok(parsed) = ip.parse::() { let service = geoip_service(); report.geoip = Some(service.lookup(parsed)); } } fn enrich_tcp_geoip(report: &mut wtfnet_probe::TcpPingReport) { let ip = match report.ip.as_deref() { Some(ip) => ip, None => return, }; if let Ok(parsed) = ip.parse::() { let service = geoip_service(); report.geoip = Some(service.lookup(parsed)); } } fn enrich_trace_geoip(report: &mut wtfnet_probe::TraceReport) { let service = geoip_service(); if let Some(ip) = report.ip.as_deref() { if let Ok(parsed) = ip.parse::() { report.geoip = Some(service.lookup(parsed)); } } for hop in &mut report.hops { if let Some(addr) = hop.addr.as_deref() { if let Ok(parsed) = addr.parse::() { hop.geoip = Some(service.lookup(parsed)); } } } } fn format_geoip(record: &wtfnet_geoip::GeoIpRecord) -> String { let country = record.country.as_ref(); let iso = country .and_then(|value| value.iso_code.as_deref()) .unwrap_or("-"); let name = country .and_then(|value| value.name.as_deref()) .unwrap_or("-"); let asn = record .asn .as_ref() .and_then(|value| value.number) .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()); let org = record .asn .as_ref() .and_then(|value| value.organization.as_deref()) .unwrap_or("-"); format!("country={} name={} asn={} org={}", iso, name, asn, org) } fn emit_platform_error(cli: &Cli, err: PlatformError) -> i32 { let code = err.code.clone(); let message = err.message.clone(); if cli.json { let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); let command = CommandInfo::new("sys ifaces", Vec::new()); let mut envelope = CommandEnvelope::new(meta, command, serde_json::Value::Null); envelope.errors.push(ErrItem::new(code.clone(), message.clone())); let json = if cli.pretty { serde_json::to_string_pretty(&envelope) } else { serde_json::to_string(&envelope) }; if let Ok(payload) = json { println!("{payload}"); } else if let Ok(payload) = serde_json::to_string(&envelope) { println!("{payload}"); } } else { eprintln!("{message}"); } match code { wtfnet_core::ErrorCode::PermissionDenied => ExitKind::Permission.code(), wtfnet_core::ErrorCode::Timeout => ExitKind::Timeout.code(), wtfnet_core::ErrorCode::InvalidArgs => ExitKind::Usage.code(), wtfnet_core::ErrorCode::Partial => ExitKind::Partial.code(), _ => ExitKind::Failed.code(), } } fn emit_json(cli: &Cli, envelope: &CommandEnvelope) -> i32 { let json = if cli.pretty { serde_json::to_string_pretty(envelope) } else { serde_json::to_string(envelope) }; match json { Ok(payload) => { println!("{payload}"); ExitKind::Ok.code() } Err(err) => { eprintln!("failed to serialize json: {err}"); ExitKind::Failed.code() } } } fn logging_config_from_cli(cli: &Cli) -> LoggingConfig { if cli.quiet { return LoggingConfig { level: LogLevel::Error, format: parse_log_format(cli.log_format.as_deref()) .or_else(env_log_format) .unwrap_or(LogFormat::Text), log_file: cli.log_file.clone().or_else(env_log_file), }; } let level = parse_log_level(cli.log_level.as_deref()) .or_else(env_log_level) .unwrap_or_else(|| level_from_verbosity(cli.verbose)); LoggingConfig { level, format: parse_log_format(cli.log_format.as_deref()) .or_else(env_log_format) .unwrap_or(LogFormat::Text), log_file: cli.log_file.clone().or_else(env_log_file), } } fn level_from_verbosity(count: u8) -> LogLevel { match count { 0 => LogLevel::Info, 1 => LogLevel::Debug, _ => LogLevel::Trace, } } fn parse_log_level(value: Option<&str>) -> Option { match value?.to_ascii_lowercase().as_str() { "error" => Some(LogLevel::Error), "warn" => Some(LogLevel::Warn), "info" => Some(LogLevel::Info), "debug" => Some(LogLevel::Debug), "trace" => Some(LogLevel::Trace), _ => None, } } fn parse_log_format(value: Option<&str>) -> Option { match value?.to_ascii_lowercase().as_str() { "text" => Some(LogFormat::Text), "json" => Some(LogFormat::Json), _ => None, } } fn env_log_level() -> Option { std::env::var("NETTOOL_LOG_LEVEL") .ok() .as_deref() .and_then(|value| parse_log_level(Some(value))) } fn env_log_format() -> Option { std::env::var("NETTOOL_LOG_FORMAT") .ok() .as_deref() .and_then(|value| parse_log_format(Some(value))) } fn env_log_file() -> Option { std::env::var("NETTOOL_LOG_FILE").ok().map(PathBuf::from) }