2439 lines
76 KiB
Rust
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)
|
|
}
|