Add base subcrates
This commit is contained in:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[build]
|
||||||
|
rustflags = ["-L", "C:/npcap-sdk-1.15/Lib/x64"]
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/data
|
||||||
1061
Cargo.lock
generated
Normal file
1061
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = [
|
||||||
|
"crates/wtfnet-core",
|
||||||
|
"crates/wtfnet-cli",
|
||||||
|
"crates/wtfnet-platform",
|
||||||
|
"crates/wtfnet-platform-windows",
|
||||||
|
"crates/wtfnet-platform-linux",
|
||||||
|
]
|
||||||
22
crates/wtfnet-cli/Cargo.toml
Normal file
22
crates/wtfnet-cli/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wtfn"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
|
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
wtfnet-platform-windows = { path = "../wtfnet-platform-windows" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
wtfnet-platform-linux = { path = "../wtfnet-platform-linux" }
|
||||||
736
crates/wtfnet-cli/src/main.rs
Normal file
736
crates/wtfnet-cli/src/main.rs
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
12
crates/wtfnet-core/Cargo.toml
Normal file
12
crates/wtfnet-core/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
|
||||||
487
crates/wtfnet-core/src/lib.rs
Normal file
487
crates/wtfnet-core/src/lib.rs
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommandEnvelope<T> {
|
||||||
|
pub meta: Meta,
|
||||||
|
pub command: CommandInfo,
|
||||||
|
pub data: T,
|
||||||
|
pub warnings: Vec<WarnItem>,
|
||||||
|
pub errors: Vec<ErrItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> CommandEnvelope<T> {
|
||||||
|
pub fn new(meta: Meta, command: CommandInfo, data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
meta,
|
||||||
|
command,
|
||||||
|
data,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Meta {
|
||||||
|
pub tool: String,
|
||||||
|
pub version: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
|
pub privileges: Privileges,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Meta {
|
||||||
|
pub fn new(tool: impl Into<String>, version: impl Into<String>, is_admin: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
tool: tool.into(),
|
||||||
|
version: version.into(),
|
||||||
|
timestamp: now_rfc3339(),
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
|
privileges: Privileges {
|
||||||
|
is_admin,
|
||||||
|
notes: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_privilege_note(&mut self, note: impl Into<String>) {
|
||||||
|
self.privileges.notes.push(note.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Privileges {
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommandInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandInfo {
|
||||||
|
pub fn new(name: impl Into<String>, args: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WarnItem {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarnItem {
|
||||||
|
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErrItem {
|
||||||
|
pub code: ErrorCode,
|
||||||
|
pub message: String,
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrItem {
|
||||||
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
PermissionDenied,
|
||||||
|
Timeout,
|
||||||
|
NotSupported,
|
||||||
|
IoError,
|
||||||
|
InvalidArgs,
|
||||||
|
Partial,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ExitKind {
|
||||||
|
Ok,
|
||||||
|
Failed,
|
||||||
|
Usage,
|
||||||
|
Permission,
|
||||||
|
Timeout,
|
||||||
|
Partial,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExitKind {
|
||||||
|
pub fn code(self) -> i32 {
|
||||||
|
match self {
|
||||||
|
ExitKind::Ok => 0,
|
||||||
|
ExitKind::Failed => 1,
|
||||||
|
ExitKind::Usage => 2,
|
||||||
|
ExitKind::Permission => 3,
|
||||||
|
ExitKind::Timeout => 4,
|
||||||
|
ExitKind::Partial => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum LogFormat {
|
||||||
|
Text,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFormat {
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"text" => Some(LogFormat::Text),
|
||||||
|
"json" => Some(LogFormat::Json),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Error,
|
||||||
|
Warn,
|
||||||
|
Info,
|
||||||
|
Debug,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLevel {
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
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 to_level_filter(self) -> tracing_subscriber::filter::LevelFilter {
|
||||||
|
match self {
|
||||||
|
LogLevel::Error => tracing_subscriber::filter::LevelFilter::ERROR,
|
||||||
|
LogLevel::Warn => tracing_subscriber::filter::LevelFilter::WARN,
|
||||||
|
LogLevel::Info => tracing_subscriber::filter::LevelFilter::INFO,
|
||||||
|
LogLevel::Debug => tracing_subscriber::filter::LevelFilter::DEBUG,
|
||||||
|
LogLevel::Trace => tracing_subscriber::filter::LevelFilter::TRACE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoggingConfig {
|
||||||
|
pub level: LogLevel,
|
||||||
|
pub format: LogFormat,
|
||||||
|
pub log_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoggingHandle {
|
||||||
|
_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_logging(config: &LoggingConfig) -> Result<LoggingHandle, Box<dyn std::error::Error>> {
|
||||||
|
let level_filter = config.level.to_level_filter();
|
||||||
|
match config.format {
|
||||||
|
LogFormat::Text => init_logging_text(level_filter, config.log_file.as_ref()),
|
||||||
|
LogFormat::Json => init_logging_json(level_filter, config.log_file.as_ref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging_text(
|
||||||
|
level_filter: tracing_subscriber::filter::LevelFilter,
|
||||||
|
log_file: Option<&PathBuf>,
|
||||||
|
) -> Result<LoggingHandle, Box<dyn std::error::Error>> {
|
||||||
|
let (writer, guard) = logging_writer(log_file)?;
|
||||||
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(writer)
|
||||||
|
.with_filter(level_filter);
|
||||||
|
tracing_subscriber::registry().with(layer).init();
|
||||||
|
Ok(LoggingHandle { _guard: guard })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging_json(
|
||||||
|
level_filter: tracing_subscriber::filter::LevelFilter,
|
||||||
|
log_file: Option<&PathBuf>,
|
||||||
|
) -> Result<LoggingHandle, Box<dyn std::error::Error>> {
|
||||||
|
let (writer, guard) = logging_writer(log_file)?;
|
||||||
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(writer)
|
||||||
|
.json()
|
||||||
|
.with_filter(level_filter);
|
||||||
|
tracing_subscriber::registry().with(layer).init();
|
||||||
|
Ok(LoggingHandle { _guard: guard })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logging_writer(
|
||||||
|
log_file: Option<&PathBuf>,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
tracing_subscriber::fmt::writer::BoxMakeWriter,
|
||||||
|
Option<tracing_appender::non_blocking::WorkerGuard>,
|
||||||
|
),
|
||||||
|
Box<dyn std::error::Error>,
|
||||||
|
> {
|
||||||
|
use tracing_subscriber::fmt::writer::{BoxMakeWriter, MakeWriterExt};
|
||||||
|
|
||||||
|
if let Some(path) = log_file {
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)?;
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file);
|
||||||
|
let writer = std::io::stderr.and(non_blocking);
|
||||||
|
Ok((BoxMakeWriter::new(writer), Some(guard)))
|
||||||
|
} else {
|
||||||
|
Ok((BoxMakeWriter::new(std::io::stderr), None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_rfc3339() -> String {
|
||||||
|
OffsetDateTime::now_utc()
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub geoip: GeoIpConfig,
|
||||||
|
pub dns: DnsConfig,
|
||||||
|
pub probe: ProbeConfig,
|
||||||
|
pub http: HttpConfig,
|
||||||
|
pub logging: LoggingSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
geoip: GeoIpConfig::default(),
|
||||||
|
dns: DnsConfig::default(),
|
||||||
|
probe: ProbeConfig::default(),
|
||||||
|
http: HttpConfig::default(),
|
||||||
|
logging: LoggingSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeoIpConfig {
|
||||||
|
pub country_db: Option<PathBuf>,
|
||||||
|
pub asn_db: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GeoIpConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
country_db: None,
|
||||||
|
asn_db: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsConfig {
|
||||||
|
pub detect_servers: Vec<String>,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub repeat: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DnsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
detect_servers: vec![
|
||||||
|
"1.1.1.1".to_string(),
|
||||||
|
"8.8.8.8".to_string(),
|
||||||
|
"9.9.9.9".to_string(),
|
||||||
|
],
|
||||||
|
timeout_ms: 2000,
|
||||||
|
repeat: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProbeConfig {
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProbeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout_ms: 800,
|
||||||
|
count: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpConfig {
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub follow_redirects: u32,
|
||||||
|
pub max_body_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout_ms: 3000,
|
||||||
|
follow_redirects: 3,
|
||||||
|
max_body_bytes: 8192,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoggingSettings {
|
||||||
|
pub level: String,
|
||||||
|
pub format: String,
|
||||||
|
pub file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level: "info".to_string(),
|
||||||
|
format: "text".to_string(),
|
||||||
|
file: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Parse(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ConfigError::Io(err) => write!(f, "config io error: {err}"),
|
||||||
|
ConfigError::Parse(err) => write!(f, "config parse error: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ConfigError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
ConfigError::Io(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for ConfigError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
ConfigError::Parse(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config(path: Option<&Path>) -> Result<Config, ConfigError> {
|
||||||
|
let mut config = match path {
|
||||||
|
Some(path) => load_config_from_path(path)?,
|
||||||
|
None => load_default_config().unwrap_or_else(Config::default),
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_env_overrides(&mut config);
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default_config() -> Option<Config> {
|
||||||
|
let path = default_config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
load_config_from_path(&path).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config_from_path(path: &Path) -> Result<Config, ConfigError> {
|
||||||
|
let contents = std::fs::read_to_string(path)?;
|
||||||
|
let config = serde_json::from_str(&contents)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> Option<PathBuf> {
|
||||||
|
if cfg!(windows) {
|
||||||
|
std::env::var("APPDATA")
|
||||||
|
.ok()
|
||||||
|
.map(|root| PathBuf::from(root).join("wtfnet").join("config.json"))
|
||||||
|
} else {
|
||||||
|
let base = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
PathBuf::from(xdg)
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(base.join("wtfnet").join("config.json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_env_overrides(config: &mut Config) {
|
||||||
|
if let Ok(value) = std::env::var("NETTOOL_GEOIP_COUNTRY_DB") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
config.geoip.country_db = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = std::env::var("NETTOOL_GEOIP_ASN_DB") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
config.geoip.asn_db = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = std::env::var("NETTOOL_LOG_LEVEL") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
config.logging.level = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = std::env::var("NETTOOL_LOG_FORMAT") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
config.logging.format = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = std::env::var("NETTOOL_LOG_FILE") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
config.logging.file = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/wtfnet-platform-linux/Cargo.toml
Normal file
16
crates/wtfnet-platform-linux/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-platform-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
network-interface = "1"
|
||||||
|
resolv-conf = "0.7"
|
||||||
|
rustls-native-certs = "0.7"
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
x509-parser = "0.16"
|
||||||
|
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||||
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
411
crates/wtfnet-platform-linux/src/lib.rs
Normal file
411
crates/wtfnet-platform-linux/src/lib.rs
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
|
use sha2::Digest;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wtfnet_core::ErrorCode;
|
||||||
|
use wtfnet_platform::{
|
||||||
|
CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface,
|
||||||
|
Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||||
|
};
|
||||||
|
use x509_parser::oid_registry::{
|
||||||
|
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
||||||
|
OID_KEY_TYPE_GOST_R3410_2012_512, OID_PKCS1_RSAENCRYPTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn platform() -> Platform {
|
||||||
|
Platform {
|
||||||
|
sys: Arc::new(LinuxSysProvider),
|
||||||
|
ports: Arc::new(LinuxPortsProvider),
|
||||||
|
cert: Arc::new(LinuxCertProvider),
|
||||||
|
neigh: Arc::new(LinuxNeighProvider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LinuxSysProvider;
|
||||||
|
struct LinuxPortsProvider;
|
||||||
|
struct LinuxCertProvider;
|
||||||
|
struct LinuxNeighProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SysProvider for LinuxSysProvider {
|
||||||
|
async fn interfaces(&self) -> Result<Vec<NetInterface>, PlatformError> {
|
||||||
|
let interfaces = NetworkInterface::show()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
Ok(interfaces.into_iter().map(map_interface).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn routes(&self) -> Result<Vec<RouteEntry>, PlatformError> {
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
routes.extend(parse_ipv4_routes()?);
|
||||||
|
routes.extend(parse_ipv6_routes()?);
|
||||||
|
Ok(routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dns_config(&self) -> Result<DnsConfigSnapshot, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string("/etc/resolv.conf")
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let cfg = resolv_conf::Config::parse(&contents)
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let servers = cfg
|
||||||
|
.nameservers
|
||||||
|
.iter()
|
||||||
|
.map(|ns| ns.to_string())
|
||||||
|
.collect();
|
||||||
|
let search_domains = cfg
|
||||||
|
.get_last_search_or_domain()
|
||||||
|
.map(|domain| domain.to_string())
|
||||||
|
.collect();
|
||||||
|
Ok(DnsConfigSnapshot {
|
||||||
|
servers,
|
||||||
|
search_domains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_interface(iface: NetworkInterface) -> NetInterface {
|
||||||
|
let addresses = iface
|
||||||
|
.addr
|
||||||
|
.into_iter()
|
||||||
|
.map(|addr| match addr {
|
||||||
|
Addr::V4(v4) => wtfnet_platform::NetAddress {
|
||||||
|
ip: v4.ip.to_string(),
|
||||||
|
prefix_len: prefix_from_v4_netmask(v4.netmask),
|
||||||
|
scope: None,
|
||||||
|
},
|
||||||
|
Addr::V6(v6) => wtfnet_platform::NetAddress {
|
||||||
|
ip: v6.ip.to_string(),
|
||||||
|
prefix_len: prefix_from_v6_netmask(v6.netmask),
|
||||||
|
scope: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
NetInterface {
|
||||||
|
name: iface.name,
|
||||||
|
index: Some(iface.index),
|
||||||
|
is_up: None,
|
||||||
|
mtu: None,
|
||||||
|
mac: iface.mac_addr,
|
||||||
|
addresses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_from_v4_netmask(netmask: Option<std::net::Ipv4Addr>) -> Option<u8> {
|
||||||
|
netmask.map(|mask| u32::from_be_bytes(mask.octets()).count_ones() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_from_v6_netmask(netmask: Option<std::net::Ipv6Addr>) -> Option<u8> {
|
||||||
|
netmask.map(|mask| u128::from_be_bytes(mask.octets()).count_ones() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv4_routes() -> Result<Vec<RouteEntry>, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string("/proc/net/route")
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
for (idx, line) in contents.lines().enumerate() {
|
||||||
|
if idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let iface = parts[0].to_string();
|
||||||
|
let dest = parse_ipv4_hex(parts[1]);
|
||||||
|
let gateway = parse_ipv4_hex(parts[2]);
|
||||||
|
let mask = parse_ipv4_hex(parts[7]);
|
||||||
|
let metric = parts[6].parse::<u32>().ok();
|
||||||
|
|
||||||
|
let destination = match (dest, mask) {
|
||||||
|
(Some(dest), Some(mask)) => {
|
||||||
|
let prefix = u32::from(mask).count_ones();
|
||||||
|
format!("{}/{}", dest, prefix)
|
||||||
|
}
|
||||||
|
(Some(dest), None) => dest.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
routes.push(RouteEntry {
|
||||||
|
destination,
|
||||||
|
gateway: gateway.map(|ip| ip.to_string()).filter(|ip| ip != "0.0.0.0"),
|
||||||
|
interface: Some(iface),
|
||||||
|
metric,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv6_routes() -> Result<Vec<RouteEntry>, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string("/proc/net/ipv6_route")
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
for line in contents.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 10 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = parse_ipv6_hex(parts[0]);
|
||||||
|
let dest_prefix = u32::from_str_radix(parts[1], 16).ok();
|
||||||
|
let gateway = parse_ipv6_hex(parts[4]);
|
||||||
|
let metric = u32::from_str_radix(parts[5], 16).ok();
|
||||||
|
let iface = parts[9].to_string();
|
||||||
|
|
||||||
|
let destination = match (dest, dest_prefix) {
|
||||||
|
(Some(dest), Some(prefix)) => format!("{}/{}", dest, prefix),
|
||||||
|
(Some(dest), None) => dest.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
routes.push(RouteEntry {
|
||||||
|
destination,
|
||||||
|
gateway: gateway.map(|ip| ip.to_string()).filter(|ip| ip != "::"),
|
||||||
|
interface: Some(iface),
|
||||||
|
metric,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv4_hex(value: &str) -> Option<std::net::Ipv4Addr> {
|
||||||
|
if value.len() != 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw = u32::from_str_radix(value, 16).ok()?;
|
||||||
|
let bytes = raw.to_le_bytes();
|
||||||
|
Some(std::net::Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv6_hex(value: &str) -> Option<std::net::Ipv6Addr> {
|
||||||
|
if value.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
let start = i * 2;
|
||||||
|
let chunk = &value[start..start + 2];
|
||||||
|
bytes[i] = u8::from_str_radix(chunk, 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(std::net::Ipv6Addr::from(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_linux_tcp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string(path)
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
for (idx, line) in contents.lines().enumerate() {
|
||||||
|
if idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let local = parts[1];
|
||||||
|
let state = parts[3];
|
||||||
|
if state != "0A" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
||||||
|
sockets.push(ListenSocket {
|
||||||
|
proto: "tcp".to_string(),
|
||||||
|
local_addr,
|
||||||
|
state: Some("LISTEN".to_string()),
|
||||||
|
pid: None,
|
||||||
|
ppid: None,
|
||||||
|
process_name: None,
|
||||||
|
process_path: None,
|
||||||
|
owner: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_linux_udp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string(path)
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
for (idx, line) in contents.lines().enumerate() {
|
||||||
|
if idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let local = parts[1];
|
||||||
|
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
||||||
|
sockets.push(ListenSocket {
|
||||||
|
proto: "udp".to_string(),
|
||||||
|
local_addr,
|
||||||
|
state: None,
|
||||||
|
pid: None,
|
||||||
|
ppid: None,
|
||||||
|
process_name: None,
|
||||||
|
process_path: None,
|
||||||
|
owner: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_proc_socket_addr(value: &str, is_v6: bool) -> Option<String> {
|
||||||
|
let mut parts = value.split(':');
|
||||||
|
let addr_hex = parts.next()?;
|
||||||
|
let port_hex = parts.next()?;
|
||||||
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||||
|
if is_v6 {
|
||||||
|
let addr = parse_ipv6_hex(addr_hex)?;
|
||||||
|
Some(format!("[{}]:{}", addr, port))
|
||||||
|
} else {
|
||||||
|
let addr = parse_ipv4_hex(addr_hex)?;
|
||||||
|
Some(format!("{}:{}", addr, port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_linux_arp(contents: &str) -> Vec<NeighborEntry> {
|
||||||
|
let mut neighbors = Vec::new();
|
||||||
|
for (idx, line) in contents.lines().enumerate() {
|
||||||
|
if idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let flags = parts[2];
|
||||||
|
let state = match flags {
|
||||||
|
"0x2" => Some("reachable".to_string()),
|
||||||
|
_ => Some("stale".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
neighbors.push(NeighborEntry {
|
||||||
|
ip: parts[0].to_string(),
|
||||||
|
mac: Some(parts[3].to_string()).filter(|mac| mac != "00:00:00:00:00:00"),
|
||||||
|
interface: Some(parts[5].to_string()),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_port(value: &str) -> Option<u16> {
|
||||||
|
if let Some(pos) = value.rfind(':') {
|
||||||
|
return value[pos + 1..].parse::<u16>().ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
|
let certs = rustls_native_certs::load_native_certs()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
for cert in certs {
|
||||||
|
let der = cert.as_ref();
|
||||||
|
let parsed = match x509_parser::parse_x509_certificate(der) {
|
||||||
|
Ok((_, cert)) => cert,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject = parsed.subject().to_string();
|
||||||
|
let issuer = parsed.issuer().to_string();
|
||||||
|
let not_before = parsed.validity().not_before.to_string();
|
||||||
|
let not_after = parsed.validity().not_after.to_string();
|
||||||
|
let serial = parsed.tbs_certificate.raw_serial_as_string();
|
||||||
|
let sha1 = format_fingerprint(sha1::Sha1::digest(der).as_slice());
|
||||||
|
let sha256 = format_fingerprint(sha2::Sha256::digest(der).as_slice());
|
||||||
|
let (key_algorithm, key_size) = key_info(&parsed);
|
||||||
|
|
||||||
|
roots.push(RootCert {
|
||||||
|
subject,
|
||||||
|
issuer,
|
||||||
|
not_before,
|
||||||
|
not_after,
|
||||||
|
serial_number: serial,
|
||||||
|
sha1,
|
||||||
|
sha256,
|
||||||
|
key_algorithm,
|
||||||
|
key_size,
|
||||||
|
store: Some(store.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_info(cert: &x509_parser::certificate::X509Certificate<'_>) -> (String, Option<u32>) {
|
||||||
|
let algorithm = &cert.subject_pki.algorithm.algorithm;
|
||||||
|
let name = if algorithm == &OID_PKCS1_RSAENCRYPTION {
|
||||||
|
"RSA"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_EC_PUBLIC_KEY {
|
||||||
|
"EC"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_DSA {
|
||||||
|
"DSA"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_256 {
|
||||||
|
"GOST2012-256"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_512 {
|
||||||
|
"GOST2012-512"
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_size = cert
|
||||||
|
.subject_pki
|
||||||
|
.parsed()
|
||||||
|
.ok()
|
||||||
|
.map(|key| key.key_size() as u32)
|
||||||
|
.filter(|size| *size > 0);
|
||||||
|
|
||||||
|
(name.to_string(), key_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_fingerprint(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (idx, byte) in bytes.iter().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
out.push(':');
|
||||||
|
}
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(out, "{:02x}", byte);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PortsProvider for LinuxPortsProvider {
|
||||||
|
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
sockets.extend(parse_linux_tcp("/proc/net/tcp", false)?);
|
||||||
|
sockets.extend(parse_linux_tcp("/proc/net/tcp6", true)?);
|
||||||
|
sockets.extend(parse_linux_udp("/proc/net/udp", false)?);
|
||||||
|
sockets.extend(parse_linux_udp("/proc/net/udp6", true)?);
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn who_owns(&self, port: u16) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let sockets = self.listening().await?;
|
||||||
|
Ok(sockets
|
||||||
|
.into_iter()
|
||||||
|
.filter(|socket| extract_port(&socket.local_addr) == Some(port))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CertProvider for LinuxCertProvider {
|
||||||
|
async fn trusted_roots(&self) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
|
load_native_roots("linux")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NeighProvider for LinuxNeighProvider {
|
||||||
|
async fn neighbors(&self) -> Result<Vec<NeighborEntry>, PlatformError> {
|
||||||
|
let contents = std::fs::read_to_string("/proc/net/arp")
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
Ok(parse_linux_arp(&contents))
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/wtfnet-platform-windows/Cargo.toml
Normal file
16
crates/wtfnet-platform-windows/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-platform-windows"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
network-interface = "1"
|
||||||
|
regex = "1"
|
||||||
|
rustls-native-certs = "0.7"
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
x509-parser = "0.16"
|
||||||
|
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||||
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
529
crates/wtfnet-platform-windows/src/lib.rs
Normal file
529
crates/wtfnet-platform-windows/src/lib.rs
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
|
use regex::Regex;
|
||||||
|
use sha2::Digest;
|
||||||
|
use x509_parser::oid_registry::{
|
||||||
|
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
||||||
|
OID_KEY_TYPE_GOST_R3410_2012_512, OID_PKCS1_RSAENCRYPTION,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wtfnet_core::ErrorCode;
|
||||||
|
use wtfnet_platform::{
|
||||||
|
CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface,
|
||||||
|
Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn platform() -> Platform {
|
||||||
|
Platform {
|
||||||
|
sys: Arc::new(WindowsSysProvider),
|
||||||
|
ports: Arc::new(WindowsPortsProvider),
|
||||||
|
cert: Arc::new(WindowsCertProvider),
|
||||||
|
neigh: Arc::new(WindowsNeighProvider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WindowsSysProvider;
|
||||||
|
struct WindowsPortsProvider;
|
||||||
|
struct WindowsCertProvider;
|
||||||
|
struct WindowsNeighProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SysProvider for WindowsSysProvider {
|
||||||
|
async fn interfaces(&self) -> Result<Vec<NetInterface>, PlatformError> {
|
||||||
|
let interfaces = NetworkInterface::show()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
Ok(interfaces.into_iter().map(map_interface).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn routes(&self) -> Result<Vec<RouteEntry>, PlatformError> {
|
||||||
|
let interfaces = NetworkInterface::show()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
parse_windows_routes(&interfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dns_config(&self) -> Result<DnsConfigSnapshot, PlatformError> {
|
||||||
|
let output = std::process::Command::new("ipconfig")
|
||||||
|
.arg("/all")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(PlatformError::new(
|
||||||
|
ErrorCode::IoError,
|
||||||
|
"ipconfig /all failed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Ok(parse_ipconfig_dns(&text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_interface(iface: NetworkInterface) -> NetInterface {
|
||||||
|
let addresses = iface
|
||||||
|
.addr
|
||||||
|
.into_iter()
|
||||||
|
.map(|addr| match addr {
|
||||||
|
Addr::V4(v4) => wtfnet_platform::NetAddress {
|
||||||
|
ip: v4.ip.to_string(),
|
||||||
|
prefix_len: prefix_from_v4_netmask(v4.netmask),
|
||||||
|
scope: None,
|
||||||
|
},
|
||||||
|
Addr::V6(v6) => wtfnet_platform::NetAddress {
|
||||||
|
ip: v6.ip.to_string(),
|
||||||
|
prefix_len: prefix_from_v6_netmask(v6.netmask),
|
||||||
|
scope: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
NetInterface {
|
||||||
|
name: iface.name,
|
||||||
|
index: Some(iface.index),
|
||||||
|
is_up: None,
|
||||||
|
mtu: None,
|
||||||
|
mac: iface.mac_addr,
|
||||||
|
addresses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_from_v4_netmask(netmask: Option<std::net::Ipv4Addr>) -> Option<u8> {
|
||||||
|
netmask.map(|mask| u32::from_be_bytes(mask.octets()).count_ones() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_from_v6_netmask(netmask: Option<std::net::Ipv6Addr>) -> Option<u8> {
|
||||||
|
netmask.map(|mask| u128::from_be_bytes(mask.octets()).count_ones() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_windows_routes(
|
||||||
|
interfaces: &[NetworkInterface],
|
||||||
|
) -> Result<Vec<RouteEntry>, PlatformError> {
|
||||||
|
let output = std::process::Command::new("route")
|
||||||
|
.arg("print")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(PlatformError::new(
|
||||||
|
ErrorCode::IoError,
|
||||||
|
"route print failed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
routes.extend(parse_windows_ipv4_routes(&text, interfaces));
|
||||||
|
routes.extend(parse_windows_ipv6_routes(&text, interfaces));
|
||||||
|
Ok(routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_windows_ipv4_routes(
|
||||||
|
text: &str,
|
||||||
|
interfaces: &[NetworkInterface],
|
||||||
|
) -> Vec<RouteEntry> {
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
let mut in_ipv4 = false;
|
||||||
|
let mut in_active = false;
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("IPv4 Route Table") {
|
||||||
|
in_ipv4 = true;
|
||||||
|
in_active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("IPv6 Route Table") {
|
||||||
|
in_ipv4 = false;
|
||||||
|
in_active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_ipv4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("Active Routes:") {
|
||||||
|
in_active = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("====") || trimmed.is_empty() || trimmed.starts_with("Network") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination = parts[0];
|
||||||
|
let netmask = parts[1];
|
||||||
|
let gateway = parts[2];
|
||||||
|
let interface_addr = parts[3];
|
||||||
|
let metric = parts[4].parse::<u32>().ok();
|
||||||
|
|
||||||
|
let prefix = parse_ipv4_prefix(netmask);
|
||||||
|
let destination = if let Some(prefix) = prefix {
|
||||||
|
format!("{}/{}", destination, prefix)
|
||||||
|
} else {
|
||||||
|
destination.to_string()
|
||||||
|
};
|
||||||
|
let iface = interface_name_from_ip(interfaces, interface_addr);
|
||||||
|
|
||||||
|
routes.push(RouteEntry {
|
||||||
|
destination,
|
||||||
|
gateway: Some(gateway.to_string()).filter(|g| g != "0.0.0.0"),
|
||||||
|
interface: iface,
|
||||||
|
metric,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
routes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_windows_ipv6_routes(
|
||||||
|
text: &str,
|
||||||
|
interfaces: &[NetworkInterface],
|
||||||
|
) -> Vec<RouteEntry> {
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
let mut in_ipv6 = false;
|
||||||
|
let mut in_active = false;
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("IPv6 Route Table") {
|
||||||
|
in_ipv6 = true;
|
||||||
|
in_active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("====") && in_ipv6 && in_active {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !in_ipv6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("Active Routes:") {
|
||||||
|
in_active = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with("If") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let iface_index = parts[0];
|
||||||
|
let metric = parts[1].parse::<u32>().ok();
|
||||||
|
let destination = parts[2].to_string();
|
||||||
|
let gateway = parts[3].to_string();
|
||||||
|
let iface = interface_name_from_index(interfaces, iface_index);
|
||||||
|
|
||||||
|
routes.push(RouteEntry {
|
||||||
|
destination,
|
||||||
|
gateway: Some(gateway).filter(|g| g != "On-link"),
|
||||||
|
interface: iface,
|
||||||
|
metric,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
routes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv4_prefix(netmask: &str) -> Option<u32> {
|
||||||
|
let mask: std::net::Ipv4Addr = netmask.parse().ok()?;
|
||||||
|
Some(u32::from_be_bytes(mask.octets()).count_ones())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_name_from_ip(
|
||||||
|
interfaces: &[NetworkInterface],
|
||||||
|
addr: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let parsed: std::net::IpAddr = addr.parse().ok()?;
|
||||||
|
for iface in interfaces {
|
||||||
|
if iface.addr.iter().any(|entry| entry.ip() == parsed) {
|
||||||
|
return Some(iface.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_name_from_index(
|
||||||
|
interfaces: &[NetworkInterface],
|
||||||
|
index: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let index = index.parse::<u32>().ok()?;
|
||||||
|
interfaces
|
||||||
|
.iter()
|
||||||
|
.find(|iface| iface.index == index)
|
||||||
|
.map(|iface| iface.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipconfig_dns(text: &str) -> DnsConfigSnapshot {
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
let mut search_domains = Vec::new();
|
||||||
|
let dns_server_re = Regex::new(r"^DNS Servers?\s*[:.]\s*(.+)$").unwrap();
|
||||||
|
let dns_suffix_re = Regex::new(r"^DNS Suffix Search List\.?\s*[:.]\s*(.+)$").unwrap();
|
||||||
|
|
||||||
|
let mut in_dns_servers = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
in_dns_servers = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(caps) = dns_server_re.captures(trimmed) {
|
||||||
|
if let Some(value) = caps.get(1) {
|
||||||
|
servers.push(value.as_str().to_string());
|
||||||
|
}
|
||||||
|
in_dns_servers = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_dns_servers && !trimmed.contains(':') {
|
||||||
|
servers.push(trimmed.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(caps) = dns_suffix_re.captures(trimmed) {
|
||||||
|
if let Some(value) = caps.get(1) {
|
||||||
|
let list = value.as_str();
|
||||||
|
for entry in list.split_whitespace() {
|
||||||
|
search_domains.push(entry.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DnsConfigSnapshot {
|
||||||
|
servers,
|
||||||
|
search_domains,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_windows_listeners() -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let output = std::process::Command::new("netstat")
|
||||||
|
.arg("-ano")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(PlatformError::new(ErrorCode::IoError, "netstat -ano failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("TCP") {
|
||||||
|
if let Some(socket) = parse_netstat_tcp_line(trimmed) {
|
||||||
|
sockets.push(socket);
|
||||||
|
}
|
||||||
|
} else if trimmed.starts_with("UDP") {
|
||||||
|
if let Some(socket) = parse_netstat_udp_line(trimmed) {
|
||||||
|
sockets.push(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_netstat_tcp_line(line: &str) -> Option<ListenSocket> {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let local = parts[1];
|
||||||
|
let state = parts[3];
|
||||||
|
let pid = parts[4].parse::<u32>().ok();
|
||||||
|
|
||||||
|
if state != "LISTENING" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ListenSocket {
|
||||||
|
proto: "tcp".to_string(),
|
||||||
|
local_addr: local.to_string(),
|
||||||
|
state: Some(state.to_string()),
|
||||||
|
pid,
|
||||||
|
ppid: None,
|
||||||
|
process_name: None,
|
||||||
|
process_path: None,
|
||||||
|
owner: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_netstat_udp_line(line: &str) -> Option<ListenSocket> {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let local = parts[1];
|
||||||
|
let pid = parts[3].parse::<u32>().ok();
|
||||||
|
|
||||||
|
Some(ListenSocket {
|
||||||
|
proto: "udp".to_string(),
|
||||||
|
local_addr: local.to_string(),
|
||||||
|
state: None,
|
||||||
|
pid,
|
||||||
|
ppid: None,
|
||||||
|
process_name: None,
|
||||||
|
process_path: None,
|
||||||
|
owner: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_arp_output(text: &str) -> Vec<NeighborEntry> {
|
||||||
|
let mut neighbors = Vec::new();
|
||||||
|
let mut current_iface = None;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("Interface:") {
|
||||||
|
current_iface = trimmed
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("Internet Address") || trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors.push(NeighborEntry {
|
||||||
|
ip: parts[0].to_string(),
|
||||||
|
mac: Some(parts[1].to_string()),
|
||||||
|
interface: current_iface.clone(),
|
||||||
|
state: Some(parts[2].to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_port(value: &str) -> Option<u16> {
|
||||||
|
if let Some(pos) = value.rfind(':') {
|
||||||
|
return value[pos + 1..].parse::<u16>().ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
|
let certs = rustls_native_certs::load_native_certs()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
for cert in certs {
|
||||||
|
let der = cert.as_ref();
|
||||||
|
let parsed = match x509_parser::parse_x509_certificate(der) {
|
||||||
|
Ok((_, cert)) => cert,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject = parsed.subject().to_string();
|
||||||
|
let issuer = parsed.issuer().to_string();
|
||||||
|
let not_before = parsed.validity().not_before.to_string();
|
||||||
|
let not_after = parsed.validity().not_after.to_string();
|
||||||
|
let serial = parsed.tbs_certificate.raw_serial_as_string();
|
||||||
|
let sha1 = format_fingerprint(sha1::Sha1::digest(der).as_slice());
|
||||||
|
let sha256 = format_fingerprint(sha2::Sha256::digest(der).as_slice());
|
||||||
|
let (key_algorithm, key_size) = key_info(&parsed);
|
||||||
|
|
||||||
|
roots.push(RootCert {
|
||||||
|
subject,
|
||||||
|
issuer,
|
||||||
|
not_before,
|
||||||
|
not_after,
|
||||||
|
serial_number: serial,
|
||||||
|
sha1,
|
||||||
|
sha256,
|
||||||
|
key_algorithm,
|
||||||
|
key_size,
|
||||||
|
store: Some(store.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_info(cert: &x509_parser::certificate::X509Certificate<'_>) -> (String, Option<u32>) {
|
||||||
|
let algorithm = &cert.subject_pki.algorithm.algorithm;
|
||||||
|
let name = if algorithm == &OID_PKCS1_RSAENCRYPTION {
|
||||||
|
"RSA"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_EC_PUBLIC_KEY {
|
||||||
|
"EC"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_DSA {
|
||||||
|
"DSA"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_256 {
|
||||||
|
"GOST2012-256"
|
||||||
|
} else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_512 {
|
||||||
|
"GOST2012-512"
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_size = cert
|
||||||
|
.subject_pki
|
||||||
|
.parsed()
|
||||||
|
.ok()
|
||||||
|
.map(|key| key.key_size() as u32)
|
||||||
|
.filter(|size| *size > 0);
|
||||||
|
|
||||||
|
(name.to_string(), key_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_fingerprint(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (idx, byte) in bytes.iter().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
out.push(':');
|
||||||
|
}
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(out, "{:02x}", byte);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PortsProvider for WindowsPortsProvider {
|
||||||
|
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let sockets = parse_windows_listeners()?;
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn who_owns(&self, port: u16) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let sockets = parse_windows_listeners()?;
|
||||||
|
Ok(sockets
|
||||||
|
.into_iter()
|
||||||
|
.filter(|socket| extract_port(&socket.local_addr) == Some(port))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CertProvider for WindowsCertProvider {
|
||||||
|
async fn trusted_roots(&self) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
|
load_native_roots("windows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NeighProvider for WindowsNeighProvider {
|
||||||
|
async fn neighbors(&self) -> Result<Vec<NeighborEntry>, PlatformError> {
|
||||||
|
let output = std::process::Command::new("arp")
|
||||||
|
.arg("-a")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(PlatformError::new(ErrorCode::IoError, "arp -a failed"));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Ok(parse_arp_output(&text))
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/wtfnet-platform/Cargo.toml
Normal file
9
crates/wtfnet-platform/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-platform"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
118
crates/wtfnet-platform/src/lib.rs
Normal file
118
crates/wtfnet-platform/src/lib.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wtfnet_core::ErrorCode;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetInterface {
|
||||||
|
pub name: String,
|
||||||
|
pub index: Option<u32>,
|
||||||
|
pub is_up: Option<bool>,
|
||||||
|
pub mtu: Option<u32>,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub addresses: Vec<NetAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetAddress {
|
||||||
|
pub ip: String,
|
||||||
|
pub prefix_len: Option<u8>,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsConfigSnapshot {
|
||||||
|
pub servers: Vec<String>,
|
||||||
|
pub search_domains: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RouteEntry {
|
||||||
|
pub destination: String,
|
||||||
|
pub gateway: Option<String>,
|
||||||
|
pub interface: Option<String>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ListenSocket {
|
||||||
|
pub proto: String,
|
||||||
|
pub local_addr: String,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub ppid: Option<u32>,
|
||||||
|
pub process_name: Option<String>,
|
||||||
|
pub process_path: Option<String>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RootCert {
|
||||||
|
pub subject: String,
|
||||||
|
pub issuer: String,
|
||||||
|
pub not_before: String,
|
||||||
|
pub not_after: String,
|
||||||
|
pub serial_number: String,
|
||||||
|
pub sha1: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub key_algorithm: String,
|
||||||
|
pub key_size: Option<u32>,
|
||||||
|
pub store: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NeighborEntry {
|
||||||
|
pub ip: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub interface: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlatformError {
|
||||||
|
pub code: ErrorCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlatformError {
|
||||||
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_supported(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::NotSupported, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SysProvider: Send + Sync {
|
||||||
|
async fn interfaces(&self) -> Result<Vec<NetInterface>, PlatformError>;
|
||||||
|
async fn routes(&self) -> Result<Vec<RouteEntry>, PlatformError>;
|
||||||
|
async fn dns_config(&self) -> Result<DnsConfigSnapshot, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PortsProvider: Send + Sync {
|
||||||
|
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||||
|
async fn who_owns(&self, port: u16) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CertProvider: Send + Sync {
|
||||||
|
async fn trusted_roots(&self) -> Result<Vec<RootCert>, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait NeighProvider: Send + Sync {
|
||||||
|
async fn neighbors(&self) -> Result<Vec<NeighborEntry>, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Platform {
|
||||||
|
pub sys: Arc<dyn SysProvider>,
|
||||||
|
pub ports: Arc<dyn PortsProvider>,
|
||||||
|
pub cert: Arc<dyn CertProvider>,
|
||||||
|
pub neigh: Arc<dyn NeighProvider>,
|
||||||
|
}
|
||||||
660
docs/implementation_notes.md
Normal file
660
docs/implementation_notes.md
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
# IMPLEMENTATION.md
|
||||||
|
|
||||||
|
## 0) Project identity
|
||||||
|
|
||||||
|
* **Project name:** WTFnet
|
||||||
|
* **Binary name:** `wtfn`
|
||||||
|
* **Tagline:** *“What the f*ck is my networking doing?”*
|
||||||
|
|
||||||
|
Target OS (first-class):
|
||||||
|
|
||||||
|
* ✅ Linux (Debian/Ubuntu)
|
||||||
|
* ✅ Windows 10/11 + Windows Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Workspace layout (Cargo)
|
||||||
|
|
||||||
|
Recommended workspace structure: keep the CLI thin, keep logic reusable, and isolate OS-specific code.
|
||||||
|
|
||||||
|
```
|
||||||
|
wtfnet/
|
||||||
|
├─ Cargo.toml
|
||||||
|
├─ crates/
|
||||||
|
│ ├─ wtfnet-cli/ # bin: clap parsing + output formatting
|
||||||
|
│ ├─ wtfnet-core/ # shared types: errors, output schema, config, logging init
|
||||||
|
│ ├─ wtfnet-platform/ # platform traits + OS dispatch
|
||||||
|
│ ├─ wtfnet-platform-linux/ # Linux implementations (netlink/procfs)
|
||||||
|
│ ├─ wtfnet-platform-windows/ # Windows implementations (Win32 APIs)
|
||||||
|
│ ├─ wtfnet-geoip/ # GeoLite2 Country+ASN mmdb
|
||||||
|
│ ├─ wtfnet-probe/ # ping/tcping/trace + geoip enrichment
|
||||||
|
│ ├─ wtfnet-dns/ # query/detect/watch
|
||||||
|
│ ├─ wtfnet-http/ # HTTP/1.1, HTTP/2, optional HTTP/3
|
||||||
|
│ ├─ wtfnet-tls/ # TLS handshake/cert/verify/alpn
|
||||||
|
│ ├─ wtfnet-discover/ # mdns/ssdp discovery
|
||||||
|
│ └─ wtfnet-diag/ # diag bundle orchestrator
|
||||||
|
└─ docs/ # design/usage docs (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root `Cargo.toml` (workspace)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/wtfnet-cli",
|
||||||
|
"crates/wtfnet-core",
|
||||||
|
"crates/wtfnet-platform",
|
||||||
|
"crates/wtfnet-platform-linux",
|
||||||
|
"crates/wtfnet-platform-windows",
|
||||||
|
"crates/wtfnet-geoip",
|
||||||
|
"crates/wtfnet-probe",
|
||||||
|
"crates/wtfnet-dns",
|
||||||
|
"crates/wtfnet-http",
|
||||||
|
"crates/wtfnet-tls",
|
||||||
|
"crates/wtfnet-discover",
|
||||||
|
"crates/wtfnet-diag",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Subcrate responsibilities
|
||||||
|
|
||||||
|
### 2.1 `wtfnet-cli` (binary)
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
* Defines `clap` subcommands & global flags (`--json`, `--pretty`, logging flags, timeouts)
|
||||||
|
* Calls into library crates
|
||||||
|
* Converts results → human tables OR JSON
|
||||||
|
|
||||||
|
**Strict rule**
|
||||||
|
|
||||||
|
* No OS-specific code here.
|
||||||
|
* No heavy logic here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 `wtfnet-core` (shared kernel)
|
||||||
|
|
||||||
|
**Owns**
|
||||||
|
|
||||||
|
* Output schema wrapper (`meta`, `command`, `data`, `warnings`, `errors`)
|
||||||
|
* Error taxonomy + exit code mapping
|
||||||
|
* Config loading (flags/env/config.json)
|
||||||
|
* Logging initialization (`tracing`)
|
||||||
|
* Formatting helpers (durations, bytes, IP formatting)
|
||||||
|
* “human table renderer” utilities (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 `wtfnet-platform` (traits + dispatch)
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
Expose a stable interface for sysadmin-ish data:
|
||||||
|
|
||||||
|
* sys: interfaces/IP/route/DNS config snapshot
|
||||||
|
* ports: listening sockets + PID/process info
|
||||||
|
* cert roots: enumerate trusted roots
|
||||||
|
* neigh: ARP/NDP cache
|
||||||
|
|
||||||
|
**Pattern**
|
||||||
|
|
||||||
|
* Define Rust traits like `SysProvider`, `PortsProvider`, `CertProvider`, `NeighProvider`
|
||||||
|
* Provide a `Platform` object that selects implementation by target OS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 `wtfnet-platform-linux`
|
||||||
|
|
||||||
|
Linux implementations:
|
||||||
|
|
||||||
|
* netlink-based route/IP/neigh: use `rtnetlink` ([Docs.rs][1])
|
||||||
|
* ports/process mapping:
|
||||||
|
|
||||||
|
* either use `listeners` (cross-platform) or Linux procfs parsing
|
||||||
|
* DNS snapshot: parse `/etc/resolv.conf` + detect `systemd-resolved` best-effort
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 `wtfnet-platform-windows`
|
||||||
|
|
||||||
|
Windows implementations:
|
||||||
|
|
||||||
|
* use `windows` crate / Win32 APIs
|
||||||
|
|
||||||
|
* interfaces/routes/neigh via IP Helper APIs
|
||||||
|
* ports/process via Windows socket/process APIs
|
||||||
|
* trusted roots via Windows cert store APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 `wtfnet-geoip`
|
||||||
|
|
||||||
|
* Loads **local** GeoLite2 Country + ASN mmdb
|
||||||
|
* Provides one `GeoIpService` with `lookup(ip)` returning:
|
||||||
|
|
||||||
|
* country + ISO
|
||||||
|
* ASN + org
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 `wtfnet-probe`
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
|
||||||
|
* ping (v4/v6)
|
||||||
|
* tcping (connect latency)
|
||||||
|
* trace (traceroute-like)
|
||||||
|
* optional `--geoip` enrichment
|
||||||
|
|
||||||
|
For ping, you can start with `surge-ping` ([Crates][2]) or `ping-async` ([Docs.rs][3]) (both async-oriented).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 `wtfnet-dns`
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
|
||||||
|
* `dns query` (dig-like)
|
||||||
|
* `dns detect` (poisoning compare)
|
||||||
|
* `dns watch` (passive, best-effort)
|
||||||
|
|
||||||
|
Use **Hickory DNS** (Trust-DNS rebrand) ([Docs.rs][4]) for a solid resolver/client base.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 `wtfnet-http`
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
|
||||||
|
* `http head|get`
|
||||||
|
* HTTP/2 support is required (via `reqwest`/`hyper`)
|
||||||
|
* HTTP/3 optional behind feature flag:
|
||||||
|
|
||||||
|
* `h3` + `h3-quinn` + `quinn` ([GitHub][5])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.10 `wtfnet-tls`
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
|
||||||
|
* `tls handshake|cert|verify|alpn`
|
||||||
|
* Use `rustls` for handshake parsing
|
||||||
|
* For system trust verification:
|
||||||
|
|
||||||
|
* `rustls-native-certs` for loading OS roots ([Crates][6])
|
||||||
|
* optionally `rustls-platform-verifier` for “verify like the OS” behavior ([GitHub][7])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.11 `wtfnet-discover`
|
||||||
|
|
||||||
|
Implements bounded local discovery:
|
||||||
|
|
||||||
|
* mDNS service discovery: `mdns-sd` ([Crates][8])
|
||||||
|
* SSDP discovery: `ssdp-client` ([Crates][9])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.12 `wtfnet-diag`
|
||||||
|
|
||||||
|
Orchestrates:
|
||||||
|
|
||||||
|
* sys snapshot
|
||||||
|
* routes
|
||||||
|
* ports listen
|
||||||
|
* neigh list
|
||||||
|
* optional quick checks: DNS detect, TLS handshake, HTTP head
|
||||||
|
* bundle export to zip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Dependency map (crate graph)
|
||||||
|
|
||||||
|
**High-level dependency graph:**
|
||||||
|
|
||||||
|
```
|
||||||
|
wtfnet-cli
|
||||||
|
├─ wtfnet-core
|
||||||
|
├─ wtfnet-platform
|
||||||
|
│ ├─ wtfnet-platform-linux (cfg linux)
|
||||||
|
│ └─ wtfnet-platform-windows (cfg windows)
|
||||||
|
├─ wtfnet-geoip
|
||||||
|
├─ wtfnet-probe
|
||||||
|
├─ wtfnet-dns
|
||||||
|
├─ wtfnet-http
|
||||||
|
├─ wtfnet-tls
|
||||||
|
├─ wtfnet-discover
|
||||||
|
└─ wtfnet-diag
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule of thumb**
|
||||||
|
|
||||||
|
* `wtfnet-core` should not depend on OS crates.
|
||||||
|
* feature crates should depend on `wtfnet-core` + minimal extras.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Shared libraries / crate dependencies (recommended)
|
||||||
|
|
||||||
|
### 4.1 Core stack (almost everywhere)
|
||||||
|
|
||||||
|
* **CLI**: `clap` (+ `clap_complete` optional)
|
||||||
|
* **Async runtime**: `tokio`
|
||||||
|
* **Serialization**: `serde`, `serde_json`
|
||||||
|
* **Errors**: `thiserror` (libs), `anyhow` (CLI glue)
|
||||||
|
* **URLs**: `url`
|
||||||
|
* **Time**: `time` (or `chrono`), `humantime`
|
||||||
|
* **Tables (human output)**: `tabled` or `comfy-table`
|
||||||
|
* **Zip bundles**: `zip` (diag bundle)
|
||||||
|
|
||||||
|
### 4.2 Logging / tracing
|
||||||
|
|
||||||
|
Use `tracing` + `tracing-subscriber`:
|
||||||
|
|
||||||
|
* `tracing`
|
||||||
|
* `tracing-subscriber` (fmt + env filter)
|
||||||
|
* optional: `tracing-appender` (log file)
|
||||||
|
|
||||||
|
**Why this choice**
|
||||||
|
|
||||||
|
* structured logs
|
||||||
|
* spans for timing each probe stage
|
||||||
|
* easy JSON logs to stderr
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Sys/platform related crates
|
||||||
|
|
||||||
|
* Interfaces: `network-interface` ([Crates][10]) (good for standardized interface listing)
|
||||||
|
* Linux netlink: `rtnetlink` ([Docs.rs][1])
|
||||||
|
* Ports:
|
||||||
|
|
||||||
|
* `listeners` (cross-platform “listening process mapping”) ([GitHub][11])
|
||||||
|
* fallback: `netstat2` (cross-platform sockets info) ([Docs.rs][12])
|
||||||
|
* Windows APIs: `windows` crate
|
||||||
|
|
||||||
|
> Suggestion: start with `listeners` for `ports listen/who` (it directly targets your use-case). ([GitHub][11])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 GeoIP
|
||||||
|
|
||||||
|
* `maxminddb` (read GeoLite2 mmdb)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 DNS
|
||||||
|
|
||||||
|
* `hickory-resolver` / Hickory ecosystem ([Docs.rs][4])
|
||||||
|
|
||||||
|
Passive `dns watch` (optional):
|
||||||
|
|
||||||
|
* `pcap` or `pnet` (feature-gated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.6 HTTP / TLS
|
||||||
|
|
||||||
|
HTTP:
|
||||||
|
|
||||||
|
* `reqwest` (easy HTTP/1.1 + HTTP/2)
|
||||||
|
* or `hyper` if you want lower-level control
|
||||||
|
|
||||||
|
HTTP/3 (optional feature):
|
||||||
|
|
||||||
|
* `h3` + `h3-quinn` + `quinn` ([GitHub][5])
|
||||||
|
|
||||||
|
TLS:
|
||||||
|
|
||||||
|
* `rustls`
|
||||||
|
* `rustls-native-certs` ([Crates][6])
|
||||||
|
* `rustls-platform-verifier` (optional) ([GitHub][7])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.7 Probing
|
||||||
|
|
||||||
|
* ping:
|
||||||
|
|
||||||
|
* `surge-ping` ([Crates][2])
|
||||||
|
* or `ping-async` ([Docs.rs][3])
|
||||||
|
* tcping: plain `tokio::net::TcpStream::connect` + `timeout`
|
||||||
|
* trace: start simple (UDP/ICMP-based approaches are trickier & permission-sensitive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.8 Discovery
|
||||||
|
|
||||||
|
* mDNS: `mdns-sd` ([Crates][8])
|
||||||
|
* SSDP: `ssdp-client` ([Crates][9])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Feature flags & compile-time options
|
||||||
|
|
||||||
|
In root design, define optional features to avoid heavy deps by default.
|
||||||
|
|
||||||
|
Suggested features:
|
||||||
|
|
||||||
|
* `http3` → enables `h3`, `h3-quinn`, `quinn`
|
||||||
|
* `pcap` → enables passive DNS watch with packet capture
|
||||||
|
* `discover` → enable mdns/ssdp features (if you want a “minimal build”)
|
||||||
|
|
||||||
|
Example snippet (in `wtfnet-http/Cargo.toml`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
default = ["http2"]
|
||||||
|
http2 = []
|
||||||
|
http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "*", features = ["rustls-tls", "http2"] }
|
||||||
|
h3 = { version = "*", optional = true }
|
||||||
|
h3-quinn = { version = "*", optional = true }
|
||||||
|
quinn = { version = "*", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Core data model + output schema (do this early)
|
||||||
|
|
||||||
|
### 6.1 Unified JSON wrapper (recommended)
|
||||||
|
|
||||||
|
Every command returns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CommandEnvelope<T> {
|
||||||
|
pub meta: Meta,
|
||||||
|
pub command: CommandInfo,
|
||||||
|
pub data: T,
|
||||||
|
pub warnings: Vec<Warn>,
|
||||||
|
pub errors: Vec<ErrItem>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key principles:
|
||||||
|
|
||||||
|
* stable keys
|
||||||
|
* additive schema evolution
|
||||||
|
* logs never pollute stdout JSON
|
||||||
|
|
||||||
|
### 6.2 Exit code mapping
|
||||||
|
|
||||||
|
Put this in `wtfnet-core` and make CLI enforce it:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ExitKind {
|
||||||
|
Ok,
|
||||||
|
Failed,
|
||||||
|
Usage,
|
||||||
|
Permission,
|
||||||
|
Timeout,
|
||||||
|
Partial,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Logging design (`tracing`)
|
||||||
|
|
||||||
|
### 7.1 Init once in `main()`
|
||||||
|
|
||||||
|
`wtfnet-core::logging::init(...)` should:
|
||||||
|
|
||||||
|
* respect CLI flags + env vars
|
||||||
|
* print to stderr
|
||||||
|
* support `text|json` formats
|
||||||
|
|
||||||
|
### 7.2 Use spans for “timing breakdown”
|
||||||
|
|
||||||
|
Example: HTTP diagnostics
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tracing::instrument(skip(client))]
|
||||||
|
async fn http_head(client: &Client, url: &Url) -> Result<HttpReport, Error> {
|
||||||
|
let _span = tracing::info_span!("http_head", %url).entered();
|
||||||
|
// measure dns/connect/tls/ttfb where possible
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Platform abstraction pattern (Rust traits)
|
||||||
|
|
||||||
|
In `wtfnet-platform`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait SysProvider {
|
||||||
|
async fn interfaces(&self) -> Result<Vec<NetInterface>, PlatformError>;
|
||||||
|
async fn routes(&self) -> Result<Vec<RouteEntry>, PlatformError>;
|
||||||
|
async fn dns_config(&self) -> Result<DnsConfig, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait PortsProvider {
|
||||||
|
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||||
|
async fn who_owns(&self, port: u16) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CertProvider {
|
||||||
|
async fn trusted_roots(&self) -> Result<Vec<RootCert>, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait NeighProvider {
|
||||||
|
async fn neighbors(&self) -> Result<Vec<NeighborEntry>, PlatformError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then provide:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Platform {
|
||||||
|
pub sys: Arc<dyn SysProvider + Send + Sync>,
|
||||||
|
pub ports: Arc<dyn PortsProvider + Send + Sync>,
|
||||||
|
pub cert: Arc<dyn CertProvider + Send + Sync>,
|
||||||
|
pub neigh: Arc<dyn NeighProvider + Send + Sync>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
OS dispatch:
|
||||||
|
|
||||||
|
* `cfg(target_os = "linux")` → `wtfnet-platform-linux`
|
||||||
|
* `cfg(target_os = "windows")` → `wtfnet-platform-windows`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Implementation notes per command area
|
||||||
|
|
||||||
|
### 9.1 sys (interfaces/routes/dns)
|
||||||
|
|
||||||
|
**Interfaces**
|
||||||
|
|
||||||
|
* start with `network-interface` ([Crates][10]) for a normalized list
|
||||||
|
* if you need MTU / gateway details not exposed, add platform-native calls
|
||||||
|
|
||||||
|
**Linux routes/neigh**
|
||||||
|
|
||||||
|
* `rtnetlink` can manage links/addresses/ARP/route tables ([Docs.rs][1])
|
||||||
|
|
||||||
|
**Windows routes/neigh**
|
||||||
|
|
||||||
|
* use Win32 IP helper APIs via `windows` crate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 ports (listen/who)
|
||||||
|
|
||||||
|
Best path:
|
||||||
|
|
||||||
|
* use `listeners` crate for cross-platform “listening sockets → process” mapping ([GitHub][11])
|
||||||
|
|
||||||
|
Fallback:
|
||||||
|
|
||||||
|
* `netstat2` provides cross-platform socket information ([Docs.rs][12])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 cert roots
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* `rustls-native-certs` to load roots from OS trust store ([Crates][6])
|
||||||
|
|
||||||
|
Filtering:
|
||||||
|
|
||||||
|
* by subject substring
|
||||||
|
* by fingerprint sha256
|
||||||
|
* expired/not-yet-valid
|
||||||
|
|
||||||
|
Diff:
|
||||||
|
|
||||||
|
* export stable JSON
|
||||||
|
* compare by fingerprint (sha256)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.4 geoip (local mmdb)
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* `maxminddb`
|
||||||
|
Expose:
|
||||||
|
* `GeoIpService::new(country_path, asn_path)`
|
||||||
|
* `lookup(IpAddr) -> GeoInfo`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.5 dns (query/detect/watch)
|
||||||
|
|
||||||
|
Resolver:
|
||||||
|
|
||||||
|
* Hickory DNS ecosystem ([Docs.rs][4])
|
||||||
|
|
||||||
|
Detect logic (keep deterministic):
|
||||||
|
|
||||||
|
* Query multiple resolvers
|
||||||
|
* Normalize answers (A/AAAA/CNAME)
|
||||||
|
* “suspicious” if major divergence, NXDOMAIN mismatch, private IP injection patterns
|
||||||
|
|
||||||
|
Watch:
|
||||||
|
|
||||||
|
* feature-gate packet capture
|
||||||
|
* document privilege needs clearly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.6 http (head/get)
|
||||||
|
|
||||||
|
HTTP/2:
|
||||||
|
|
||||||
|
* `reqwest` makes this trivial
|
||||||
|
|
||||||
|
HTTP/3 (optional):
|
||||||
|
|
||||||
|
* use `h3` + `h3-quinn` + `quinn` ([GitHub][5])
|
||||||
|
Keep it behind `--http3` and fallback to HTTP/2 when UDP is blocked.
|
||||||
|
|
||||||
|
Timing breakdown:
|
||||||
|
|
||||||
|
* you’ll get total time easily
|
||||||
|
* fine-grained DNS/connect/TLS timing may need deeper client hooks (ok to be best-effort)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.7 tls (handshake/cert/verify)
|
||||||
|
|
||||||
|
Handshake:
|
||||||
|
|
||||||
|
* use `rustls` to connect and extract:
|
||||||
|
|
||||||
|
* version, cipher suite, ALPN
|
||||||
|
* peer cert chain
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
* use `rustls-platform-verifier` if you want OS-like verification ([GitHub][7])
|
||||||
|
* otherwise load roots via `rustls-native-certs` ([Crates][6]) and verify with webpki
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.8 neigh (ARP/NDP)
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
* `rtnetlink` includes ARP/neighbor operations ([Docs.rs][1])
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
* IP Helper API provides neighbor cache info (implementation detail)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.9 discover (mDNS + SSDP)
|
||||||
|
|
||||||
|
mDNS:
|
||||||
|
|
||||||
|
* `mdns-sd` ([Crates][8])
|
||||||
|
Bounded `--duration`, no spam.
|
||||||
|
|
||||||
|
SSDP:
|
||||||
|
|
||||||
|
* `ssdp-client` ([Crates][9])
|
||||||
|
Send M-SEARCH, collect responses, parse location/server/usn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Testing strategy
|
||||||
|
|
||||||
|
### 10.1 Unit tests (fast, pure)
|
||||||
|
|
||||||
|
* subnet math (`calc`)
|
||||||
|
* parsing/formatting
|
||||||
|
* DNS comparison heuristics (test vectors)
|
||||||
|
|
||||||
|
### 10.2 Snapshot tests (JSON stability)
|
||||||
|
|
||||||
|
Use `insta`:
|
||||||
|
|
||||||
|
* ensure `--json` schema doesn’t drift accidentally
|
||||||
|
|
||||||
|
### 10.3 Integration tests (CI)
|
||||||
|
|
||||||
|
* run non-privileged commands only:
|
||||||
|
|
||||||
|
* `sys ifaces`
|
||||||
|
* `calc subnet`
|
||||||
|
* `dns query example.com A`
|
||||||
|
* `http head https://example.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Coding conventions
|
||||||
|
|
||||||
|
* Every command handler returns a structured `CommandEnvelope<T>`
|
||||||
|
* Never `println!` from libs; return data → CLI prints it
|
||||||
|
* `--json` must be clean stdout (no logs mixed in)
|
||||||
|
* Use timeouts everywhere in probe/dns/http/tls
|
||||||
|
* Prefer “best-effort + warnings” over hard failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Minimal “first coding milestone” plan
|
||||||
|
|
||||||
|
1. `wtfnet-core`: envelope + logging init + exit mapping
|
||||||
|
2. `wtfnet-cli`: clap skeleton + `sys ifaces`
|
||||||
|
3. `wtfnet-geoip`: load mmdb + `geoip <ip>`
|
||||||
|
4. `ports listen/who` using `listeners` ([GitHub][11])
|
||||||
|
5. `dns query` via Hickory ([Docs.rs][4])
|
||||||
|
6. `http head` and `tls handshake` basic success path
|
||||||
|
7. `diag` orchestration + zip bundle
|
||||||
|
|
||||||
|
---
|
||||||
938
docs/requirement_docs.md
Normal file
938
docs/requirement_docs.md
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# README.md
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# WTFnet
|
||||||
|
|
||||||
|
**WTFnet** is a pure CLI toolbox for diagnosing network problems on **Linux (Debian/Ubuntu)** and **Windows**.
|
||||||
|
|
||||||
|
> _"What the f\*ck is my networking doing?"_
|
||||||
|
|
||||||
|
It combines system network inspection, port/process visibility, DNS poisoning checks, HTTP/TLS diagnostics, GeoIP enrichment, ARP/NDP neighbor tables, and lightweight discovery tools — all in one consistent CLI.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- **Pure CLI** (no REPL / no TUI)
|
||||||
|
- **Fast + scriptable** output (`--json` supported everywhere)
|
||||||
|
- **First-class support:** Linux (Debian/Ubuntu), Windows
|
||||||
|
- **Rust implementation**
|
||||||
|
- **Graceful degradation** when OS APIs differ or privileges are missing
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### Show interfaces & IPs
|
||||||
|
```bash
|
||||||
|
wtfnet sys ifaces
|
||||||
|
wtfnet sys ip --all
|
||||||
|
wtfnet sys route
|
||||||
|
````
|
||||||
|
|
||||||
|
### Find which process owns port 443
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet ports who 443
|
||||||
|
wtfnet ports listen --tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS poisoning detection (multi-resolver compare)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet dns detect example.com
|
||||||
|
wtfnet dns detect example.com --servers 1.1.1.1,8.8.8.8,9.9.9.9 --repeat 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP + TLS diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet http head https://example.com --show-headers --http2-only
|
||||||
|
wtfnet tls handshake example.com:443 --show-chain
|
||||||
|
wtfnet tls verify example.com:443
|
||||||
|
```
|
||||||
|
|
||||||
|
### GeoIP lookup (local GeoLite2 DBs)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet geoip 8.8.8.8
|
||||||
|
wtfnet probe tcping example.com:443 --geoip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neighbor table (ARP / NDP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet neigh list
|
||||||
|
wtfnet neigh list --ipv4
|
||||||
|
wtfnet neigh list --ipv6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate a diagnostic report bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet diag --bundle out.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output modes
|
||||||
|
|
||||||
|
* Default: readable tables / summaries
|
||||||
|
* Machine-readable: `--json`
|
||||||
|
* Pretty JSON: `--json --pretty`
|
||||||
|
* Logs go to **stderr** (JSON output stays clean)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet sys ip --json --pretty > ip.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet --log-level debug sys route
|
||||||
|
wtfnet --log-format json sys dns
|
||||||
|
wtfnet --log-file wtfnet.log diag
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
* `NETTOOL_LOG_LEVEL`
|
||||||
|
* `NETTOOL_LOG_FORMAT`
|
||||||
|
* `NETTOOL_LOG_FILE`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REQUIREMENTS.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# WTFnet — Requirements (v0.2)
|
||||||
|
|
||||||
|
## 1. Product overview
|
||||||
|
|
||||||
|
### 1.1 Purpose
|
||||||
|
WTFnet is a **single-executable CLI toolbox** for diagnosing network problems:
|
||||||
|
- inspect IP/interface/route/DNS
|
||||||
|
- probe connectivity (ICMP/TCP/path)
|
||||||
|
- detect **DNS poisoning**
|
||||||
|
- inspect **trusted root certificates**
|
||||||
|
- map **listening ports → processes**
|
||||||
|
- run **HTTP/TLS diagnostics**
|
||||||
|
- view **ARP/NDP neighbor cache**
|
||||||
|
- perform lightweight **service discovery**
|
||||||
|
- export a consistent report bundle for incident response
|
||||||
|
|
||||||
|
### 1.2 Design goals
|
||||||
|
- Pure CLI: no REPL / no TUI
|
||||||
|
- Script-friendly: stable output & exit codes
|
||||||
|
- First-class support: **Linux (Debian/Ubuntu)** + **Windows**
|
||||||
|
- Rust implementation
|
||||||
|
- Works without admin where possible; warns clearly when privileges required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Platform support
|
||||||
|
|
||||||
|
### 2.1 First-class OS targets
|
||||||
|
- Linux: Debian / Ubuntu
|
||||||
|
- Windows 10/11 + Windows Server
|
||||||
|
|
||||||
|
### 2.2 Best-effort targets
|
||||||
|
- macOS (not required for v0.x)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CLI UX requirements
|
||||||
|
|
||||||
|
### 3.1 Global flags
|
||||||
|
All commands MUST support:
|
||||||
|
- `--json` (machine output)
|
||||||
|
- `--pretty` (pretty JSON)
|
||||||
|
- `--no-color`
|
||||||
|
- `--quiet`
|
||||||
|
- `-v`, `-vv` (verbosity)
|
||||||
|
- logging flags (see §4)
|
||||||
|
|
||||||
|
### 3.2 Exit codes
|
||||||
|
- `0`: success
|
||||||
|
- `1`: generic failure
|
||||||
|
- `2`: invalid args
|
||||||
|
- `3`: insufficient permissions
|
||||||
|
- `4`: timeout/unreachable category
|
||||||
|
- `5`: partial success (some checks failed; still produced results)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Logging requirements
|
||||||
|
|
||||||
|
### 4.1 Goals
|
||||||
|
- Debug WTFnet itself in production environments
|
||||||
|
- Preserve clean stdout for piping / JSON mode
|
||||||
|
|
||||||
|
### 4.2 Behavior
|
||||||
|
- Logs MUST go to **stderr**
|
||||||
|
- Command output MUST go to **stdout**
|
||||||
|
- JSON output must remain valid even with debug logs enabled
|
||||||
|
|
||||||
|
### 4.3 Controls
|
||||||
|
Flags:
|
||||||
|
- `--log-level <error|warn|info|debug|trace>` (default `info`)
|
||||||
|
- `--log-format <text|json>` (default `text`)
|
||||||
|
- `--log-file <path>` (optional; write logs there; may also tee stderr)
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
- `NETTOOL_LOG_LEVEL`
|
||||||
|
- `NETTOOL_LOG_FORMAT`
|
||||||
|
- `NETTOOL_LOG_FILE`
|
||||||
|
|
||||||
|
### 4.4 Sensitive logging policy
|
||||||
|
- Do NOT log secrets by default (tokens, cookies, passwords)
|
||||||
|
- HTTP response bodies are hidden by default
|
||||||
|
- Provide explicit `--show-secrets` / `--show-body` gates where relevant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Functional requirements
|
||||||
|
|
||||||
|
### 5.1 System inspection (`sys`)
|
||||||
|
#### 5.1.1 Interfaces & addresses
|
||||||
|
Must provide:
|
||||||
|
- interface name/index, state, MTU, MAC
|
||||||
|
- IPv4/IPv6 addresses with prefix + scope
|
||||||
|
- DNS servers + search domains (best-effort)
|
||||||
|
- default gateway mapping (best-effort)
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet sys ifaces`
|
||||||
|
- `wtfnet sys ip --all`
|
||||||
|
- `wtfnet sys route`
|
||||||
|
- `wtfnet sys dns`
|
||||||
|
|
||||||
|
#### 5.1.2 Routing table
|
||||||
|
Outputs:
|
||||||
|
- destination/prefix, gateway/next hop, interface, metric
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Certificate inspection (`cert`)
|
||||||
|
Must list system-wide trusted roots:
|
||||||
|
- subject, issuer
|
||||||
|
- validity range
|
||||||
|
- serial number
|
||||||
|
- SHA1 + SHA256 fingerprint
|
||||||
|
- key algorithm + size
|
||||||
|
- OS store origin (Windows store / Linux path)
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet cert roots`
|
||||||
|
- `wtfnet cert roots --filter subject="DigiCert"`
|
||||||
|
- `wtfnet cert roots --export baseline.json`
|
||||||
|
- `wtfnet cert roots --diff baseline.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Active probing (`probe`)
|
||||||
|
#### 5.3.1 ping
|
||||||
|
- IPv4/IPv6
|
||||||
|
- count/timeout/interval
|
||||||
|
- summary: min/avg/max latency, packet loss
|
||||||
|
|
||||||
|
#### 5.3.2 tcping
|
||||||
|
- hostname or IP:port
|
||||||
|
- resolution result
|
||||||
|
- connect latency
|
||||||
|
- failure classification
|
||||||
|
|
||||||
|
#### 5.3.3 trace
|
||||||
|
- hop list with RTT and IP
|
||||||
|
- IPv4/IPv6 best-effort
|
||||||
|
|
||||||
|
#### 5.3.4 GeoIP enrichment integration
|
||||||
|
All probe commands must support:
|
||||||
|
- `--geoip` to attach GeoIP info to targets/resolved IPs/hops
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 GeoIP (`geoip`)
|
||||||
|
#### 5.4.1 Local DB support (GeoLite2 Country + ASN)
|
||||||
|
- Country DB + ASN DB used offline
|
||||||
|
- degrade gracefully if missing
|
||||||
|
|
||||||
|
DB configuration:
|
||||||
|
- flags:
|
||||||
|
- `--country-db <path>`
|
||||||
|
- `--asn-db <path>`
|
||||||
|
- env vars:
|
||||||
|
- `NETTOOL_GEOIP_COUNTRY_DB`
|
||||||
|
- `NETTOOL_GEOIP_ASN_DB`
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet geoip <ip>`
|
||||||
|
- `wtfnet geoip status`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- country + ISO code if available
|
||||||
|
- ASN number + org name
|
||||||
|
- DB source/version timestamp if detectable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 DNS diagnostics (`dns`)
|
||||||
|
#### 5.5.1 Query
|
||||||
|
Commands:
|
||||||
|
- `wtfnet dns query <domain> <type> [--server <ip>] [--tcp]`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- rcode, answer set + TTL, timing, server used
|
||||||
|
|
||||||
|
#### 5.5.2 Active poisoning detection
|
||||||
|
Commands:
|
||||||
|
- `wtfnet dns detect example.com`
|
||||||
|
- `wtfnet dns detect example.com --servers 1.1.1.1,8.8.8.8 --repeat 5`
|
||||||
|
|
||||||
|
Heuristics to flag suspicious:
|
||||||
|
- major answer divergence across resolvers
|
||||||
|
- abnormal TTL patterns
|
||||||
|
- unexpected private/reserved results
|
||||||
|
- NXDOMAIN injection patterns
|
||||||
|
|
||||||
|
Output verdict:
|
||||||
|
- `clean | suspicious | inconclusive`
|
||||||
|
with evidence list
|
||||||
|
|
||||||
|
#### 5.5.3 Passive watch (best-effort)
|
||||||
|
Commands:
|
||||||
|
- `wtfnet dns watch --duration 30s [--iface eth0] [--filter example.com]`
|
||||||
|
|
||||||
|
Must:
|
||||||
|
- be time-bounded
|
||||||
|
- clearly document privilege requirements (pcap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 Ports & processes (`ports`)
|
||||||
|
Must show listening sockets and owners:
|
||||||
|
- proto, local addr:port, state
|
||||||
|
- PID, PPID (best-effort), process name/path
|
||||||
|
- user/owner (best-effort)
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet ports listen --tcp|--udp`
|
||||||
|
- `wtfnet ports who <port>`
|
||||||
|
|
||||||
|
(Optional)
|
||||||
|
- `wtfnet ports conns`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 Subnet calculator (`calc`)
|
||||||
|
Commands:
|
||||||
|
- `wtfnet calc subnet <cidr|ip mask>`
|
||||||
|
- `wtfnet calc contains <cidrA> <cidrB>`
|
||||||
|
- `wtfnet calc overlap <cidrA> <cidrB>`
|
||||||
|
- `wtfnet calc summarize <cidr1> <cidr2> ...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.8 HTTP diagnostics (`http`)
|
||||||
|
Goals:
|
||||||
|
- verify endpoint health and protocol negotiation
|
||||||
|
- help debug redirect loops, TLS errors, HTTP version issues
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet http head <url>`
|
||||||
|
- `wtfnet http get <url>`
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
- `--http1-only`
|
||||||
|
- `--http2-only`
|
||||||
|
- `--http3` (best-effort optional)
|
||||||
|
- `--timeout 3s`
|
||||||
|
- `--follow-redirects [N]`
|
||||||
|
- `--header "K: V"` (repeatable)
|
||||||
|
- `--show-headers`
|
||||||
|
- `--show-body` (off by default)
|
||||||
|
- `--max-body <bytes>`
|
||||||
|
- `--geoip`
|
||||||
|
|
||||||
|
Required outputs:
|
||||||
|
- resolved IP(s)
|
||||||
|
- negotiated HTTP version (1.1/2/3)
|
||||||
|
- status code
|
||||||
|
- optional headers/body
|
||||||
|
- timing breakdown (best-effort):
|
||||||
|
- DNS
|
||||||
|
- connect / QUIC handshake
|
||||||
|
- TLS handshake
|
||||||
|
- TTFB
|
||||||
|
- total
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.9 TLS diagnostics (`tls`)
|
||||||
|
Commands:
|
||||||
|
- `wtfnet tls handshake <host:port>`
|
||||||
|
- `wtfnet tls cert <host:port>`
|
||||||
|
- `wtfnet tls verify <host:port>`
|
||||||
|
- `wtfnet tls alpn <host:port>`
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
- `--sni <hostname>`
|
||||||
|
- `--alpn h2,http/1.1`
|
||||||
|
- `--insecure`
|
||||||
|
- `--show-chain`
|
||||||
|
- `--geoip`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- TLS version + cipher
|
||||||
|
- ALPN negotiated
|
||||||
|
- chain summary (subject/issuer/validity/SAN best-effort)
|
||||||
|
- verification verdict + error category
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.10 Neighbor table (`neigh`)
|
||||||
|
Commands:
|
||||||
|
- `wtfnet neigh list [--ipv4|--ipv6] [--iface eth0]`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- IP → MAC/LLADDR mapping
|
||||||
|
- interface
|
||||||
|
- state (reachable/stale/failed if available)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.11 Discovery services (`discover`)
|
||||||
|
Purpose: lightweight local discovery, bounded and safe.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `wtfnet discover mdns --duration 3s`
|
||||||
|
- `wtfnet discover ssdp --duration 3s`
|
||||||
|
(optional)
|
||||||
|
- `wtfnet discover llmnr --duration 3s`
|
||||||
|
- `wtfnet discover nbns --duration 3s`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- service/device name
|
||||||
|
- IP/port if present
|
||||||
|
- protocol and service type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.12 Diagnostic bundle (`diag`)
|
||||||
|
Commands:
|
||||||
|
- `wtfnet diag`
|
||||||
|
- `wtfnet diag --out report.json --json`
|
||||||
|
- `wtfnet diag --bundle out.zip`
|
||||||
|
|
||||||
|
Bundle must include:
|
||||||
|
- sys snapshot
|
||||||
|
- routes
|
||||||
|
- dns config + optional detect check
|
||||||
|
- ports listen
|
||||||
|
- neighbor snapshot
|
||||||
|
- meta.json (OS, version, timestamp, privilege hints)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Non-functional requirements
|
||||||
|
|
||||||
|
- robust error handling (no panics)
|
||||||
|
- partial results allowed (exit code `5`)
|
||||||
|
- no indefinite hangs (timeouts everywhere)
|
||||||
|
- privacy: never exfiltrate data; don’t log secrets by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Acceptance criteria (v0.2)
|
||||||
|
On Linux (Debian/Ubuntu) and Windows:
|
||||||
|
- sys inspection works
|
||||||
|
- cert roots listing/filter works
|
||||||
|
- ping + tcping works (IPv4/IPv6 best-effort)
|
||||||
|
- dns query + detect works with verdict+evidence
|
||||||
|
- ports listen/who works (best-effort PID mapping)
|
||||||
|
- http head/get works with HTTP/2 support
|
||||||
|
- tls handshake/verify works with clear output
|
||||||
|
- neigh list works (ARP/NDP snapshot)
|
||||||
|
- logging behaves correctly without breaking JSON output
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# COMMANDS.md
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# WTFnet — Command Reference
|
||||||
|
|
||||||
|
This file documents WTFnet CLI commands and flags.
|
||||||
|
|
||||||
|
## Global flags
|
||||||
|
|
||||||
|
Applies to all commands:
|
||||||
|
|
||||||
|
- `--json` : machine-readable output
|
||||||
|
- `--pretty` : pretty JSON (requires `--json`)
|
||||||
|
- `--no-color` : disable ANSI color
|
||||||
|
- `--quiet` : minimal output
|
||||||
|
- `-v`, `-vv` : verbose output
|
||||||
|
- `--log-level <error|warn|info|debug|trace>`
|
||||||
|
- `--log-format <text|json>`
|
||||||
|
- `--log-file <path>`
|
||||||
|
|
||||||
|
Exit codes: see `REQUIREMENTS.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## sys
|
||||||
|
|
||||||
|
### `wtfnet sys ifaces`
|
||||||
|
Show interface inventory.
|
||||||
|
|
||||||
|
### `wtfnet sys ip [--all] [--iface <name>]`
|
||||||
|
Show IP addresses.
|
||||||
|
|
||||||
|
### `wtfnet sys route [--ipv4|--ipv6] [--to <ip>]`
|
||||||
|
Show routing table; optionally “route-to target”.
|
||||||
|
|
||||||
|
### `wtfnet sys dns`
|
||||||
|
Show resolver configuration snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cert
|
||||||
|
|
||||||
|
### `wtfnet cert roots`
|
||||||
|
List trusted root certificates.
|
||||||
|
|
||||||
|
Common filters:
|
||||||
|
- `--filter subject="..."`
|
||||||
|
- `--expired`
|
||||||
|
- `--fingerprint <sha256>`
|
||||||
|
|
||||||
|
Baseline tools:
|
||||||
|
- `--export <file.json>`
|
||||||
|
- `--diff <baseline.json>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## probe
|
||||||
|
|
||||||
|
### `wtfnet probe ping <host|ip> [--count N] [--timeout 800ms] [--interval 200ms] [--geoip]`
|
||||||
|
ICMP echo with stats.
|
||||||
|
|
||||||
|
### `wtfnet probe tcping <host:port> [--count N] [--timeout 800ms] [--geoip]`
|
||||||
|
TCP connect timing.
|
||||||
|
|
||||||
|
### `wtfnet probe trace <host|ip> [--max-hops N] [--timeout 800ms] [--geoip]`
|
||||||
|
Traceroute-like path discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## geoip
|
||||||
|
|
||||||
|
### `wtfnet geoip <ip|host> [--resolve]`
|
||||||
|
Geo lookup (offline DB).
|
||||||
|
|
||||||
|
### `wtfnet geoip status`
|
||||||
|
Show DB presence and detected paths.
|
||||||
|
|
||||||
|
DB flags:
|
||||||
|
- `--country-db <path>`
|
||||||
|
- `--asn-db <path>`
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
- `NETTOOL_GEOIP_COUNTRY_DB`
|
||||||
|
- `NETTOOL_GEOIP_ASN_DB`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dns
|
||||||
|
|
||||||
|
### `wtfnet dns query <domain> <TYPE> [--server <ip>] [--tcp] [--timeout 2s]`
|
||||||
|
Dig-like query.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```bash
|
||||||
|
wtfnet dns query example.com A
|
||||||
|
wtfnet dns query example.com AAAA --server 1.1.1.1
|
||||||
|
wtfnet dns query example.com A --tcp
|
||||||
|
````
|
||||||
|
|
||||||
|
### `wtfnet dns detect <domain> [--servers <csv>] [--repeat N] [--timeout 2s]`
|
||||||
|
|
||||||
|
Compare across resolvers and detect anomalies.
|
||||||
|
|
||||||
|
### `wtfnet dns watch [--iface <name>] [--duration 30s] [--filter <pattern>]`
|
||||||
|
|
||||||
|
Passive watch (best-effort; may require privileges).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ports
|
||||||
|
|
||||||
|
### `wtfnet ports listen [--tcp|--udp] [--port N]`
|
||||||
|
|
||||||
|
Show listening sockets.
|
||||||
|
|
||||||
|
### `wtfnet ports who <port|ip:port>`
|
||||||
|
|
||||||
|
Find owning process.
|
||||||
|
|
||||||
|
(Optional)
|
||||||
|
|
||||||
|
### `wtfnet ports conns [--top N]`
|
||||||
|
|
||||||
|
Show active connections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## calc
|
||||||
|
|
||||||
|
### `wtfnet calc subnet <CIDR|ip mask>`
|
||||||
|
|
||||||
|
Subnet information.
|
||||||
|
|
||||||
|
### `wtfnet calc contains <CIDR-A> <CIDR-B>`
|
||||||
|
|
||||||
|
Containment check.
|
||||||
|
|
||||||
|
### `wtfnet calc overlap <CIDR-A> <CIDR-B>`
|
||||||
|
|
||||||
|
Overlap check.
|
||||||
|
|
||||||
|
### `wtfnet calc summarize <CIDR...>`
|
||||||
|
|
||||||
|
Summarize multiple networks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## http
|
||||||
|
|
||||||
|
### `wtfnet http head <url>`
|
||||||
|
|
||||||
|
### `wtfnet http get <url>`
|
||||||
|
|
||||||
|
Core flags:
|
||||||
|
|
||||||
|
* `--http1-only`
|
||||||
|
* `--http2-only`
|
||||||
|
* `--http3` (best-effort)
|
||||||
|
* `--timeout 3s`
|
||||||
|
* `--follow-redirects [N]`
|
||||||
|
* `--header "K: V"` (repeatable)
|
||||||
|
* `--show-headers`
|
||||||
|
* `--show-body`
|
||||||
|
* `--max-body <bytes>`
|
||||||
|
* `--geoip`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet http head https://example.com --http2-only --show-headers
|
||||||
|
wtfnet http get https://example.com --follow-redirects 5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## tls
|
||||||
|
|
||||||
|
### `wtfnet tls handshake <host:port>`
|
||||||
|
|
||||||
|
### `wtfnet tls cert <host:port>`
|
||||||
|
|
||||||
|
### `wtfnet tls verify <host:port>`
|
||||||
|
|
||||||
|
### `wtfnet tls alpn <host:port>`
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--sni <hostname>`
|
||||||
|
* `--alpn h2,http/1.1`
|
||||||
|
* `--insecure`
|
||||||
|
* `--show-chain`
|
||||||
|
* `--geoip`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet tls handshake example.com:443 --show-chain
|
||||||
|
wtfnet tls verify example.com:443
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## neigh
|
||||||
|
|
||||||
|
### `wtfnet neigh list [--ipv4|--ipv6] [--iface <name>]`
|
||||||
|
|
||||||
|
Show neighbor table (ARP/NDP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## discover
|
||||||
|
|
||||||
|
### `wtfnet discover mdns --duration 3s`
|
||||||
|
|
||||||
|
### `wtfnet discover ssdp --duration 3s`
|
||||||
|
|
||||||
|
(Optional)
|
||||||
|
|
||||||
|
### `wtfnet discover llmnr --duration 3s`
|
||||||
|
|
||||||
|
### `wtfnet discover nbns --duration 3s`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## diag
|
||||||
|
|
||||||
|
### `wtfnet diag [--json] [--out <file>]`
|
||||||
|
|
||||||
|
Generate report.
|
||||||
|
|
||||||
|
### `wtfnet diag --bundle <zip>`
|
||||||
|
|
||||||
|
Export support bundle.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wtfnet diag --json --pretty --out report.json
|
||||||
|
wtfnet diag --bundle out.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CONFIG.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# WTFnet — Configuration
|
||||||
|
|
||||||
|
WTFnet supports configuration via:
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1) CLI flags
|
||||||
|
2) Environment variables
|
||||||
|
3) Config file (optional)
|
||||||
|
4) Built-in defaults
|
||||||
|
|
||||||
|
## Config file location (proposed)
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
- `$XDG_CONFIG_HOME/wtfnet/config.json`
|
||||||
|
- fallback: `~/.config/wtfnet/config.json`
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
- `%APPDATA%\wtfnet\config.json`
|
||||||
|
|
||||||
|
## Example config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"geoip": {
|
||||||
|
"country_db": "/opt/geoip/GeoLite2-Country.mmdb",
|
||||||
|
"asn_db": "/opt/geoip/GeoLite2-ASN.mmdb"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"detect_servers": ["1.1.1.1", "8.8.8.8", "9.9.9.9"],
|
||||||
|
"timeout_ms": 2000,
|
||||||
|
"repeat": 3
|
||||||
|
},
|
||||||
|
"probe": {
|
||||||
|
"timeout_ms": 800,
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"timeout_ms": 3000,
|
||||||
|
"follow_redirects": 3,
|
||||||
|
"max_body_bytes": 8192
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "info",
|
||||||
|
"format": "text",
|
||||||
|
"file": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
GeoIP:
|
||||||
|
|
||||||
|
* `NETTOOL_GEOIP_COUNTRY_DB`
|
||||||
|
* `NETTOOL_GEOIP_ASN_DB`
|
||||||
|
|
||||||
|
Logging:
|
||||||
|
|
||||||
|
* `NETTOOL_LOG_LEVEL`
|
||||||
|
* `NETTOOL_LOG_FORMAT`
|
||||||
|
* `NETTOOL_LOG_FILE`
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# OUTPUT_SCHEMA.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# WTFnet — JSON Output Conventions
|
||||||
|
|
||||||
|
All commands support `--json`.
|
||||||
|
|
||||||
|
## General rules
|
||||||
|
|
||||||
|
- Output must be valid JSON to stdout
|
||||||
|
- Logs always go to stderr
|
||||||
|
- Prefer stable keys; changes should be additive
|
||||||
|
- Include metadata about tool version + timestamp
|
||||||
|
|
||||||
|
## Common wrapper schema (recommended)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"tool": "wtfnet",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"timestamp": "2026-01-15T22:01:00-05:00",
|
||||||
|
"os": "linux|windows",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"privileges": {
|
||||||
|
"is_admin": false,
|
||||||
|
"notes": ["pcap capture requires elevated privileges"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"name": "sys ip",
|
||||||
|
"args": ["--all"]
|
||||||
|
},
|
||||||
|
"data": {},
|
||||||
|
"warnings": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
## Error representation
|
||||||
|
|
||||||
|
* `errors[]` should contain structured objects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "PERMISSION_DENIED|TIMEOUT|NOT_SUPPORTED|IO_ERROR",
|
||||||
|
"message": "Human readable explanation",
|
||||||
|
"details": { "hint": "Try running as admin" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timing fields (for probe/http/tls)
|
||||||
|
|
||||||
|
Use milliseconds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timing_ms": {
|
||||||
|
"dns_resolve": 12,
|
||||||
|
"connect": 40,
|
||||||
|
"tls_handshake": 55,
|
||||||
|
"ttfb": 70,
|
||||||
|
"total": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ROADMAP.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# WTFnet — Roadmap
|
||||||
|
|
||||||
|
## v0.1 (MVP)
|
||||||
|
Focus: core sysadmin essentials
|
||||||
|
- sys: ifaces/ip/route/dns
|
||||||
|
- ports: listen/who
|
||||||
|
- probe: ping + tcping
|
||||||
|
- calc: subnet/contains/overlap
|
||||||
|
- basic logging + --json everywhere
|
||||||
|
|
||||||
|
## v0.2 (this requirements set)
|
||||||
|
- dns: query + detect + watch (best-effort)
|
||||||
|
- geoip: local Country+ASN mmdb integration
|
||||||
|
- http: head/get (HTTP/2 required; HTTP/3 best-effort optional)
|
||||||
|
- tls: handshake/verify/cert/alpn
|
||||||
|
- neigh: ARP/NDP snapshot
|
||||||
|
- discover: mdns + ssdp (bounded)
|
||||||
|
- diag: bundle export (zip)
|
||||||
|
|
||||||
|
## v0.3 (future upgrades)
|
||||||
|
- richer trace output (reverse lookup, per-hop loss)
|
||||||
|
- TLS extras: OCSP stapling indicator, more chain parsing
|
||||||
|
- ports conns improvements (top talkers / summary)
|
||||||
|
- better baseline/diff for system roots
|
||||||
|
- smarter “diagnose <domain>” workflow mode
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SECURITY.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# WTFnet — Security & Privacy Notes
|
||||||
|
|
||||||
|
## Data handling
|
||||||
|
- WTFnet performs local inspections and probes.
|
||||||
|
- It does not upload anything automatically.
|
||||||
|
|
||||||
|
## Sensitive output defaults
|
||||||
|
- HTTP bodies are not printed by default.
|
||||||
|
- Secrets (Authorization/Cookies) are never logged by default.
|
||||||
|
|
||||||
|
## Capture-based features
|
||||||
|
Some features (e.g. passive DNS watch) may require elevated privileges.
|
||||||
|
WTFnet must clearly indicate when a feature is:
|
||||||
|
- unavailable
|
||||||
|
- permission-limited
|
||||||
|
- OS-limited
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# docs/platform-notes.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Platform Notes
|
||||||
|
|
||||||
|
## Linux (Debian/Ubuntu)
|
||||||
|
- sys: netlink (/proc, /sys) sources
|
||||||
|
- neigh: `ip neigh` equivalent via netlink
|
||||||
|
- ports: `/proc/net/*` + process mapping
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
- sys: Win32 APIs (IP Helper API etc.)
|
||||||
|
- ports/process mapping: Windows APIs (best-effort)
|
||||||
|
- cert roots: Windows certificate store APIs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# docs/troubleshooting.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## `dns watch` shows permission errors
|
||||||
|
Passive capture may require elevated privileges or pcap capabilities.
|
||||||
|
Run as admin/root or configure capture permissions appropriately.
|
||||||
|
|
||||||
|
## `ping` not working without admin
|
||||||
|
Some OS configurations restrict ICMP sockets. Use:
|
||||||
|
- `wtfnet probe tcping <host:port>` as an alternative reachability test.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
53
docs/status.md
Normal file
53
docs/status.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# WTFnet Roadmap and Status
|
||||||
|
|
||||||
|
This document tracks the planned roadmap alongside the current implementation status.
|
||||||
|
|
||||||
|
## Roadmap (from docs/requirement_docs.md)
|
||||||
|
|
||||||
|
### v0.1 (MVP)
|
||||||
|
- sys: ifaces/ip/route/dns
|
||||||
|
- ports: listen/who
|
||||||
|
- probe: ping + tcping
|
||||||
|
- calc: subnet/contains/overlap
|
||||||
|
- basic logging + --json everywhere
|
||||||
|
|
||||||
|
### v0.2 (current requirements)
|
||||||
|
- dns: query + detect + watch (best-effort)
|
||||||
|
- geoip: local Country+ASN mmdb integration
|
||||||
|
- http: head/get (HTTP/2 required; HTTP/3 best-effort optional)
|
||||||
|
- tls: handshake/verify/cert/alpn
|
||||||
|
- neigh: ARP/NDP snapshot
|
||||||
|
- discover: mdns + ssdp (bounded)
|
||||||
|
- diag: bundle export (zip)
|
||||||
|
|
||||||
|
### v0.3 (future upgrades)
|
||||||
|
- richer trace output (reverse lookup, per-hop loss)
|
||||||
|
- TLS extras: OCSP stapling indicator, more chain parsing
|
||||||
|
- ports conns improvements (top talkers / summary)
|
||||||
|
- better baseline/diff for system roots
|
||||||
|
- smarter "diagnose <domain>" workflow mode
|
||||||
|
|
||||||
|
## Current stage
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- Workspace and core crate scaffold.
|
||||||
|
- Core data model: command envelope, meta, warnings/errors.
|
||||||
|
- Exit code mapping.
|
||||||
|
- Logging initialization with text/json formats and optional file output.
|
||||||
|
- CLI crate scaffold with global flags and logging config wiring (placeholder `sys ifaces`).
|
||||||
|
- Config/env parsing helpers in core (config file + env overrides).
|
||||||
|
- Platform trait crate and OS-specific stub crates for Windows/Linux.
|
||||||
|
- `sys ifaces` implemented in Windows/Linux providers (via `network-interface`) and wired to CLI.
|
||||||
|
- CLI support for `sys ip` and `sys route` (platform routes still OS-specific work).
|
||||||
|
- Platform `sys route` implementations (Linux via `/proc/net`, Windows via `route print` parsing).
|
||||||
|
- Platform `sys dns` implementations (Linux `/etc/resolv.conf`, Windows `ipconfig /all`) and CLI command.
|
||||||
|
- Platform `ports listen/who` best-effort parsing (Linux `/proc/net`, Windows `netstat -ano`).
|
||||||
|
- Platform `neigh list` best-effort parsing (Linux `/proc/net/arp`, Windows `arp -a`).
|
||||||
|
- Platform `cert roots` implementation via native trust store parsing.
|
||||||
|
- CLI commands for `ports listen/who`, `neigh list`, and `cert roots`.
|
||||||
|
|
||||||
|
### In progress
|
||||||
|
- None.
|
||||||
|
|
||||||
|
### Next
|
||||||
|
- Start additional platform/feature crates per dependency map.
|
||||||
Reference in New Issue
Block a user