Add base subcrates
This commit is contained in:
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>,
|
||||
}
|
||||
Reference in New Issue
Block a user