Files
WTFnet/crates/wtfnet-cli/src/main.rs

2439 lines
76 KiB
Rust

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<String>,
#[arg(long)]
log_format: Option<String>,
#[arg(long)]
log_file: Option<PathBuf>,
#[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<String>,
}
#[derive(Parser, Debug, Clone)]
struct SysRouteArgs {
#[arg(long)]
ipv4: bool,
#[arg(long)]
ipv6: bool,
#[arg(long)]
to: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct PortsListenArgs {
#[arg(long)]
tcp: bool,
#[arg(long)]
udp: bool,
#[arg(long)]
port: Option<u16>,
}
#[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<String>,
}
#[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<String>,
#[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<String>,
#[arg(long, default_value = "udp")]
transport: String,
#[arg(long)]
tls_name: Option<String>,
#[arg(long)]
socks5: Option<String>,
#[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<String>,
#[arg(long, default_value = "udp")]
transport: String,
#[arg(long)]
tls_name: Option<String>,
#[arg(long)]
socks5: Option<String>,
#[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<String>,
#[arg(long)]
filter: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct CalcSubnetArgs {
input: Vec<String>,
}
#[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<String>,
}
#[derive(Parser, Debug, Clone)]
struct HttpRequestArgs {
url: String,
#[arg(long, default_value_t = 3000)]
timeout_ms: u64,
#[arg(long)]
follow_redirects: Option<u32>,
#[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<String>,
}
#[derive(Parser, Debug, Clone)]
struct TlsArgs {
target: String,
#[arg(long)]
sni: Option<String>,
#[arg(long)]
alpn: Option<String>,
#[arg(long, default_value_t = 3000)]
timeout_ms: u64,
#[arg(long)]
insecure: bool,
#[arg(long)]
socks5: Option<String>,
#[arg(long)]
prefer_ipv4: bool,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverMdnsArgs {
#[arg(long, default_value = "3s")]
duration: String,
#[arg(long)]
service: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverSsdpArgs {
#[arg(long, default_value = "3s")]
duration: String,
}
#[derive(Parser, Debug, Clone)]
struct DiagArgs {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
bundle: Option<PathBuf>,
#[arg(long)]
dns_detect: Option<String>,
#[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<wtfnet_geoip::GeoIpRecord>,
}
#[derive(Debug, Clone, Serialize)]
struct DnsQueryReportGeoIp {
pub domain: String,
pub record_type: String,
pub transport: String,
pub server: Option<String>,
pub server_name: Option<String>,
pub server_geoip: Option<wtfnet_geoip::GeoIpRecord>,
pub proxy: Option<String>,
pub rcode: String,
pub answers: Vec<DnsAnswerGeoIp>,
pub duration_ms: u128,
}
#[derive(Debug, Clone, Serialize)]
struct DnsDetectResultGeoIp {
pub verdict: String,
pub evidence: Vec<wtfnet_dns::DnsDetectEvidence>,
pub results: Vec<DnsQueryReportGeoIp>,
}
#[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<String>,
pub result: Vec<String>,
}
#[derive(Debug, Serialize)]
struct HttpReportGeoIp {
pub url: String,
pub final_url: Option<String>,
pub method: String,
pub status: Option<u16>,
pub http_version: Option<String>,
pub resolved_ips: Vec<String>,
pub geoip: Vec<wtfnet_geoip::GeoIpRecord>,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
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::<std::net::IpAddr>() {
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<wtfnet_platform::NetInterface>,
args: &SysIpArgs,
) -> Vec<wtfnet_platform::NetInterface> {
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<wtfnet_platform::RouteEntry>,
args: &SysRouteArgs,
) -> Vec<wtfnet_platform::RouteEntry> {
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::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
fn is_ipv4_route(value: &str) -> bool {
value.parse::<std::net::Ipv4Addr>().is_ok()
}
fn is_ipv6_route(value: &str) -> bool {
value.parse::<std::net::Ipv6Addr>().is_ok()
}
fn filter_ports(
sockets: Vec<wtfnet_platform::ListenSocket>,
args: &PortsListenArgs,
) -> Vec<wtfnet_platform::ListenSocket> {
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<wtfnet_platform::NeighborEntry>,
args: &NeighListArgs,
) -> Vec<wtfnet_platform::NeighborEntry> {
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::<std::net::Ipv4Addr>().is_ok()
}
fn is_ipv6_addr(value: &str) -> bool {
value.parse::<std::net::Ipv6Addr>().is_ok()
}
fn extract_port(value: &str) -> Option<u16> {
if let Some(pos) = value.rfind(':') {
return value[pos + 1..].parse::<u16>().ok();
}
None
}
fn parse_port_arg(value: &str) -> Option<u16> {
if let Ok(port) = value.parse::<u16>() {
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<std::path::PathBuf> {
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::<u16>().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::<wtfnet_dns::DnsTransport>() {
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::<wtfnet_dns::DnsTransport>() {
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::<Result<Vec<_>, _>>();
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::<Vec<_>>();
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<wtfnet_dns::DnsServerTarget, String> {
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::<std::net::SocketAddr>() {
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::<std::net::IpAddr>() {
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::<u16>().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::<u16>()
.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<Option<std::net::SocketAddr>, 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::<std::net::IpAddr>().ok())
.map(|ip| service.lookup(ip))
.collect::<Vec<_>>();
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<String> {
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<T: serde::Serialize>(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<String> {
if let Ok(_addr) = value.parse::<std::net::SocketAddr>() {
return None;
}
if let Ok(_ip) = value.parse::<std::net::IpAddr>() {
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::<std::net::IpAddr>().is_ok() {
None
} else {
Some(host.to_string())
}
}
fn normalize_subnet_input(values: &[String]) -> Result<String, String> {
match values.len() {
1 => Ok(values[0].clone()),
2 => Ok(format!("{} {}", values[0], values[1])),
_ => Err("expected: <cidr> or <ip> <mask>".to_string()),
}
}
fn parse_duration_ms(value: &str) -> Result<u64, String> {
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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
DnsDetectResultGeoIp {
verdict: report.verdict.clone(),
evidence: report.evidence.clone(),
results,
}
}
fn parse_ip_from_server(value: &str) -> Option<std::net::IpAddr> {
if let Ok(addr) = value.parse::<std::net::SocketAddr>() {
return Some(addr.ip());
}
value.parse::<std::net::IpAddr>().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::<std::net::IpAddr>() {
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::<std::net::IpAddr>() {
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::<std::net::IpAddr>() {
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::<std::net::IpAddr>() {
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<T: serde::Serialize>(cli: &Cli, envelope: &CommandEnvelope<T>) -> 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<LogLevel> {
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<LogFormat> {
match value?.to_ascii_lowercase().as_str() {
"text" => Some(LogFormat::Text),
"json" => Some(LogFormat::Json),
_ => None,
}
}
fn env_log_level() -> Option<LogLevel> {
std::env::var("NETTOOL_LOG_LEVEL")
.ok()
.as_deref()
.and_then(|value| parse_log_level(Some(value)))
}
fn env_log_format() -> Option<LogFormat> {
std::env::var("NETTOOL_LOG_FORMAT")
.ok()
.as_deref()
.and_then(|value| parse_log_format(Some(value)))
}
fn env_log_file() -> Option<PathBuf> {
std::env::var("NETTOOL_LOG_FILE").ok().map(PathBuf::from)
}