From f29fb71312b80487b630b99a60231a6abc409317 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 9 Nov 2025 20:18:22 +0900 Subject: [PATCH 01/24] Update dependencies --- Cargo.lock | 20 +++++++++++++++----- Cargo.toml | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7399bcd..d1573fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,6 +810,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +dependencies = [ + "serde", +] + [[package]] name = "memchr" version = "2.7.6" @@ -850,27 +859,28 @@ dependencies = [ [[package]] name = "ndb-oui" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "787e9dfc6693c40148f5123e1f53709eda311f1c2de07b87212bc9ce751db8f4" +checksum = "ed688164b72fd09394f077dda902f086280565e6370f74364112f43b822a8f35" dependencies = [ "anyhow", "bincode", "csv", - "netdev", + "mac-addr", "rangemap", "serde", ] [[package]] name = "netdev" -version = "0.38.1" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c1c4a111cf1ba52aa040e77e55fd0e2066d03a3d6ea7d8f383725166a60820" +checksum = "35a703aa1a87cd885b9f674922445a42dbb0c0f4f1b28fef21b227ae32375d21" dependencies = [ "dlopen2", "ipnet", "libc", + "mac-addr", "netlink-packet-core", "netlink-packet-route", "netlink-sys", diff --git a/Cargo.toml b/Cargo.toml index f7bbe29..68055d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,14 +18,14 @@ tracing = { version = "0.1" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml = { version = "0.9" } -netdev = { version = "0.38", features = ["serde"] } +netdev = { version = "0.39", features = ["serde"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } clap = { version = "4.5", features = ["derive", "cargo"] } termtree = { version = "0.5" } hostname = { version = "0.4" } os_info = { version = "3.12" } -ndb-oui = { version = "0.3", features = ["bundled"] } +ndb-oui = { version = "0.4", features = ["bundled"] } ratatui = "0.25" crossterm = "0.27" humansize = "2.1" From 4cb46115c400cb1a8d8cd8ef836cca98aacc792d Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 9 Nov 2025 20:30:28 +0900 Subject: [PATCH 02/24] Rename module from collector to net --- Cargo.lock | 42 ++++++++++++++++++++++++++++++--- Cargo.toml | 1 + src/cmd/export.rs | 2 +- src/cmd/list.rs | 8 +++---- src/cmd/monitor.rs | 4 ++-- src/cmd/os.rs | 4 ++-- src/cmd/public.rs | 2 +- src/cmd/show.rs | 4 ++-- src/main.rs | 2 +- src/model/snapshot.rs | 2 +- src/{collector => net}/iface.rs | 0 src/{collector => net}/mod.rs | 4 ++-- src/{collector => net}/sys.rs | 0 src/renderer/json.rs | 2 +- src/renderer/tree.rs | 18 +++++++------- src/renderer/yaml.rs | 2 +- 16 files changed, 67 insertions(+), 30 deletions(-) rename src/{collector => net}/iface.rs (100%) rename src/{collector => net}/mod.rs (58%) rename src/{collector => net}/sys.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index d1573fc..e7d30e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,6 +922,21 @@ dependencies = [ "log", ] +[[package]] +name = "netroute" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59eb911513f79dbbd83c7cb5ec059f1c4631c1b1f52f1703f52538d50a9aa200" +dependencies = [ + "libc", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "serde", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + [[package]] name = "nifa" version = "0.3.1" @@ -934,6 +949,7 @@ dependencies = [ "libc", "ndb-oui", "netdev", + "netroute", "os_info", "ratatui", "reqwest", @@ -1108,7 +1124,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -1129,7 +1145,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -1634,13 +1650,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 68055d2..89308ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml = { version = "0.9" } netdev = { version = "0.39", features = ["serde"] } +netroute = { version = "0.3", features = ["serde"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } clap = { version = "4.5", features = ["derive", "cargo"] } diff --git a/src/cmd/export.rs b/src/cmd/export.rs index 39704b8..124e7fd 100644 --- a/src/cmd/export.rs +++ b/src/cmd/export.rs @@ -4,7 +4,7 @@ use crate::cli::{Cli, ExportArgs, OutputFormat}; use anyhow::{Context, Result}; pub fn export_snapshot(cli: &Cli, args: &ExportArgs) -> Result<()> { - let snapshot = crate::collector::collect_snapshot()?; + let snapshot = crate::net::collect_snapshot()?; let (bytes, ext_default) = match cli.format { OutputFormat::Json | OutputFormat::Tree => { // tree are ignored for export, default to json diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 059945e..e85d67e 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,6 +1,6 @@ use crate::cli::Cli; use crate::cli::ListArgs; -use crate::collector; +use crate::net; use crate::renderer; use netdev::Interface; use netdev::interface::state::OperState; @@ -8,11 +8,11 @@ use netdev::interface::state::OperState; /// Default action with no subcommand pub fn show_interfaces(cli: &Cli) { let interfaces: Vec = if cli.default { - collector::iface::get_default_interface() + net::iface::get_default_interface() .into_iter() .collect() } else { - collector::iface::collect_all_interfaces() + net::iface::collect_all_interfaces() }; // Render output match cli.format { @@ -23,7 +23,7 @@ pub fn show_interfaces(cli: &Cli) { } pub fn list_interfaces(cli: &Cli, args: &ListArgs) { - let mut interfaces: Vec = collector::iface::collect_all_interfaces(); + let mut interfaces: Vec = net::iface::collect_all_interfaces(); // Apply filters if let Some(name_like) = &args.name_like { diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index f978358..983f1f1 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -25,7 +25,7 @@ use termtree::Tree; use crate::cli::Cli; use crate::cli::MonitorArgs; -use crate::collector::iface::collect_all_interfaces; +use crate::net::iface::collect_all_interfaces; use crate::renderer::tree::{fmt_bps, fmt_flags, tree_label}; #[derive(Clone, Copy, Debug, ValueEnum)] @@ -474,7 +474,7 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { } fn iface_to_text(iface: &netdev::Interface) -> String { - let host = crate::collector::sys::hostname(); + let host = crate::net::sys::hostname(); let title = format!( "{}{} on {}", iface.name, diff --git a/src/cmd/os.rs b/src/cmd/os.rs index 829319f..d28511e 100644 --- a/src/cmd/os.rs +++ b/src/cmd/os.rs @@ -2,8 +2,8 @@ use crate::cli::Cli; /// Show system network stack details pub fn show_system_net_stack(cli: &Cli) { - let sys_info = crate::collector::sys::system_info(); - let default_iface_opt = crate::collector::iface::get_default_interface(); + let sys_info = crate::net::sys::system_info(); + let default_iface_opt = crate::net::iface::get_default_interface(); match cli.format { crate::cli::OutputFormat::Tree => { crate::renderer::tree::print_system_with_default_iface(&sys_info, default_iface_opt) diff --git a/src/cmd/public.rs b/src/cmd/public.rs index 08327c7..82eceb6 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -48,7 +48,7 @@ pub async fn show_public_ip_info(cli: &Cli, args: &PublicArgs) -> Result<()> { let out = build_public_out(v4, v6); - let default_iface_opt = crate::collector::iface::get_default_interface(); + let default_iface_opt = crate::net::iface::get_default_interface(); match cli.format { OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), diff --git a/src/cmd/show.rs b/src/cmd/show.rs index 9262a96..d364156 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -1,11 +1,11 @@ use crate::cli::Cli; use crate::cli::ShowArgs; -use crate::collector; +use crate::net; use crate::renderer; /// Show specified interface details pub fn show_interface(cli: &Cli, args: &ShowArgs) { - match collector::iface::get_interface_by_name(&args.iface) { + match net::iface::get_interface_by_name(&args.iface) { Some(iface) => { // Render output match cli.format { diff --git a/src/main.rs b/src/main.rs index a6076b8..6a3482e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; mod cli; mod cmd; -mod collector; +mod net; mod db; mod model; mod renderer; diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index bb89c88..b0cbfa2 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -1,7 +1,7 @@ use netdev::Interface; use serde::{Deserialize, Serialize}; -use crate::collector::sys::SysInfo; +use crate::net::sys::SysInfo; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Snapshot { diff --git a/src/collector/iface.rs b/src/net/iface.rs similarity index 100% rename from src/collector/iface.rs rename to src/net/iface.rs diff --git a/src/collector/mod.rs b/src/net/mod.rs similarity index 58% rename from src/collector/mod.rs rename to src/net/mod.rs index 1b4875c..c8f030a 100644 --- a/src/collector/mod.rs +++ b/src/net/mod.rs @@ -6,7 +6,7 @@ use anyhow::Result; use crate::model::snapshot::Snapshot; pub fn collect_snapshot() -> Result { - let sys = crate::collector::sys::system_info(); - let interfaces = crate::collector::iface::collect_all_interfaces(); + let sys = crate::net::sys::system_info(); + let interfaces = crate::net::iface::collect_all_interfaces(); Ok(Snapshot { sys, interfaces }) } diff --git a/src/collector/sys.rs b/src/net/sys.rs similarity index 100% rename from src/collector/sys.rs rename to src/net/sys.rs diff --git a/src/renderer/json.rs b/src/renderer/json.rs index 282b3e4..3bd28bd 100644 --- a/src/renderer/json.rs +++ b/src/renderer/json.rs @@ -1,4 +1,4 @@ -use crate::{collector::sys::SysInfo, model::snapshot::Snapshot}; +use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; pub fn print_interface_json(ifaces: &[Interface]) { diff --git a/src/renderer/tree.rs b/src/renderer/tree.rs index c376ad2..8db7f4d 100644 --- a/src/renderer/tree.rs +++ b/src/renderer/tree.rs @@ -2,7 +2,7 @@ use netdev::{Interface, MacAddr}; use termtree::Tree; use url::Url; -use crate::{collector::sys::SysInfo, db::oui::is_oui_db_initialized, model::ipinfo::PublicOut}; +use crate::{net::sys::SysInfo, db::oui::is_oui_db_initialized, model::ipinfo::PublicOut}; /// Convert a string into a tree label. pub fn tree_label>(s: S) -> String { @@ -47,7 +47,7 @@ pub fn print_interface_tree(ifaces: &[Interface]) { } else { false }; - let host = crate::collector::sys::hostname(); + let host = crate::net::sys::hostname(); let mut root = if default { Tree::new(tree_label(format!("Default Interface on {}", host))) } else { @@ -138,7 +138,7 @@ pub fn print_interface_tree(ifaces: &[Interface]) { } if iface.default { - let vpn_heuristic = crate::collector::iface::detect_vpn_like(&iface); + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); if vpn_heuristic.is_vpn_like { let mut heuristic_node = Tree::new(tree_label("Heuristic")); heuristic_node.push(Tree::new(format!( @@ -156,7 +156,7 @@ pub fn print_interface_tree(ifaces: &[Interface]) { /// Print detailed information of a single interface in a tree structure. pub fn print_interface_detail_tree(iface: &Interface) { - let host = crate::collector::sys::hostname(); + let host = crate::net::sys::hostname(); let title = format!( "{}{} on {}", iface.name, @@ -268,7 +268,7 @@ pub fn print_interface_detail_tree(iface: &Interface) { root.push(stats_node); } - let vpn_heuristic = crate::collector::iface::detect_vpn_like(&iface); + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); if vpn_heuristic.is_vpn_like { let mut heuristic_node = Tree::new(tree_label("Heuristic")); heuristic_node.push(Tree::new(format!( @@ -306,7 +306,7 @@ pub fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option) { - let host = crate::collector::sys::hostname(); + let host = crate::net::sys::hostname(); let mut root = Tree::new(tree_label(format!("Public IPs on {}", host))); let mut v4node = Tree::new(tree_label("IPv4")); @@ -629,7 +629,7 @@ pub fn print_public_ip_tree(out: &PublicOut, default_iface: Option) { if_node.push(gw_node); } - let vpn_heuristic = crate::collector::iface::detect_vpn_like(&iface); + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); if vpn_heuristic.is_vpn_like { let mut heuristic_node = Tree::new(tree_label("Heuristic")); heuristic_node.push(Tree::new(format!( diff --git a/src/renderer/yaml.rs b/src/renderer/yaml.rs index c7dcef2..cbbfa4d 100644 --- a/src/renderer/yaml.rs +++ b/src/renderer/yaml.rs @@ -1,4 +1,4 @@ -use crate::{collector::sys::SysInfo, model::snapshot::Snapshot}; +use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; pub fn print_interface_yaml(ifaces: &[Interface]) { From 4a052a55fbd306c6012e04839c284285f4fe3f80 Mon Sep 17 00:00:00 2001 From: shellrow Date: Thu, 13 Nov 2025 00:28:23 +0900 Subject: [PATCH 03/24] Add routing table sub-command - Add routing tables sub-command - Add neighbor table sub-command - Some refactoring --- Cargo.lock | 83 ++++- Cargo.toml | 2 + src/cli.rs | 74 ++++- src/cmd/export.rs | 40 --- src/cmd/list.rs | 178 +++++++++- src/cmd/mod.rs | 3 +- src/cmd/monitor.rs | 3 +- src/cmd/neigh.rs | 74 +++++ src/cmd/os.rs | 218 +++++++++++- src/cmd/public.rs | 203 +++++++++++- src/cmd/route.rs | 126 +++++++ src/cmd/show.rs | 164 ++++++++- src/fs.rs | 48 +++ src/main.rs | 10 +- src/net/mod.rs | 12 +- src/net/neigh/mod.rs | 9 + src/net/neigh/os/darwin.rs | 324 ++++++++++++++++++ src/net/neigh/os/linux.rs | 256 ++++++++++++++ src/net/neigh/os/mod.rs | 17 + src/net/neigh/os/windows.rs | 106 ++++++ src/net/route.rs | 6 + src/renderer/mod.rs | 19 ++ src/renderer/table.rs | 27 ++ src/renderer/tree.rs | 644 ------------------------------------ 24 files changed, 1912 insertions(+), 734 deletions(-) delete mode 100644 src/cmd/export.rs create mode 100644 src/cmd/neigh.rs create mode 100644 src/cmd/route.rs create mode 100644 src/fs.rs create mode 100644 src/net/neigh/mod.rs create mode 100644 src/net/neigh/os/darwin.rs create mode 100644 src/net/neigh/os/linux.rs create mode 100644 src/net/neigh/os/mod.rs create mode 100644 src/net/neigh/os/windows.rs create mode 100644 src/net/route.rs create mode 100644 src/renderer/table.rs diff --git a/Cargo.lock b/Cargo.lock index e7d30e6..deab05f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "comfy-table" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -260,6 +271,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -321,6 +346,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -333,6 +367,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -774,12 +818,24 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -943,10 +999,12 @@ version = "0.3.1" dependencies = [ "anyhow", "clap", - "crossterm", + "comfy-table", + "crossterm 0.27.0", "hostname", "humansize", "libc", + "mac-addr", "ndb-oui", "netdev", "netroute", @@ -1223,7 +1281,7 @@ checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" dependencies = [ "bitflags", "cassowary", - "crossterm", + "crossterm 0.27.0", "indoc", "itertools", "lru", @@ -1231,7 +1289,7 @@ dependencies = [ "stability", "strum", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1308,6 +1366,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.32" @@ -1885,6 +1956,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 89308ce..3ca4303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ tracing = { version = "0.1" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml = { version = "0.9" } +mac-addr = { version = "0.3.0" } netdev = { version = "0.39", features = ["serde"] } netroute = { version = "0.3", features = ["serde"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } @@ -31,6 +32,7 @@ ratatui = "0.25" crossterm = "0.27" humansize = "2.1" url = "2.5" +comfy-table = "7.2" #tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } #home = { version = "0.5" } diff --git a/src/cli.rs b/src/cli.rs index 2a88cca..4f4ead6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,7 @@ pub struct Cli { #[derive(Debug, Clone, Copy, ValueEnum)] pub enum OutputFormat { Tree, + Table, Json, Yaml, } @@ -42,10 +43,12 @@ pub enum Command { Monitor(MonitorArgs), /// Show OS/network stack/permission information Os, - /// Export snapshot as JSON/YAML - Export(ExportArgs), /// Show public IP information Public(PublicArgs), + /// Show routing tables (IPv4/IPv6) + Route(RouteArgs), + /// Show neighbor table (ARP/NDP) + Neigh(NeighArgs), } /// List command arguments @@ -72,6 +75,15 @@ pub struct ListArgs { /// Show interfaces with IPv6 address only #[arg(long)] pub ipv6: bool, + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, } /// Show command arguments @@ -79,6 +91,15 @@ pub struct ListArgs { pub struct ShowArgs { /// Show details for specified interface pub iface: String, + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, } /// Monitor command arguments @@ -98,14 +119,6 @@ pub struct MonitorArgs { pub unit: Unit, } -/// Export command arguments -#[derive(Args, Debug)] -pub struct ExportArgs { - /// Output file - #[arg(short, long)] - pub output: Option, -} - #[derive(Args, Debug)] pub struct PublicArgs { /// IPv4 only @@ -114,4 +127,45 @@ pub struct PublicArgs { /// Timeout seconds #[arg(long, default_value_t = 3)] pub timeout: u64, + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug)] +pub enum RouteFamilyOpt { All, Ipv4, Ipv6 } + +#[derive(Args, Debug)] +pub struct RouteArgs { + /// Family filter + #[arg(long, value_enum, default_value_t = RouteFamilyOpt::All)] + pub family: RouteFamilyOpt, + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, +} + +#[derive(Args, Debug)] +pub struct NeighArgs { + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, } diff --git a/src/cmd/export.rs b/src/cmd/export.rs deleted file mode 100644 index 124e7fd..0000000 --- a/src/cmd/export.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{fs, io::Write, path::Path}; - -use crate::cli::{Cli, ExportArgs, OutputFormat}; -use anyhow::{Context, Result}; - -pub fn export_snapshot(cli: &Cli, args: &ExportArgs) -> Result<()> { - let snapshot = crate::net::collect_snapshot()?; - let (bytes, ext_default) = match cli.format { - OutputFormat::Json | OutputFormat::Tree => { - // tree are ignored for export, default to json - (serde_json::to_vec_pretty(&snapshot)?, "json") - } - OutputFormat::Yaml => (serde_yaml::to_string(&snapshot)?.into_bytes(), "yaml"), - }; - if let Some(path) = &args.output { - atomic_write(path, &bytes, ext_default)?; - eprintln!("Exported {} bytes to {}", bytes.len(), path.display()); - } else { - // if no output file, write to stdout - std::io::stdout() - .write_all(&bytes) - .context("write stdout")?; - } - Ok(()) -} - -/// Atomically write data to a file (with default extension if missing) -fn atomic_write(path: &Path, data: &[u8], ext_default: &str) -> Result<()> { - // Add default extension if missing - let target = if path.extension().is_none() { - path.with_extension(ext_default) - } else { - path.to_path_buf() - }; - - let tmp = target.with_extension("tmp"); - fs::write(&tmp, data).with_context(|| format!("write temp {}", tmp.display()))?; - fs::rename(&tmp, &target).with_context(|| format!("rename to {}", target.display()))?; - Ok(()) -} diff --git a/src/cmd/list.rs b/src/cmd/list.rs index e85d67e..b23e05b 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,9 +1,14 @@ use crate::cli::Cli; use crate::cli::ListArgs; +use crate::db::oui::is_oui_db_initialized; use crate::net; use crate::renderer; +use crate::renderer::table::make_table; +use crate::renderer::tree::tree_label; +use mac_addr::MacAddr; use netdev::Interface; use netdev::interface::state::OperState; +use termtree::Tree; /// Default action with no subcommand pub fn show_interfaces(cli: &Cli) { @@ -16,13 +21,14 @@ pub fn show_interfaces(cli: &Cli) { }; // Render output match cli.format { - crate::cli::OutputFormat::Tree => renderer::tree::print_interface_tree(&interfaces), + crate::cli::OutputFormat::Tree => print_interface_tree(&interfaces), crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), + crate::cli::OutputFormat::Table => print_interface_table(&interfaces), } } -pub fn list_interfaces(cli: &Cli, args: &ListArgs) { +pub fn list_interfaces(_cli: &Cli, args: &ListArgs) { let mut interfaces: Vec = net::iface::collect_all_interfaces(); // Apply filters @@ -48,10 +54,168 @@ pub fn list_interfaces(cli: &Cli, args: &ListArgs) { interfaces.retain(|iface| !iface.ipv6.is_empty()); } - // Render output - match cli.format { - crate::cli::OutputFormat::Tree => renderer::tree::print_interface_tree(&interfaces), - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), + if args.export { + crate::fs::export( + args.format, + args.output.as_deref(), + &interfaces, + ).unwrap_or_else(|e| { + tracing::error!("Export failed: {}", e); + }); + }else{ + // Render output + match args.format { + crate::cli::OutputFormat::Tree => print_interface_tree(&interfaces), + crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), + crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), + crate::cli::OutputFormat::Table => print_interface_table(&interfaces), + } } } + +/// Print the network interfaces in a tree structure. +fn print_interface_tree(ifaces: &[Interface]) { + let default: bool = if ifaces.len() == 1 { + ifaces[0].default + } else { + false + }; + let host = crate::net::sys::hostname(); + let mut root = if default { + Tree::new(tree_label(format!("Default Interface on {}", host))) + } else { + Tree::new(tree_label(format!("Interfaces on {}", host))) + }; + for iface in ifaces { + let mut node = Tree::new(format!( + "{}{}", + iface.name, + if iface.default { " (default)" } else { "" } + )); + + node.push(Tree::new(format!("Index: {}", iface.index))); + + if let Some(fn_name) = &iface.friendly_name { + node.push(Tree::new(format!("Friendly Name: {}", fn_name))); + } + if let Some(desc) = &iface.description { + node.push(Tree::new(format!("Description: {}", desc))); + } + + node.push(Tree::new(format!("Type: {:?}", iface.if_type))); + node.push(Tree::new(format!("State: {:?}", iface.oper_state))); + if let Some(mac) = &iface.mac_addr { + node.push(Tree::new(format!("MAC: {}", mac))); + + if is_oui_db_initialized() && *mac != MacAddr::zero() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + node.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + } + + if let Some(mtu) = iface.mtu { + node.push(Tree::new(format!("MTU: {}", mtu))); + } + + if !iface.ipv4.is_empty() { + let mut ipv4_tree = Tree::new(tree_label("IPv4")); + for net in &iface.ipv4 { + ipv4_tree.push(Tree::new(net.to_string())); + } + node.push(ipv4_tree); + } + + if !iface.ipv6.is_empty() { + let mut ipv6_tree = Tree::new(tree_label("IPv6")); + for (i, net) in iface.ipv6.iter().enumerate() { + let mut label = net.to_string(); + if let Some(scope) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", scope)); + } + ipv6_tree.push(Tree::new(label)); + } + node.push(ipv6_tree); + } + + if !iface.dns_servers.is_empty() { + let mut dns_tree = Tree::new(tree_label("DNS")); + for dns in &iface.dns_servers { + dns_tree.push(Tree::new(dns.to_string())); + } + node.push(dns_tree); + } + + if let Some(gw) = &iface.gateway { + let mut gw_node = Tree::new(tree_label("Gateway")); + // GW MAC + gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); + // GW IPv4/IPv6 + if !gw.ipv4.is_empty() { + let mut gw_tree = Tree::new(tree_label("IPv4")); + for ip in &gw.ipv4 { + gw_tree.push(Tree::new(ip.to_string())); + } + gw_node.push(gw_tree); + } + if !gw.ipv6.is_empty() { + let mut gw_tree = Tree::new(tree_label("IPv6")); + for ip in &gw.ipv6 { + gw_tree.push(Tree::new(ip.to_string())); + } + gw_node.push(gw_tree); + } + node.push(gw_node); + } + + if iface.default { + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); + if vpn_heuristic.is_vpn_like { + let mut heuristic_node = Tree::new(tree_label("Heuristic")); + heuristic_node.push(Tree::new(format!( + "VPN-like: {}", + vpn_heuristic.is_vpn_like + ))); + node.push(heuristic_node); + } + } + + root.push(node); + } + println!("{}", root); +} + +fn print_interface_table(ifs: &[Interface]) { + let mut table = make_table(&["INDEX", "NAME", "TYPE", "STATE", "MAC", "IPv4", "IPv6", "MTU"]); + for iface in ifs { + let ipv4 = iface.ipv4 + .iter() + .map(|n| n.addr().to_string()) + .collect::>() + .join(", "); + let ipv6 = iface.ipv6 + .iter() + .map(|n| n.addr().to_string()) + .collect::>() + .join(", "); + + table.add_row(vec![ + iface.index.to_string(), + iface.name.clone(), + iface.if_type.name(), + iface.oper_state.to_string(), + iface.mac_addr + .map(|m| m.to_string()) + .unwrap_or_else(|| "-".into()), + ipv4, + ipv6, + iface.mtu + .map(|m| m.to_string()) + .unwrap_or_else(|| "-".into()), + ]); + } + + println!("{table}"); +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 9c839a5..48752e4 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,7 @@ -pub mod export; pub mod list; pub mod monitor; pub mod os; pub mod public; pub mod show; +pub mod route; +pub mod neigh; diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index 983f1f1..42366d5 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -26,7 +26,8 @@ use termtree::Tree; use crate::cli::Cli; use crate::cli::MonitorArgs; use crate::net::iface::collect_all_interfaces; -use crate::renderer::tree::{fmt_bps, fmt_flags, tree_label}; +use crate::renderer::{fmt_bps, fmt_flags}; +use crate::renderer::tree::tree_label; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum SortKey { diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs new file mode 100644 index 0000000..3343598 --- /dev/null +++ b/src/cmd/neigh.rs @@ -0,0 +1,74 @@ +use anyhow::Result; +use crate::cli::{Cli, OutputFormat, NeighArgs}; +use crate::net::neigh; +use crate::renderer::table::make_table; +use termtree::Tree; +use crate::renderer::tree::tree_label; + +pub fn run_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { + let table = neigh::get_neighbor_table()?; // HashMap + + if args.export { + crate::fs::export( + args.format, + args.output.as_deref(), + &table, + ).unwrap_or_else(|e| { + tracing::error!("Export failed: {}", e); + }); + } else { + match args.format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&table)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&table)?), + OutputFormat::Table => { + print_neigh_table(&table); + } + OutputFormat::Tree => { + print_neigh_tree(&table); + } + } + } + + Ok(()) +} + +fn print_neigh_tree(table: &std::collections::HashMap) { + let host = crate::net::sys::hostname(); + let mut root = Tree::new(tree_label(format!("Neighbors (ARP/NDP) on {}", host))); + + let mut v4 = Tree::new(tree_label("IPv4")); + let mut v6 = Tree::new(tree_label("IPv6")); + + let mut keys: Vec<_> = table.keys().cloned().collect(); + keys.sort_by(|a,b| a.to_string().cmp(&b.to_string())); + + for ip in keys { + let mac = table.get(&ip).unwrap(); + let leaf = Tree::new(format!("{} -> {}", ip, mac)); + match ip { + std::net::IpAddr::V4(_) => { + v4.push(leaf); + }, + std::net::IpAddr::V6(_) => { + v6.push(leaf); + }, + } + } + + if !v4.leaves.is_empty() { root.push(v4); } + if !v6.leaves.is_empty() { root.push(v6); } + println!("{}", root); +} + +fn print_neigh_table(table: &std::collections::HashMap) { + let mut tbl = make_table(&["IP ADDRESS", "MAC ADDRESS"]); + + let mut rows: Vec<_> = table.iter().collect(); + rows.sort_by(|(a, _), (b, _)| a.to_string().cmp(&b.to_string())); + + for (ip, mac) in rows { + tbl.add_row(vec![ip.to_string(), mac.to_string()]); + } + + println!("{tbl}"); +} diff --git a/src/cmd/os.rs b/src/cmd/os.rs index d28511e..c47b13e 100644 --- a/src/cmd/os.rs +++ b/src/cmd/os.rs @@ -1,4 +1,9 @@ -use crate::cli::Cli; +use netdev::Interface; +use termtree::Tree; +use url::Url; +use mac_addr::MacAddr; + +use crate::{cli::Cli, db::oui::is_oui_db_initialized, net::sys::SysInfo, renderer::{fmt_bps, tree::tree_label}}; /// Show system network stack details pub fn show_system_net_stack(cli: &Cli) { @@ -6,7 +11,7 @@ pub fn show_system_net_stack(cli: &Cli) { let default_iface_opt = crate::net::iface::get_default_interface(); match cli.format { crate::cli::OutputFormat::Tree => { - crate::renderer::tree::print_system_with_default_iface(&sys_info, default_iface_opt) + print_system_with_default_iface(&sys_info, default_iface_opt) } crate::cli::OutputFormat::Json => { crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) @@ -14,5 +19,214 @@ pub fn show_system_net_stack(cli: &Cli) { crate::cli::OutputFormat::Yaml => { crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) } + _ => { + tracing::error!( + "Unsupported format for show system network stack: {:?}", + cli.format + ); + } + } +} + +/// Mask username/password in proxy URL for privacy +fn mask_proxy_url(raw: &str) -> String { + if let Ok(mut url) = Url::parse(raw) { + if url.password().is_some() || !url.username().is_empty() { + let user = url.username().to_string(); + let _ = url.set_username(&user).ok(); + let _ = url.set_password(Some("*****")).ok(); + } + return url.to_string(); + } + raw.to_string() +} + +fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option) { + let mut root = Tree::new(tree_label(format!( + "System Information on {}", + sys.hostname + ))); + + // ---- System ---- + let mut sys_node = Tree::new(tree_label("System")); + sys_node.push(Tree::new(tree_label(format!("OS Type: {}", sys.os_type)))); + sys_node.push(Tree::new(tree_label(format!( + "Version: {}", + sys.os_version + )))); + if let Some(kv) = &sys.kernel_version { + sys_node.push(Tree::new(tree_label(format!("Kernel: {}", kv)))); } + sys_node.push(Tree::new(tree_label(format!("Edition: {}", sys.edition)))); + sys_node.push(Tree::new(tree_label(format!("Codename: {}", sys.codename)))); + sys_node.push(Tree::new(tree_label(format!("Bitness: {}", sys.bitness)))); + sys_node.push(Tree::new(tree_label(format!( + "Architecture: {}", + sys.architecture + )))); + + // ---- Proxy (env) ---- + let px = crate::net::sys::collect_proxy_env(); + let mut px_node = Tree::new(tree_label("Proxy (env)")); + px_node.push(Tree::new(format!( + "HTTP_PROXY: {}", + px.http + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + px_node.push(Tree::new(format!( + "HTTPS_PROXY: {}", + px.https + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + px_node.push(Tree::new(format!( + "ALL_PROXY: {}", + px.all + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + if let Some(np) = px.no_proxy.as_deref() { + let mut np_node = Tree::new(tree_label("NO_PROXY")); + // Split by comma and trim spaces, ignore empty parts + for (i, part) in np + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .enumerate() + { + // Limit to first 20 entries + if i < 20 { + np_node.push(Tree::new(part.to_string())); + } else { + np_node.push(Tree::new(format!( + "(+{} more)", + np.split(',').count().saturating_sub(20) + ))); + break; + } + } + px_node.push(np_node); + } else { + px_node.push(Tree::new(tree_label("NO_PROXY: (none)"))); + } + sys_node.push(px_node); + + root.push(sys_node); + + // ---- Default Interface (optional) ---- + if let Some(iface) = default_iface { + let mut if_node = Tree::new(tree_label(format!("Default Interface: {}", iface.name))); + + if let Some(fn_name) = &iface.friendly_name { + if_node.push(Tree::new(tree_label(format!("Friendly Name: {}", fn_name)))); + } + if let Some(desc) = &iface.description { + if_node.push(Tree::new(tree_label(format!("Description: {}", desc)))); + } + + if_node.push(Tree::new(tree_label(format!("Index: {}", iface.index)))); + if_node.push(Tree::new(tree_label(format!("Type: {:?}", iface.if_type)))); + if_node.push(Tree::new(tree_label(format!( + "State: {:?}", + iface.oper_state + )))); + if let Some(mac) = &iface.mac_addr { + if_node.push(Tree::new(tree_label(format!("MAC: {}", mac)))); + + if is_oui_db_initialized() && *mac != MacAddr::zero() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + if_node.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + } + + if let Some(mtu) = iface.mtu { + if_node.push(Tree::new(tree_label(format!("MTU: {}", mtu)))); + } + + // Speeds + if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { + let mut speed = Tree::new(tree_label("Link Speed")); + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(tree_label(format!("TX: {}", fmt_bps(tx))))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(tree_label(format!("RX: {}", fmt_bps(rx))))); + } + if_node.push(speed); + } + + // IPv4 + if !iface.ipv4.is_empty() { + let mut ipv4_node = Tree::new(tree_label("IPv4")); + for n in &iface.ipv4 { + ipv4_node.push(Tree::new(tree_label(n.to_string()))); + } + if_node.push(ipv4_node); + } + // IPv6 with scope ID + if !iface.ipv6.is_empty() { + let mut ipv6_node = Tree::new(tree_label("IPv6")); + for (i, n) in iface.ipv6.iter().enumerate() { + let mut label = n.to_string(); + if let Some(sc) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", sc)); + } + ipv6_node.push(Tree::new(tree_label(label))); + } + if_node.push(ipv6_node); + } + + // DNS + if !iface.dns_servers.is_empty() { + let mut dns = Tree::new(tree_label("DNS")); + for s in &iface.dns_servers { + dns.push(Tree::new(tree_label(s.to_string()))); + } + if_node.push(dns); + } + + // Gateway (IP + MAC) + if let Some(gw) = &iface.gateway { + let mut gw_node = Tree::new(tree_label("Gateway")); + gw_node.push(Tree::new(tree_label(format!("MAC: {}", gw.mac_addr)))); + if !gw.ipv4.is_empty() { + let mut gw4 = Tree::new(tree_label("IPv4")); + for ip in &gw.ipv4 { + gw4.push(Tree::new(tree_label(ip.to_string()))); + } + gw_node.push(gw4); + } + if !gw.ipv6.is_empty() { + let mut gw6 = Tree::new(tree_label("IPv6")); + for ip in &gw.ipv6 { + gw6.push(Tree::new(tree_label(ip.to_string()))); + } + gw_node.push(gw6); + } + if_node.push(gw_node); + } + + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); + if vpn_heuristic.is_vpn_like { + let mut heuristic_node = Tree::new(tree_label("Heuristic")); + heuristic_node.push(Tree::new(format!( + "VPN-like: {}", + vpn_heuristic.is_vpn_like + ))); + if_node.push(heuristic_node); + } + + root.push(if_node); + } else { + root.push(Tree::new(tree_label("Default Interface: (not found)"))); + } + + println!("{}", root); } diff --git a/src/cmd/public.rs b/src/cmd/public.rs index 82eceb6..e913ac8 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -1,10 +1,15 @@ use anyhow::{Context, Result}; +use netdev::Interface; +use mac_addr::MacAddr; use reqwest::Client; +use termtree::Tree; use std::time::Duration; use crate::cli::{Cli, OutputFormat, PublicArgs}; +use crate::db::oui::is_oui_db_initialized; use crate::model::ipinfo::{CommonInfo, IpInfo, IpSide, PublicOut}; -use crate::renderer::tree::print_public_ip_tree; +use crate::renderer::fmt_bps; +use crate::renderer::tree::tree_label; const IPSTRUCT_URL: &str = "https://api.ipstruct.com/ip"; const IPSTRUCT_V4_URL: &str = "https://ipv4.ipstruct.com/ip"; @@ -12,7 +17,7 @@ const IPSTRUCT_V4_URL: &str = "https://ipv4.ipstruct.com/ip"; const IP_VERSION_6: &str = "v6"; /// Show public IP information -pub async fn show_public_ip_info(cli: &Cli, args: &PublicArgs) -> Result<()> { +pub async fn show_public_ip_info(_cli: &Cli, args: &PublicArgs) -> Result<()> { let client = Client::builder() .timeout(Duration::from_secs(args.timeout.max(1))) .build() @@ -50,10 +55,20 @@ pub async fn show_public_ip_info(cli: &Cli, args: &PublicArgs) -> Result<()> { let default_iface_opt = crate::net::iface::get_default_interface(); - match cli.format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), - _ => print_public_ip_tree(&out, default_iface_opt), + if args.export { + crate::fs::export( + args.format, + args.output.as_deref(), + &out, + ).unwrap_or_else(|e| { + tracing::error!("Export failed: {}", e); + }); + } else { + match args.format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), + _ => print_public_ip_tree(&out, default_iface_opt), + } } Ok(()) } @@ -168,3 +183,179 @@ fn build_public_out(v4: Option, v6: Option) -> PublicOut { } } } + +fn print_public_ip_tree(out: &PublicOut, default_iface: Option) { + let host = crate::net::sys::hostname(); + let mut root = Tree::new(tree_label(format!("Public IPs on {}", host))); + + let mut v4node = Tree::new(tree_label("IPv4")); + if let Some(i) = &out.ipv4 { + v4node.push(Tree::new(tree_label(format!("IP: {}", i.ip_addr)))); + //v4node.push(Tree::new(tree_label(format!("Decimal: {}", i.ip_addr_dec)))); + //v4node.push(Tree::new(tree_label(format!("Host: {}", i.host_name)))); + v4node.push(Tree::new(tree_label(format!("Network: {}", i.network)))); + if out.common.is_none() { + if let Some(asn) = &i.asn { + v4node.push(Tree::new(tree_label(format!("ASN: {}", asn)))); + } + if let Some(n) = &i.as_name { + v4node.push(Tree::new(tree_label(format!("AS Name: {}", n)))); + } + if let Some(cc) = &i.country_code { + let cn = i.country_name.as_deref().unwrap_or(""); + v4node.push(Tree::new(tree_label(format!("Country: {} ({})", cn, cc)))); + } + } + } else { + v4node.push(Tree::new(tree_label("(none)"))); + } + root.push(v4node); + + let mut v6node = Tree::new(tree_label("IPv6")); + if let Some(i) = &out.ipv6 { + v6node.push(Tree::new(tree_label(format!("IP: {}", i.ip_addr)))); + //v6node.push(Tree::new(tree_label(format!("Decimal: {}", i.ip_addr_dec)))); + //v6node.push(Tree::new(tree_label(format!("Host: {}", i.host_name)))); + v6node.push(Tree::new(tree_label(format!("Network: {}", i.network)))); + if out.common.is_none() { + if let Some(asn) = &i.asn { + v6node.push(Tree::new(tree_label(format!("ASN: {}", asn)))); + } + if let Some(n) = &i.as_name { + v6node.push(Tree::new(tree_label(format!("AS Name: {}", n)))); + } + if let Some(cc) = &i.country_code { + let cn = i.country_name.as_deref().unwrap_or(""); + v6node.push(Tree::new(tree_label(format!("Country: {} ({})", cn, cc)))); + } + } + } else { + v6node.push(Tree::new(tree_label("(none)"))); + } + root.push(v6node); + + if let Some(c) = &out.common { + let mut country_info = Tree::new(tree_label("Country")); + country_info.push(Tree::new(tree_label(format!("Code: {}", c.country_code)))); + country_info.push(Tree::new(tree_label(format!("Name: {}", c.country_name)))); + root.push(country_info); + + let mut as_info = Tree::new(tree_label("AS Info")); + as_info.push(Tree::new(tree_label(format!("ASN: {}", c.asn)))); + as_info.push(Tree::new(tree_label(format!("AS Name: {}", c.as_name)))); + root.push(as_info); + } + + // ---- Default Interface (optional) ---- + if let Some(iface) = default_iface { + let mut if_node = Tree::new(tree_label(format!("Default Interface: {}", iface.name))); + + if let Some(fn_name) = &iface.friendly_name { + if_node.push(Tree::new(tree_label(format!("Friendly Name: {}", fn_name)))); + } + if let Some(desc) = &iface.description { + if_node.push(Tree::new(tree_label(format!("Description: {}", desc)))); + } + + if_node.push(Tree::new(tree_label(format!("Index: {}", iface.index)))); + if_node.push(Tree::new(tree_label(format!("Type: {:?}", iface.if_type)))); + if_node.push(Tree::new(tree_label(format!( + "State: {:?}", + iface.oper_state + )))); + if let Some(mac) = &iface.mac_addr { + if_node.push(Tree::new(tree_label(format!("MAC: {}", mac)))); + + if is_oui_db_initialized() && *mac != MacAddr::zero() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + if_node.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + } + + if let Some(mtu) = iface.mtu { + if_node.push(Tree::new(tree_label(format!("MTU: {}", mtu)))); + } + + // Speeds + if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { + let mut speed = Tree::new(tree_label("Link Speed")); + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(tree_label(format!("TX: {}", fmt_bps(tx))))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(tree_label(format!("RX: {}", fmt_bps(rx))))); + } + if_node.push(speed); + } + + // IPv4 + if !iface.ipv4.is_empty() { + let mut ipv4_node = Tree::new(tree_label("IPv4")); + for n in &iface.ipv4 { + ipv4_node.push(Tree::new(tree_label(n.to_string()))); + } + if_node.push(ipv4_node); + } + // IPv6 with scope ID + if !iface.ipv6.is_empty() { + let mut ipv6_node = Tree::new(tree_label("IPv6")); + for (i, n) in iface.ipv6.iter().enumerate() { + let mut label = n.to_string(); + if let Some(sc) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", sc)); + } + ipv6_node.push(Tree::new(tree_label(label))); + } + if_node.push(ipv6_node); + } + + // DNS + if !iface.dns_servers.is_empty() { + let mut dns = Tree::new(tree_label("DNS")); + for s in &iface.dns_servers { + dns.push(Tree::new(tree_label(s.to_string()))); + } + if_node.push(dns); + } + + // Gateway (IP + MAC) + if let Some(gw) = &iface.gateway { + let mut gw_node = Tree::new(tree_label("Gateway")); + gw_node.push(Tree::new(tree_label(format!("MAC: {}", gw.mac_addr)))); + if !gw.ipv4.is_empty() { + let mut gw4 = Tree::new(tree_label("IPv4")); + for ip in &gw.ipv4 { + gw4.push(Tree::new(tree_label(ip.to_string()))); + } + gw_node.push(gw4); + } + if !gw.ipv6.is_empty() { + let mut gw6 = Tree::new(tree_label("IPv6")); + for ip in &gw.ipv6 { + gw6.push(Tree::new(tree_label(ip.to_string()))); + } + gw_node.push(gw6); + } + if_node.push(gw_node); + } + + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); + if vpn_heuristic.is_vpn_like { + let mut heuristic_node = Tree::new(tree_label("Heuristic")); + heuristic_node.push(Tree::new(format!( + "VPN-like: {}", + vpn_heuristic.is_vpn_like + ))); + if_node.push(heuristic_node); + } + + root.push(if_node); + } else { + root.push(Tree::new(tree_label("Default Interface: (not found)"))); + } + + println!("{}", root); +} diff --git a/src/cmd/route.rs b/src/cmd/route.rs new file mode 100644 index 0000000..1b01cc7 --- /dev/null +++ b/src/cmd/route.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use crate::cli::{Cli, OutputFormat, RouteArgs, RouteFamilyOpt}; +use crate::net::route; +use crate::renderer::table::make_table; +use netroute::{RouteEntry, RouteFamily, RouteFlag}; +use termtree::Tree; +use crate::renderer::tree::tree_label; + +pub fn run_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { + let mut routes = route::list_routes()?; + + // family filter + routes.retain(|r| match args.family { + RouteFamilyOpt::All => true, + RouteFamilyOpt::Ipv4 => r.family == RouteFamily::Ipv4, + RouteFamilyOpt::Ipv6 => r.family == RouteFamily::Ipv6, + }); + + if args.export { + crate::fs::export( + args.format, + args.output.as_deref(), + &routes, + ).unwrap_or_else(|e| { + tracing::error!("Export failed: {}", e); + }); + } else { + match args.format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&routes)?); + } + OutputFormat::Yaml => { + println!("{}", serde_yaml::to_string(&routes)?); + } + OutputFormat::Table => { + print_route_table(&routes); + } + OutputFormat::Tree => { + print_route_tree(&routes); + } + } + } + + Ok(()) +} + +fn print_route_tree(routes: &[RouteEntry]) { + let host = crate::net::sys::hostname(); + let mut root = Tree::new(tree_label(format!("Routing Table on {}", host))); + + let mut v4 = Tree::new(tree_label("IPv4")); + let mut v6 = Tree::new(tree_label("IPv6")); + + for r in routes { + let mut node = Tree::new(tree_label(format!("{}", r.destination))); + if let Some(gw) = r.gateway { + node.push(Tree::new(format!("via {}", gw))); + } else if r.on_link { + node.push(Tree::new(tree_label("via link"))); + } + + if let Some(name) = r.ifname.as_ref() { + node.push(Tree::new(format!("dev {}", name))); + } else if let Some(idx) = r.ifindex { + node.push(Tree::new(format!("ifindex {}", idx))); + } + + if let Some(m) = r.metric { + node.push(Tree::new(format!("metric {}", m))); + } + + if !r.flags.is_empty() { + let short = flags_short(r); + node.push(Tree::new(format!("flags {}", short))); + } + + if let Some(p) = r.protocol.as_ref() { + node.push(Tree::new(format!("proto {:?}", p))); + } + if let Some(s) = r.scope.as_ref() { + node.push(Tree::new(format!("scope {:?}", s))); + } + if let Some(tbl) = r.table { + node.push(Tree::new(format!("table {}", tbl))); + } + if let Some(ms) = r.lifetime_ms { + node.push(Tree::new(format!("lifetime {}ms", ms))); + } + + match r.family { + RouteFamily::Ipv4 => { + v4.push(node); + }, + RouteFamily::Ipv6 => { + v6.push(node); + }, + } + } + + if !v4.leaves.is_empty() { root.push(v4); } + if !v6.leaves.is_empty() { root.push(v6); } + println!("{}", root); +} + +fn print_route_table(routes: &[RouteEntry]) { + let mut table = make_table(&["FAMILY", "DESTINATION", "VIA/NH", "DEV", "METRIC", "FLAGS"]); + + for r in routes { + let fam = match r.family { RouteFamily::Ipv4 => "v4", RouteFamily::Ipv6 => "v6" }; + let via = r.gateway.map(|g| g.to_string()) + .unwrap_or_else(|| if r.on_link { "link".into() } else { "-".into() }); + let dev = r.ifname.as_deref().unwrap_or("-"); + let metric = r.metric.map(|m| m.to_string()).unwrap_or("-".into()); + let flags = r.flags.iter().map(RouteFlag::short).collect::>().join(""); + table.add_row(vec![ + fam, &r.destination.to_string(), &via, dev, &metric, &flags, + ]); + } + + println!("{table}"); +} + +fn flags_short(r: &RouteEntry) -> String { + if r.flags.is_empty() { return "-".into(); } + r.flags.iter().map(RouteFlag::short).collect::>().join("") +} diff --git a/src/cmd/show.rs b/src/cmd/show.rs index d364156..d752b6d 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -1,19 +1,42 @@ +use netdev::Interface; +use termtree::Tree; +use mac_addr::MacAddr; use crate::cli::Cli; use crate::cli::ShowArgs; +use crate::db::oui::is_oui_db_initialized; use crate::net; use crate::renderer; +use crate::renderer::fmt_bps; +use crate::renderer::fmt_flags; +use crate::renderer::tree::tree_label; /// Show specified interface details -pub fn show_interface(cli: &Cli, args: &ShowArgs) { +pub fn show_interface(_cli: &Cli, args: &ShowArgs) { match net::iface::get_interface_by_name(&args.iface) { Some(iface) => { - // Render output - match cli.format { - crate::cli::OutputFormat::Tree => { - renderer::tree::print_interface_detail_tree(&iface) + if args.export { + crate::fs::export( + args.format, + args.output.as_deref(), + &iface, + ).unwrap_or_else(|e| { + tracing::error!("Export failed: {}", e); + }); + } else { + // Render output + match args.format { + crate::cli::OutputFormat::Tree => { + print_interface_detail_tree(&iface) + } + crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), + crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), + _ => { + tracing::error!( + "Unsupported format for show interface: {:?}", + args.format + ); + } } - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), } } None => { @@ -21,3 +44,130 @@ pub fn show_interface(cli: &Cli, args: &ShowArgs) { } } } + +/// Print detailed information of a single interface in a tree structure. +fn print_interface_detail_tree(iface: &Interface) { + let host = crate::net::sys::hostname(); + let title = format!( + "{}{} on {}", + iface.name, + if iface.default { " (default)" } else { "" }, + host + ); + let mut root = Tree::new(tree_label(title)); + + // flat fields (no General section) + root.push(Tree::new(format!("Index: {}", iface.index))); + + if let Some(fn_name) = &iface.friendly_name { + root.push(Tree::new(format!("Friendly Name: {}", fn_name))); + } + if let Some(desc) = &iface.description { + root.push(Tree::new(format!("Description: {}", desc))); + } + + root.push(Tree::new(format!("Type: {:?}", iface.if_type))); + root.push(Tree::new(format!("State: {:?}", iface.oper_state))); + + if let Some(mac) = &iface.mac_addr { + root.push(Tree::new(format!("MAC: {}", mac))); + + if is_oui_db_initialized() && *mac != MacAddr::zero() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + root.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + } + + if let Some(mtu) = iface.mtu { + root.push(Tree::new(format!("MTU: {}", mtu))); + } + + // link speeds (humanized bps) + if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { + let mut speed = Tree::new(tree_label("Link Speed")); + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); + } + root.push(speed); + } + + // flags + root.push(Tree::new(format!("Flags: {}", fmt_flags(iface.flags)))); + + // ---- Addresses ---- + if !iface.ipv4.is_empty() { + let mut ipv4_tree = Tree::new(tree_label("IPv4")); + for net in &iface.ipv4 { + ipv4_tree.push(Tree::new(net.to_string())); + } + root.push(ipv4_tree); + } + + if !iface.ipv6.is_empty() { + let mut ipv6_tree = Tree::new(tree_label("IPv6")); + for (i, net) in iface.ipv6.iter().enumerate() { + let mut label = net.to_string(); + if let Some(scope) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", scope)); + } + ipv6_tree.push(Tree::new(label)); + } + root.push(ipv6_tree); + } + + // ---- DNS ---- + if !iface.dns_servers.is_empty() { + let mut dns_tree = Tree::new(tree_label("DNS")); + for dns in &iface.dns_servers { + dns_tree.push(Tree::new(dns.to_string())); + } + root.push(dns_tree); + } + + // ---- Gateway ---- + if let Some(gw) = &iface.gateway { + let mut gw_node = Tree::new(tree_label("Gateway")); + gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); + if !gw.ipv4.is_empty() { + let mut gw4 = Tree::new(tree_label("IPv4")); + for ip in &gw.ipv4 { + gw4.push(Tree::new(ip.to_string())); + } + gw_node.push(gw4); + } + if !gw.ipv6.is_empty() { + let mut gw6 = Tree::new(tree_label("IPv6")); + for ip in &gw.ipv6 { + gw6.push(Tree::new(ip.to_string())); + } + gw_node.push(gw6); + } + root.push(gw_node); + } + + // ---- Statistics (snapshot) ---- + if let Some(st) = &iface.stats { + let mut stats_node = Tree::new(tree_label("Statistics (snapshot)")); + stats_node.push(Tree::new(format!("RX bytes: {}", st.rx_bytes))); + stats_node.push(Tree::new(format!("TX bytes: {}", st.tx_bytes))); + root.push(stats_node); + } + + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); + if vpn_heuristic.is_vpn_like { + let mut heuristic_node = Tree::new(tree_label("Heuristic")); + heuristic_node.push(Tree::new(format!( + "VPN-like: {}", + vpn_heuristic.is_vpn_like + ))); + root.push(heuristic_node); + } + + println!("{}", root); +} diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..5929c0c --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,48 @@ +use anyhow::{Context, Result}; +use serde::Serialize; +use std::{fs, io::Write, path::Path}; + +use crate::cli::OutputFormat; + +pub fn export( + format: OutputFormat, + output: Option<&Path>, + data: &T, +) -> Result<()> { + let (bytes, ext_default) = match format { + OutputFormat::Json => (serde_json::to_vec_pretty(data)?, "json"), + OutputFormat::Yaml => (serde_yaml::to_string(data)?.into_bytes(), "yaml"), + other => { + tracing::warn!( + "note: --export with {:?} is not supported; falling back to JSON.", + other + ); + (serde_json::to_vec_pretty(data)?, "json") + } + }; + + if let Some(path) = output { + atomic_write(path, &bytes, ext_default)?; + tracing::info!( + "Exported {} bytes to {}", + bytes.len(), + path.display() + ); + } else { + std::io::stdout().write_all(&bytes).context("write stdout")?; + } + + Ok(()) +} + +fn atomic_write(path: &Path, data: &[u8], ext_default: &str) -> Result<()> { + let target = if path.extension().is_none() { + path.with_extension(ext_default) + } else { + path.to_path_buf() + }; + let tmp = target.with_extension("tmp"); + fs::write(&tmp, data).with_context(|| format!("write temp {}", tmp.display()))?; + fs::rename(&tmp, &target).with_context(|| format!("rename to {}", target.display()))?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 6a3482e..32ef4f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod net; mod db; mod model; mod renderer; +mod fs; use cli::{Cli, Command}; @@ -30,15 +31,18 @@ async fn main() -> Result<()> { Some(Command::Os) => { cmd::os::show_system_net_stack(&cli); } - Some(Command::Export(args)) => { - cmd::export::export_snapshot(&cli, args)?; - } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; } Some(Command::Public(args)) => { cmd::public::show_public_ip_info(&cli, args).await?; } + Some(Command::Route(args)) => { + cmd::route::run_route(&cli, args)?; + } + Some(Command::Neigh(args)) => { + cmd::neigh::run_neigh(&cli, args)?; + } }; Ok(()) } diff --git a/src/net/mod.rs b/src/net/mod.rs index c8f030a..8f4cffe 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1,12 +1,4 @@ pub mod iface; +pub mod neigh; pub mod sys; - -use anyhow::Result; - -use crate::model::snapshot::Snapshot; - -pub fn collect_snapshot() -> Result { - let sys = crate::net::sys::system_info(); - let interfaces = crate::net::iface::collect_all_interfaces(); - Ok(Snapshot { sys, interfaces }) -} +pub mod route; diff --git a/src/net/neigh/mod.rs b/src/net/neigh/mod.rs new file mode 100644 index 0000000..13f0e36 --- /dev/null +++ b/src/net/neigh/mod.rs @@ -0,0 +1,9 @@ +use std::{collections::HashMap, io, net::IpAddr}; + +use netdev::MacAddr; + +mod os; + +pub fn get_neighbor_table() -> io::Result> { + os::get_neighbor_table() +} diff --git a/src/net/neigh/os/darwin.rs b/src/net/neigh/os/darwin.rs new file mode 100644 index 0000000..6c3ad0e --- /dev/null +++ b/src/net/neigh/os/darwin.rs @@ -0,0 +1,324 @@ +#![allow(non_camel_case_types)] + +use libc::{c_char, c_int, c_uchar, pid_t, size_t}; +use std::{ + collections::HashMap, + ffi::c_void, + io, mem, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + ptr, +}; + +use netdev::MacAddr; + +const CTL_NET: c_int = 4; +#[allow(dead_code)] +const AF_ROUTE: c_int = 17; +const PF_ROUTE: c_int = 17; +const AF_LINK: c_int = 18; +const AF_INET: c_int = 2; +const AF_INET6: c_int = 30; + +//const NET_RT_DUMP: c_int = 1; +const NET_RT_FLAGS: c_int = 2; + +const RTM_VERSION: c_uchar = 5; +const RTF_LLINFO: c_int = 1024; + +// sockaddr alignment +const SA_ALIGN: usize = 4; + +#[repr(C)] +#[derive(Copy, Clone)] +struct sockaddr { + sa_len: c_uchar, + sa_family: c_uchar, + sa_data: [c_char; 14], +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +struct rt_metrics { + rmx_locks: u32, + rmx_mtu: u32, + rmx_hopcount: u32, + rmx_expire: i32, + rmx_recvpipe: u32, + rmx_sendpipe: u32, + rmx_ssthresh: u32, + rmx_rtt: u32, + rmx_rttvar: u32, + rmx_pksent: u32, + rmx_state: u32, + rmx_filler: [u32; 3], +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +struct rt_msghdr { + rtm_msglen: u16, + rtm_version: u8, + rtm_type: u8, + rtm_index: u16, + rtm_flags: c_int, + rtm_addrs: c_int, + rtm_pid: pid_t, + rtm_seq: c_int, + rtm_errno: c_int, + rtm_use: c_int, + rtm_inits: u32, + rtm_rmx: rt_metrics, +} + +unsafe extern "C" { + fn sysctl( + name: *mut c_int, + namelen: u32, + oldp: *mut c_void, + oldlenp: *mut size_t, + newp: *mut c_void, + newlen: size_t, + ) -> c_int; +} + +/// Fetches a sysctl value into a Vec. +fn sysctl_vec(mib: &mut [c_int]) -> io::Result> { + let mut len: size_t = 0; + let mut r = unsafe { + sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + ptr::null_mut(), + &mut len, + ptr::null_mut(), + 0, + ) + }; + if r < 0 { + return Err(io::Error::last_os_error()); + } + + let mut buf = vec![0u8; len as usize]; + r = unsafe { + sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + buf.as_mut_ptr() as *mut c_void, + &mut len, + ptr::null_mut(), + 0, + ) + }; + if r < 0 { + // If the value grew, kernel returns ENOMEM. Retry once. + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ENOMEM) { + let mut len2: size_t = 0; + let r2 = unsafe { + sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + ptr::null_mut(), + &mut len2, + ptr::null_mut(), + 0, + ) + }; + if r2 < 0 { + return Err(io::Error::last_os_error()); + } + buf.resize(len2 as usize, 0); + let r3 = unsafe { + sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + buf.as_mut_ptr() as *mut c_void, + &mut len2, + ptr::null_mut(), + 0, + ) + }; + if r3 < 0 { + return Err(io::Error::last_os_error()); + } + buf.truncate(len2 as usize); + return Ok(buf); + } + return Err(err); + } + buf.truncate(len as usize); + Ok(buf) +} + +#[inline] +fn roundup(len: usize) -> usize { + if len == 0 { + SA_ALIGN + } else { + (len + (SA_ALIGN - 1)) & !(SA_ALIGN - 1) + } +} + +/// Parse an IP address from a `sockaddr` +fn ip_from_sockaddr(sa: &sockaddr) -> Option { + unsafe { + match sa.sa_family as c_int { + AF_INET => { + let sin = &*(sa as *const _ as *const libc::sockaddr_in); + let n = u32::from_be(sin.sin_addr.s_addr as u32); + Some(IpAddr::V4(Ipv4Addr::from(n))) + } + AF_INET6 => { + // Require the full `sockaddr_in6` to be present. + let sin6 = &*(sa as *const _ as *const libc::sockaddr_in6); + let want = core::mem::size_of::(); + if (sa.sa_len as usize) < want { + // prevent reading a truncated variable-length sockaddr + return None; + } + // `s6_addr` is raw big-endian bytes; `Ipv6Addr::from([u8;16])` expects octets. + let addr_bytes = (*sin6).sin6_addr.s6_addr; + Some(IpAddr::V6(Ipv6Addr::from(addr_bytes))) + } + _ => None, + } + } +} + +fn code_to_error(err: i32) -> io::Error { + let kind = match err { + 17 => io::ErrorKind::AlreadyExists, // EEXIST + 3 => io::ErrorKind::NotFound, // ESRCH + 3436 => io::ErrorKind::OutOfMemory, // ENOBUFS + _ => io::ErrorKind::Other, + }; + + io::Error::new(kind, format!("rtm_errno {}", err)) +} + +/// Extract `(IP, MAC)` pair from a routing message's address block. +fn message_to_arppair(msg: &[u8]) -> Option<(IpAddr, MacAddr)> { + let mut off = 0usize; + let mut ip: Option = None; + let mut mac: Option = None; + // Walk `sockaddr` records while there is room for a header. + while off + core::mem::size_of::() <= msg.len() { + // Read the sockaddr header + let sa = unsafe { &*(msg[off..].as_ptr() as *const sockaddr) }; + let sa_len = sa.sa_len as usize; + + // `sa_len == 0` can appear as "no address" (alignment-only slot). + // Advance by the platform's alignment unit (4 on BSD/Darwin). + if sa_len == 0 { + off += roundup(0); + continue; + } + // If the element claims to extend past the buffer, skip it conservatively. + if off + sa_len > msg.len() { + off += roundup(sa_len); + continue; + } + + match sa.sa_family as c_int { + AF_INET => { + // Target IPv4 of ARP. `sockaddr_in` and `sockaddr_inarp` share the initial layout, + // so `sin_addr` sits at the same position. + if let Some(IpAddr::V4(v4)) = ip_from_sockaddr(sa) { + ip = Some(v4); + if let (Some(v4), Some(m)) = (ip, mac) { + return Some((IpAddr::V4(v4), m)); + } + } + } + AF_LINK => { + // Extract LLADDR from `sockaddr_dl`. + let sdl = unsafe { &*(sa as *const _ as *const libc::sockaddr_dl) }; + let nlen = sdl.sdl_nlen as usize; + let alen = sdl.sdl_alen as usize; + let total = sdl.sdl_len as usize; + + // Validate against the *actual* struct length (`sdl_len`), and also + // make sure the caller-provided `sa_len` is at least that long. + if total >= core::mem::size_of::() + && alen >= 6 + && sa_len >= total + { + let base = sa as *const _ as *const u8; + let data_base = &sdl.sdl_data as *const _ as *const u8; + let data_off = unsafe { data_base.offset_from(base) } as usize; + + // LLADDR is at `sdl_data + sdl_nlen`. + if data_off + nlen + alen <= total { + let mac_ptr = unsafe { data_base.add(nlen) }; + let m = MacAddr::from_octets(unsafe { + [ + *mac_ptr.add(0), + *mac_ptr.add(1), + *mac_ptr.add(2), + *mac_ptr.add(3), + *mac_ptr.add(4), + *mac_ptr.add(5), + ] + }); + mac = Some(m); + if let (Some(v4), Some(m)) = (ip, mac) { + return Some((IpAddr::V4(v4), m)); + } + } + } + } + _ => {} + } + + // Advance to the next record; BSD/Darwin sockaddrs are 4-byte aligned. + off += roundup(sa_len); + } + + None +} + +/// Build an ARP/Neighbor table from the BSD/Darwin routing socket via `sysctl`. +pub fn get_neighbor_table() -> io::Result> { + let mut arp_map: HashMap = HashMap::new(); + // sysctl net.route dump for ARP/neighbor entries (IPv4 only here). + let mut mib = [ + CTL_NET, // net + PF_ROUTE, // route + 0, // 0 + AF_INET, // IPv4 + NET_RT_FLAGS, // flags + RTF_LLINFO, // ARP/neighbor entries + ]; + // Includes ENOMEM retry internally; length is truncated to actual bytes read. + let buf = sysctl_vec(&mut mib)?; + + let mut off = 0usize; + // Each record starts with `rt_msghdr` followed by a variable-length sockaddr block. + while off + mem::size_of::() <= buf.len() { + // Header view (no copy). + let hdr = unsafe { &*(buf[off..].as_ptr() as *const rt_msghdr) }; + let msglen = hdr.rtm_msglen as usize; + if msglen == 0 || off + msglen > buf.len() { + break; + } + + // Version mismatch: skip the record but keep reading. + if hdr.rtm_version != RTM_VERSION { + off += msglen; + continue; + } + if hdr.rtm_errno != 0 { + return Err(code_to_error(hdr.rtm_errno)); + } + + // Parse the sockaddr block right after the header. + let addr_block = &buf[off + mem::size_of::()..off + msglen]; + if let Some((ip, mac)) = message_to_arppair(addr_block) { + arp_map.insert(ip, mac); + } + + off += msglen; + } + + Ok(arp_map) +} diff --git a/src/net/neigh/os/linux.rs b/src/net/neigh/os/linux.rs new file mode 100644 index 0000000..03ecf14 --- /dev/null +++ b/src/net/neigh/os/linux.rs @@ -0,0 +1,256 @@ +use netdev::MacAddr; +use netlink_packet_core::{NLM_F_DUMP, NLM_F_REQUEST, NetlinkMessage, NetlinkPayload}; +use netlink_packet_route::{ + neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourMessage}, + RouteNetlinkMessage, +}; +use netlink_sys::{protocols::NETLINK_ROUTE, Socket, SocketAddr}; +use std::{ + collections::HashMap, + io::{self, ErrorKind}, + net::{IpAddr, Ipv4Addr}, + thread, + time::{Duration, Instant}, +}; + +const SEQ_BASE: u32 = 0x6E_70_6C_73; // npls (netpulsar) +const RECV_BUFSZ: usize = 1 << 20; // 1MB +const RECV_TIMEOUT: Duration = Duration::from_secs(2); +const NLMSG_ALIGNTO: usize = 4; +const MIN_NLMSG_HEADER_LEN: usize = 16; + +#[inline] +fn nlmsg_align(n: usize) -> usize { + (n + NLMSG_ALIGNTO - 1) & !(NLMSG_ALIGNTO - 1) +} + +fn open_route_socket() -> io::Result { + let mut sock = Socket::new(NETLINK_ROUTE) + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("netlink open: {e}")))?; + sock.bind_auto() + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("bind_auto: {e}")))?; + sock.set_non_blocking(true).ok(); + Ok(sock) +} + +fn send_dump(sock: &mut Socket, msg: RouteNetlinkMessage, seq: u32) -> io::Result<()> { + let mut nl = NetlinkMessage::from(msg); + nl.header.flags = NLM_F_REQUEST | NLM_F_DUMP; + nl.header.sequence_number = seq; + nl.header.port_number = 0; + nl.finalize(); + + let blen = nl.buffer_len(); + if blen < MIN_NLMSG_HEADER_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("netlink message too short: buffer_len={}", blen), + )); + } + + let mut buf = vec![0; blen]; + nl.serialize(&mut buf); + + let kernel = SocketAddr::new(0, 0); + sock.send_to(&buf, &kernel, 0) + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("netlink send: {e}")))?; + Ok(()) +} + +fn recv_multi( + sock: &mut Socket, + expect_seq: u32, +) -> io::Result>> { + let mut out = Vec::new(); + let mut buf = vec![0u8; RECV_BUFSZ]; + let kernel = SocketAddr::new(0, 0); + let deadline = Instant::now() + RECV_TIMEOUT; + + loop { + match sock.recv_from(&mut &mut buf[..], 0) { + Ok((size, from)) => { + let _ = from == kernel; + let mut offset = 0usize; + + while offset < size { + if size - offset < MIN_NLMSG_HEADER_LEN { + break; + } + let bytes = &buf[offset..size]; + + let msg = + NetlinkMessage::::deserialize(bytes).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("deserialize: {e:?}"), + ) + })?; + + let consumed = msg.header.length as usize; + if consumed < MIN_NLMSG_HEADER_LEN || offset + consumed > size { + break; + } + + if msg.header.sequence_number != expect_seq { + offset += nlmsg_align(consumed); + continue; + } + + match &msg.payload { + NetlinkPayload::Done(_) => return Ok(out), + NetlinkPayload::Error(e) => { + if let Some(code) = e.code { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("netlink error: code={}", code), + )); + } + } + NetlinkPayload::Noop | NetlinkPayload::Overrun(_) => { /* skip */ } + _ => out.push(msg), + } + + offset += nlmsg_align(consumed); + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Ok(out); + } + thread::sleep(Duration::from_millis(5)); + } + Err(e) => return Err(e), + } + } +} + +fn dump_neigh() -> io::Result> { + let mut sock = open_route_socket()?; + let seq = SEQ_BASE ^ 0x04; + send_dump( + &mut sock, + RouteNetlinkMessage::GetNeighbour(NeighbourMessage::default()), + seq, + )?; + let msgs = recv_multi(&mut sock, seq)?; + let mut out = Vec::new(); + for m in msgs { + if let NetlinkPayload::InnerMessage(RouteNetlinkMessage::NewNeighbour(n)) = m.payload { + out.push(n); + } + } + Ok(out) +} + +fn neigh_addr_to_ip(a: &NeighbourAddress) -> Option { + match a { + NeighbourAddress::Inet(v4) => Some(IpAddr::V4(*v4)), + NeighbourAddress::Inet6(v6) => Some(IpAddr::V6(*v6)), + #[allow(unreachable_patterns)] + _ => None, + } +} + +fn parse_mac_str(s: &str) -> Option<[u8; 6]> { + // 00:11:22:33:44:55 / 00-11-22-33-44-55 + let cleaned = s.replace('-', ":"); + let parts: Vec<&str> = cleaned.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut b = [0u8; 6]; + for (i, p) in parts.iter().enumerate() { + if p.len() > 2 { + return None; + } + b[i] = u8::from_str_radix(p, 16).ok()?; + } + Some(b) +} + +/// fallback: Read /proc/net/arp and build map +fn read_proc_net_arp() -> io::Result> { + use std::fs::File; + use std::io::{BufRead, BufReader}; + + let f = File::open("/proc/net/arp")?; + let r = BufReader::new(f); + let mut map = HashMap::new(); + + // Header + lines: + // IP address HW type Flags HW address Mask Device + for (i, line) in r.lines().enumerate() { + let line = line?; + if i == 0 { + continue; // header + } + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 6 { + continue; + } + let ip_s = cols[0]; + let flags_s = cols[2]; + let mac_s = cols[3]; + + // Complete entries only (Flags = 0x2) + if !flags_s.eq_ignore_ascii_case("0x2") { + continue; + } + + if let Ok(v4) = ip_s.parse::() { + if let Some(raw) = parse_mac_str(mac_s) { + map.insert(IpAddr::V4(v4), MacAddr::from_octets(raw)); + } + } + } + Ok(map) +} + +/// Dump neighbour(ARP/NDP) table via netlink. +/// If fails, fallback to read /proc/net/arp +pub fn get_neighbor_table() -> io::Result> { + // netlink + if let Ok(neighs) = dump_neigh() { + if let Ok(m) = neighs_to_map(neighs) { + if !m.is_empty() { + return Ok(m); + } + } + } + // fallback: /proc/net/arp + if let Ok(m) = read_proc_net_arp() { + if !m.is_empty() { + return Ok(m); + } + } + Ok(HashMap::new()) +} + +fn neighs_to_map(neighs: Vec) -> io::Result> { + let mut map = HashMap::new(); + + for n in neighs { + let mut ip: Option = None; + let mut mac: Option<[u8; 6]> = None; + + for nla in &n.attributes { + match nla { + NeighbourAttribute::Destination(a) => { + ip = neigh_addr_to_ip(a); + } + NeighbourAttribute::LinkLocalAddress(bytes) => { + if bytes.len() == 6 { + mac = Some([bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]]); + } + } + _ => {} + } + } + + if let (Some(ip), Some(mac6)) = (ip, mac) { + map.insert(ip, MacAddr::from_octets(mac6)); + } + } + + Ok(map) +} diff --git a/src/net/neigh/os/mod.rs b/src/net/neigh/os/mod.rs new file mode 100644 index 0000000..2bc9fb1 --- /dev/null +++ b/src/net/neigh/os/mod.rs @@ -0,0 +1,17 @@ +#[cfg(target_vendor = "apple")] +mod darwin; + +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_vendor = "apple")] +pub use self::darwin::get_neighbor_table; + +#[cfg(target_os = "windows")] +pub use self::windows::get_neighbor_table; + +#[cfg(target_os = "linux")] +pub use self::linux::get_neighbor_table; diff --git a/src/net/neigh/os/windows.rs b/src/net/neigh/os/windows.rs new file mode 100644 index 0000000..4c1890d --- /dev/null +++ b/src/net/neigh/os/windows.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, io, net::{IpAddr, Ipv4Addr, Ipv6Addr}, ptr}; +use netdev::MacAddr; + +use windows_sys::Win32::{ + Foundation::NO_ERROR, + Networking::WinSock::{ + ADDRESS_FAMILY, AF_INET, AF_INET6, SOCKADDR_INET, + IN_ADDR, IN6_ADDR, + NlnsDelay, NlnsPermanent, NlnsProbe, NlnsReachable, NlnsStale + }, + NetworkManagement::IpHelper::{ + FreeMibTable, + GetIpNetTable2, + MIB_IPNET_ROW2, MIB_IPNET_TABLE2, + }, +}; + +pub fn get_neighbor_table() -> io::Result> { + let mut map = HashMap::new(); + // IPv4(ARP) + if let Ok(m) = dump_ipnet(AF_INET) { + map.extend(m); + } + // IPv6(NDP) + if let Ok(m) = dump_ipnet(AF_INET6) { + map.extend(m); + } + Ok(map) +} + +fn dump_ipnet(af: ADDRESS_FAMILY) -> io::Result> { + let mut out = HashMap::new(); + + unsafe { + let mut table_ptr: *mut MIB_IPNET_TABLE2 = ptr::null_mut(); + let ret = GetIpNetTable2(af, &mut table_ptr); + if ret != NO_ERROR { + return Err(io::Error::new(io::ErrorKind::Other, format!("GetIpNetTable2 failed: {ret}"))); + } + if table_ptr.is_null() { + return Ok(out); + } + + // free on scope exit + let table: &MIB_IPNET_TABLE2 = &*table_ptr; + let rows: &[MIB_IPNET_ROW2] = std::slice::from_raw_parts(table.Table.as_ptr(), table.NumEntries as usize); + + for row in rows { + if row.PhysicalAddressLength != 6 { + continue; + } + + if !is_interesting_state(row.State) { + continue; + } + + if let Some(ip) = sockaddr_inet_to_ip(&row.Address) { + let mac = MacAddr::from_octets([ + row.PhysicalAddress[0], + row.PhysicalAddress[1], + row.PhysicalAddress[2], + row.PhysicalAddress[3], + row.PhysicalAddress[4], + row.PhysicalAddress[5], + ]); + out.insert(ip, mac); + } + } + + FreeMibTable(table_ptr as _); + } + + Ok(out) +} + +#[allow(non_upper_case_globals)] +#[inline] +fn is_interesting_state(state: i32) -> bool { + matches!( + state, + NlnsPermanent + | NlnsReachable + | NlnsStale + | NlnsDelay + | NlnsProbe + ) +} + +#[inline] +fn sockaddr_inet_to_ip(sa: &SOCKADDR_INET) -> Option { + unsafe { + match sa.si_family { + AF_INET => { + let IN_ADDR { S_un: s } = sa.Ipv4.sin_addr; + let bytes = s.S_un_b; + Some(IpAddr::V4(Ipv4Addr::new(bytes.s_b1, bytes.s_b2, bytes.s_b3, bytes.s_b4))) + } + AF_INET6 => { + let IN6_ADDR { u: u6 } = sa.Ipv6.sin6_addr; + let segs = u6.Byte; + Some(IpAddr::V6(Ipv6Addr::from(segs))) + } + _ => None, + } + } +} diff --git a/src/net/route.rs b/src/net/route.rs new file mode 100644 index 0000000..d620316 --- /dev/null +++ b/src/net/route.rs @@ -0,0 +1,6 @@ +use netroute::RouteEntry; +use std::io; + +pub fn list_routes() -> io::Result> { + netroute::list_routes() +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 7aeaedb..c264776 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,3 +1,22 @@ pub mod json; pub mod tree; pub mod yaml; +pub mod table; + +pub fn fmt_bps(bps: u64) -> String { + const K: f64 = 1_000.0; + let b = bps as f64; + if b >= K * K * K { + format!("{:.2} Gb/s", b / (K * K * K)) + } else if b >= K * K { + format!("{:.2} Mb/s", b / (K * K)) + } else if b >= K { + format!("{:.2} Kb/s", b / K) + } else { + format!("{} b/s", bps) + } +} + +pub fn fmt_flags(flags: u32) -> String { + format!("0x{:08X}", flags) +} diff --git a/src/renderer/table.rs b/src/renderer/table.rs new file mode 100644 index 0000000..1130ba9 --- /dev/null +++ b/src/renderer/table.rs @@ -0,0 +1,27 @@ +use comfy_table::{ + presets::UTF8_FULL, + modifiers::UTF8_ROUND_CORNERS, + Table, ContentArrangement, Color, Cell, +}; + +/// Create a preconfigured comfy-table instance with consistent styling. +pub fn make_table(headers: &[&str]) -> Table { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::Dynamic); + + // cyan header + let header_cells = headers + .iter() + .map(|h| Cell::new(*h).fg(Color::Cyan)) + .collect::>(); + table.set_header(header_cells); + table +} + +/* /// Helper for colored header text +pub fn header_cell(text: &str) -> Cell { + Cell::new(text).fg(Color::Cyan) +} */ diff --git a/src/renderer/tree.rs b/src/renderer/tree.rs index 8db7f4d..da408ad 100644 --- a/src/renderer/tree.rs +++ b/src/renderer/tree.rs @@ -1,648 +1,4 @@ -use netdev::{Interface, MacAddr}; -use termtree::Tree; -use url::Url; - -use crate::{net::sys::SysInfo, db::oui::is_oui_db_initialized, model::ipinfo::PublicOut}; - /// Convert a string into a tree label. pub fn tree_label>(s: S) -> String { s.into() } - -pub fn fmt_bps(bps: u64) -> String { - const K: f64 = 1_000.0; - let b = bps as f64; - if b >= K * K * K { - format!("{:.2} Gb/s", b / (K * K * K)) - } else if b >= K * K { - format!("{:.2} Mb/s", b / (K * K)) - } else if b >= K { - format!("{:.2} Kb/s", b / K) - } else { - format!("{} b/s", bps) - } -} - -pub fn fmt_flags(flags: u32) -> String { - format!("0x{:08X}", flags) -} - -/// Mask username/password in proxy URL for privacy -fn mask_proxy_url(raw: &str) -> String { - if let Ok(mut url) = Url::parse(raw) { - if url.password().is_some() || !url.username().is_empty() { - let user = url.username().to_string(); - let _ = url.set_username(&user).ok(); - let _ = url.set_password(Some("*****")).ok(); - } - return url.to_string(); - } - raw.to_string() -} - -/// Print the network interfaces in a tree structure. -pub fn print_interface_tree(ifaces: &[Interface]) { - let default: bool = if ifaces.len() == 1 { - ifaces[0].default - } else { - false - }; - let host = crate::net::sys::hostname(); - let mut root = if default { - Tree::new(tree_label(format!("Default Interface on {}", host))) - } else { - Tree::new(tree_label(format!("Interfaces on {}", host))) - }; - for iface in ifaces { - let mut node = Tree::new(format!( - "{}{}", - iface.name, - if iface.default { " (default)" } else { "" } - )); - - node.push(Tree::new(format!("Index: {}", iface.index))); - - if let Some(fn_name) = &iface.friendly_name { - node.push(Tree::new(format!("Friendly Name: {}", fn_name))); - } - if let Some(desc) = &iface.description { - node.push(Tree::new(format!("Description: {}", desc))); - } - - node.push(Tree::new(format!("Type: {:?}", iface.if_type))); - node.push(Tree::new(format!("State: {:?}", iface.oper_state))); - if let Some(mac) = &iface.mac_addr { - node.push(Tree::new(format!("MAC: {}", mac))); - - if is_oui_db_initialized() && *mac != MacAddr::zero() { - let oui_db = crate::db::oui::oui_db(); - if let Some(vendor) = oui_db.lookup_mac(mac) { - let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); - node.push(Tree::new(format!("Vendor: {}", vendor_name))); - } - } - } - - if let Some(mtu) = iface.mtu { - node.push(Tree::new(format!("MTU: {}", mtu))); - } - - if !iface.ipv4.is_empty() { - let mut ipv4_tree = Tree::new(tree_label("IPv4")); - for net in &iface.ipv4 { - ipv4_tree.push(Tree::new(net.to_string())); - } - node.push(ipv4_tree); - } - - if !iface.ipv6.is_empty() { - let mut ipv6_tree = Tree::new(tree_label("IPv6")); - for (i, net) in iface.ipv6.iter().enumerate() { - let mut label = net.to_string(); - if let Some(scope) = iface.ipv6_scope_ids.get(i) { - label.push_str(&format!(" (scope_id={})", scope)); - } - ipv6_tree.push(Tree::new(label)); - } - node.push(ipv6_tree); - } - - if !iface.dns_servers.is_empty() { - let mut dns_tree = Tree::new(tree_label("DNS")); - for dns in &iface.dns_servers { - dns_tree.push(Tree::new(dns.to_string())); - } - node.push(dns_tree); - } - - if let Some(gw) = &iface.gateway { - let mut gw_node = Tree::new(tree_label("Gateway")); - // GW MAC - gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); - // GW IPv4/IPv6 - if !gw.ipv4.is_empty() { - let mut gw_tree = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { - gw_tree.push(Tree::new(ip.to_string())); - } - gw_node.push(gw_tree); - } - if !gw.ipv6.is_empty() { - let mut gw_tree = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { - gw_tree.push(Tree::new(ip.to_string())); - } - gw_node.push(gw_tree); - } - node.push(gw_node); - } - - if iface.default { - let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); - if vpn_heuristic.is_vpn_like { - let mut heuristic_node = Tree::new(tree_label("Heuristic")); - heuristic_node.push(Tree::new(format!( - "VPN-like: {}", - vpn_heuristic.is_vpn_like - ))); - node.push(heuristic_node); - } - } - - root.push(node); - } - println!("{}", root); -} - -/// Print detailed information of a single interface in a tree structure. -pub fn print_interface_detail_tree(iface: &Interface) { - let host = crate::net::sys::hostname(); - let title = format!( - "{}{} on {}", - iface.name, - if iface.default { " (default)" } else { "" }, - host - ); - let mut root = Tree::new(tree_label(title)); - - // flat fields (no General section) - root.push(Tree::new(format!("Index: {}", iface.index))); - - if let Some(fn_name) = &iface.friendly_name { - root.push(Tree::new(format!("Friendly Name: {}", fn_name))); - } - if let Some(desc) = &iface.description { - root.push(Tree::new(format!("Description: {}", desc))); - } - - root.push(Tree::new(format!("Type: {:?}", iface.if_type))); - root.push(Tree::new(format!("State: {:?}", iface.oper_state))); - - if let Some(mac) = &iface.mac_addr { - root.push(Tree::new(format!("MAC: {}", mac))); - - if is_oui_db_initialized() && *mac != MacAddr::zero() { - let oui_db = crate::db::oui::oui_db(); - if let Some(vendor) = oui_db.lookup_mac(mac) { - let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); - root.push(Tree::new(format!("Vendor: {}", vendor_name))); - } - } - } - - if let Some(mtu) = iface.mtu { - root.push(Tree::new(format!("MTU: {}", mtu))); - } - - // link speeds (humanized bps) - if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { - let mut speed = Tree::new(tree_label("Link Speed")); - if let Some(tx) = iface.transmit_speed { - speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); - } - if let Some(rx) = iface.receive_speed { - speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); - } - root.push(speed); - } - - // flags - root.push(Tree::new(format!("Flags: {}", fmt_flags(iface.flags)))); - - // ---- Addresses ---- - if !iface.ipv4.is_empty() { - let mut ipv4_tree = Tree::new(tree_label("IPv4")); - for net in &iface.ipv4 { - ipv4_tree.push(Tree::new(net.to_string())); - } - root.push(ipv4_tree); - } - - if !iface.ipv6.is_empty() { - let mut ipv6_tree = Tree::new(tree_label("IPv6")); - for (i, net) in iface.ipv6.iter().enumerate() { - let mut label = net.to_string(); - if let Some(scope) = iface.ipv6_scope_ids.get(i) { - label.push_str(&format!(" (scope_id={})", scope)); - } - ipv6_tree.push(Tree::new(label)); - } - root.push(ipv6_tree); - } - - // ---- DNS ---- - if !iface.dns_servers.is_empty() { - let mut dns_tree = Tree::new(tree_label("DNS")); - for dns in &iface.dns_servers { - dns_tree.push(Tree::new(dns.to_string())); - } - root.push(dns_tree); - } - - // ---- Gateway ---- - if let Some(gw) = &iface.gateway { - let mut gw_node = Tree::new(tree_label("Gateway")); - gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); - if !gw.ipv4.is_empty() { - let mut gw4 = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { - gw4.push(Tree::new(ip.to_string())); - } - gw_node.push(gw4); - } - if !gw.ipv6.is_empty() { - let mut gw6 = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { - gw6.push(Tree::new(ip.to_string())); - } - gw_node.push(gw6); - } - root.push(gw_node); - } - - // ---- Statistics (snapshot) ---- - if let Some(st) = &iface.stats { - let mut stats_node = Tree::new(tree_label("Statistics (snapshot)")); - stats_node.push(Tree::new(format!("RX bytes: {}", st.rx_bytes))); - stats_node.push(Tree::new(format!("TX bytes: {}", st.tx_bytes))); - root.push(stats_node); - } - - let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); - if vpn_heuristic.is_vpn_like { - let mut heuristic_node = Tree::new(tree_label("Heuristic")); - heuristic_node.push(Tree::new(format!( - "VPN-like: {}", - vpn_heuristic.is_vpn_like - ))); - root.push(heuristic_node); - } - - println!("{}", root); -} - -pub fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option) { - let mut root = Tree::new(tree_label(format!( - "System Information on {}", - sys.hostname - ))); - - // ---- System ---- - let mut sys_node = Tree::new(tree_label("System")); - sys_node.push(Tree::new(tree_label(format!("OS Type: {}", sys.os_type)))); - sys_node.push(Tree::new(tree_label(format!( - "Version: {}", - sys.os_version - )))); - if let Some(kv) = &sys.kernel_version { - sys_node.push(Tree::new(tree_label(format!("Kernel: {}", kv)))); - } - sys_node.push(Tree::new(tree_label(format!("Edition: {}", sys.edition)))); - sys_node.push(Tree::new(tree_label(format!("Codename: {}", sys.codename)))); - sys_node.push(Tree::new(tree_label(format!("Bitness: {}", sys.bitness)))); - sys_node.push(Tree::new(tree_label(format!( - "Architecture: {}", - sys.architecture - )))); - - // ---- Proxy (env) ---- - let px = crate::net::sys::collect_proxy_env(); - let mut px_node = Tree::new(tree_label("Proxy (env)")); - px_node.push(Tree::new(format!( - "HTTP_PROXY: {}", - px.http - .as_deref() - .map(mask_proxy_url) - .unwrap_or_else(|| "(none)".into()) - ))); - px_node.push(Tree::new(format!( - "HTTPS_PROXY: {}", - px.https - .as_deref() - .map(mask_proxy_url) - .unwrap_or_else(|| "(none)".into()) - ))); - px_node.push(Tree::new(format!( - "ALL_PROXY: {}", - px.all - .as_deref() - .map(mask_proxy_url) - .unwrap_or_else(|| "(none)".into()) - ))); - if let Some(np) = px.no_proxy.as_deref() { - let mut np_node = Tree::new(tree_label("NO_PROXY")); - // Split by comma and trim spaces, ignore empty parts - for (i, part) in np - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .enumerate() - { - // Limit to first 20 entries - if i < 20 { - np_node.push(Tree::new(part.to_string())); - } else { - np_node.push(Tree::new(format!( - "(+{} more)", - np.split(',').count().saturating_sub(20) - ))); - break; - } - } - px_node.push(np_node); - } else { - px_node.push(Tree::new(tree_label("NO_PROXY: (none)"))); - } - sys_node.push(px_node); - - root.push(sys_node); - - // ---- Default Interface (optional) ---- - if let Some(iface) = default_iface { - let mut if_node = Tree::new(tree_label(format!("Default Interface: {}", iface.name))); - - if let Some(fn_name) = &iface.friendly_name { - if_node.push(Tree::new(tree_label(format!("Friendly Name: {}", fn_name)))); - } - if let Some(desc) = &iface.description { - if_node.push(Tree::new(tree_label(format!("Description: {}", desc)))); - } - - if_node.push(Tree::new(tree_label(format!("Index: {}", iface.index)))); - if_node.push(Tree::new(tree_label(format!("Type: {:?}", iface.if_type)))); - if_node.push(Tree::new(tree_label(format!( - "State: {:?}", - iface.oper_state - )))); - if let Some(mac) = &iface.mac_addr { - if_node.push(Tree::new(tree_label(format!("MAC: {}", mac)))); - - if is_oui_db_initialized() && *mac != MacAddr::zero() { - let oui_db = crate::db::oui::oui_db(); - if let Some(vendor) = oui_db.lookup_mac(mac) { - let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); - if_node.push(Tree::new(format!("Vendor: {}", vendor_name))); - } - } - } - - if let Some(mtu) = iface.mtu { - if_node.push(Tree::new(tree_label(format!("MTU: {}", mtu)))); - } - - // Speeds - if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { - let mut speed = Tree::new(tree_label("Link Speed")); - if let Some(tx) = iface.transmit_speed { - speed.push(Tree::new(tree_label(format!("TX: {}", fmt_bps(tx))))); - } - if let Some(rx) = iface.receive_speed { - speed.push(Tree::new(tree_label(format!("RX: {}", fmt_bps(rx))))); - } - if_node.push(speed); - } - - // IPv4 - if !iface.ipv4.is_empty() { - let mut ipv4_node = Tree::new(tree_label("IPv4")); - for n in &iface.ipv4 { - ipv4_node.push(Tree::new(tree_label(n.to_string()))); - } - if_node.push(ipv4_node); - } - // IPv6 with scope ID - if !iface.ipv6.is_empty() { - let mut ipv6_node = Tree::new(tree_label("IPv6")); - for (i, n) in iface.ipv6.iter().enumerate() { - let mut label = n.to_string(); - if let Some(sc) = iface.ipv6_scope_ids.get(i) { - label.push_str(&format!(" (scope_id={})", sc)); - } - ipv6_node.push(Tree::new(tree_label(label))); - } - if_node.push(ipv6_node); - } - - // DNS - if !iface.dns_servers.is_empty() { - let mut dns = Tree::new(tree_label("DNS")); - for s in &iface.dns_servers { - dns.push(Tree::new(tree_label(s.to_string()))); - } - if_node.push(dns); - } - - // Gateway (IP + MAC) - if let Some(gw) = &iface.gateway { - let mut gw_node = Tree::new(tree_label("Gateway")); - gw_node.push(Tree::new(tree_label(format!("MAC: {}", gw.mac_addr)))); - if !gw.ipv4.is_empty() { - let mut gw4 = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { - gw4.push(Tree::new(tree_label(ip.to_string()))); - } - gw_node.push(gw4); - } - if !gw.ipv6.is_empty() { - let mut gw6 = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { - gw6.push(Tree::new(tree_label(ip.to_string()))); - } - gw_node.push(gw6); - } - if_node.push(gw_node); - } - - let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); - if vpn_heuristic.is_vpn_like { - let mut heuristic_node = Tree::new(tree_label("Heuristic")); - heuristic_node.push(Tree::new(format!( - "VPN-like: {}", - vpn_heuristic.is_vpn_like - ))); - if_node.push(heuristic_node); - } - - root.push(if_node); - } else { - root.push(Tree::new(tree_label("Default Interface: (not found)"))); - } - - println!("{}", root); -} - -pub fn print_public_ip_tree(out: &PublicOut, default_iface: Option) { - let host = crate::net::sys::hostname(); - let mut root = Tree::new(tree_label(format!("Public IPs on {}", host))); - - let mut v4node = Tree::new(tree_label("IPv4")); - if let Some(i) = &out.ipv4 { - v4node.push(Tree::new(tree_label(format!("IP: {}", i.ip_addr)))); - //v4node.push(Tree::new(tree_label(format!("Decimal: {}", i.ip_addr_dec)))); - //v4node.push(Tree::new(tree_label(format!("Host: {}", i.host_name)))); - v4node.push(Tree::new(tree_label(format!("Network: {}", i.network)))); - if out.common.is_none() { - if let Some(asn) = &i.asn { - v4node.push(Tree::new(tree_label(format!("ASN: {}", asn)))); - } - if let Some(n) = &i.as_name { - v4node.push(Tree::new(tree_label(format!("AS Name: {}", n)))); - } - if let Some(cc) = &i.country_code { - let cn = i.country_name.as_deref().unwrap_or(""); - v4node.push(Tree::new(tree_label(format!("Country: {} ({})", cn, cc)))); - } - } - } else { - v4node.push(Tree::new(tree_label("(none)"))); - } - root.push(v4node); - - let mut v6node = Tree::new(tree_label("IPv6")); - if let Some(i) = &out.ipv6 { - v6node.push(Tree::new(tree_label(format!("IP: {}", i.ip_addr)))); - //v6node.push(Tree::new(tree_label(format!("Decimal: {}", i.ip_addr_dec)))); - //v6node.push(Tree::new(tree_label(format!("Host: {}", i.host_name)))); - v6node.push(Tree::new(tree_label(format!("Network: {}", i.network)))); - if out.common.is_none() { - if let Some(asn) = &i.asn { - v6node.push(Tree::new(tree_label(format!("ASN: {}", asn)))); - } - if let Some(n) = &i.as_name { - v6node.push(Tree::new(tree_label(format!("AS Name: {}", n)))); - } - if let Some(cc) = &i.country_code { - let cn = i.country_name.as_deref().unwrap_or(""); - v6node.push(Tree::new(tree_label(format!("Country: {} ({})", cn, cc)))); - } - } - } else { - v6node.push(Tree::new(tree_label("(none)"))); - } - root.push(v6node); - - if let Some(c) = &out.common { - let mut country_info = Tree::new(tree_label("Country")); - country_info.push(Tree::new(tree_label(format!("Code: {}", c.country_code)))); - country_info.push(Tree::new(tree_label(format!("Name: {}", c.country_name)))); - root.push(country_info); - - let mut as_info = Tree::new(tree_label("AS Info")); - as_info.push(Tree::new(tree_label(format!("ASN: {}", c.asn)))); - as_info.push(Tree::new(tree_label(format!("AS Name: {}", c.as_name)))); - root.push(as_info); - } - - // ---- Default Interface (optional) ---- - if let Some(iface) = default_iface { - let mut if_node = Tree::new(tree_label(format!("Default Interface: {}", iface.name))); - - if let Some(fn_name) = &iface.friendly_name { - if_node.push(Tree::new(tree_label(format!("Friendly Name: {}", fn_name)))); - } - if let Some(desc) = &iface.description { - if_node.push(Tree::new(tree_label(format!("Description: {}", desc)))); - } - - if_node.push(Tree::new(tree_label(format!("Index: {}", iface.index)))); - if_node.push(Tree::new(tree_label(format!("Type: {:?}", iface.if_type)))); - if_node.push(Tree::new(tree_label(format!( - "State: {:?}", - iface.oper_state - )))); - if let Some(mac) = &iface.mac_addr { - if_node.push(Tree::new(tree_label(format!("MAC: {}", mac)))); - - if is_oui_db_initialized() && *mac != MacAddr::zero() { - let oui_db = crate::db::oui::oui_db(); - if let Some(vendor) = oui_db.lookup_mac(mac) { - let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); - if_node.push(Tree::new(format!("Vendor: {}", vendor_name))); - } - } - } - - if let Some(mtu) = iface.mtu { - if_node.push(Tree::new(tree_label(format!("MTU: {}", mtu)))); - } - - // Speeds - if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { - let mut speed = Tree::new(tree_label("Link Speed")); - if let Some(tx) = iface.transmit_speed { - speed.push(Tree::new(tree_label(format!("TX: {}", fmt_bps(tx))))); - } - if let Some(rx) = iface.receive_speed { - speed.push(Tree::new(tree_label(format!("RX: {}", fmt_bps(rx))))); - } - if_node.push(speed); - } - - // IPv4 - if !iface.ipv4.is_empty() { - let mut ipv4_node = Tree::new(tree_label("IPv4")); - for n in &iface.ipv4 { - ipv4_node.push(Tree::new(tree_label(n.to_string()))); - } - if_node.push(ipv4_node); - } - // IPv6 with scope ID - if !iface.ipv6.is_empty() { - let mut ipv6_node = Tree::new(tree_label("IPv6")); - for (i, n) in iface.ipv6.iter().enumerate() { - let mut label = n.to_string(); - if let Some(sc) = iface.ipv6_scope_ids.get(i) { - label.push_str(&format!(" (scope_id={})", sc)); - } - ipv6_node.push(Tree::new(tree_label(label))); - } - if_node.push(ipv6_node); - } - - // DNS - if !iface.dns_servers.is_empty() { - let mut dns = Tree::new(tree_label("DNS")); - for s in &iface.dns_servers { - dns.push(Tree::new(tree_label(s.to_string()))); - } - if_node.push(dns); - } - - // Gateway (IP + MAC) - if let Some(gw) = &iface.gateway { - let mut gw_node = Tree::new(tree_label("Gateway")); - gw_node.push(Tree::new(tree_label(format!("MAC: {}", gw.mac_addr)))); - if !gw.ipv4.is_empty() { - let mut gw4 = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { - gw4.push(Tree::new(tree_label(ip.to_string()))); - } - gw_node.push(gw4); - } - if !gw.ipv6.is_empty() { - let mut gw6 = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { - gw6.push(Tree::new(tree_label(ip.to_string()))); - } - gw_node.push(gw6); - } - if_node.push(gw_node); - } - - let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); - if vpn_heuristic.is_vpn_like { - let mut heuristic_node = Tree::new(tree_label("Heuristic")); - heuristic_node.push(Tree::new(format!( - "VPN-like: {}", - vpn_heuristic.is_vpn_like - ))); - if_node.push(heuristic_node); - } - - root.push(if_node); - } else { - root.push(Tree::new(tree_label("Default Interface: (not found)"))); - } - - println!("{}", root); -} From 19608264e979c7e3d0fc3d6d2763229c2ce3e9b1 Mon Sep 17 00:00:00 2001 From: shellrow Date: Fri, 14 Nov 2025 22:53:50 +0900 Subject: [PATCH 04/24] Rename function --- src/cmd/list.rs | 4 ++-- src/cmd/monitor.rs | 6 +++--- src/net/iface.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cmd/list.rs b/src/cmd/list.rs index b23e05b..4fe08f1 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -17,7 +17,7 @@ pub fn show_interfaces(cli: &Cli) { .into_iter() .collect() } else { - net::iface::collect_all_interfaces() + net::iface::get_all_interfaces() }; // Render output match cli.format { @@ -29,7 +29,7 @@ pub fn show_interfaces(cli: &Cli) { } pub fn list_interfaces(_cli: &Cli, args: &ListArgs) { - let mut interfaces: Vec = net::iface::collect_all_interfaces(); + let mut interfaces: Vec = net::iface::get_all_interfaces(); // Apply filters if let Some(name_like) = &args.name_like { diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index 42366d5..4f89b24 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -25,7 +25,7 @@ use termtree::Tree; use crate::cli::Cli; use crate::cli::MonitorArgs; -use crate::net::iface::collect_all_interfaces; +use crate::net::iface::get_all_interfaces; use crate::renderer::{fmt_bps, fmt_flags}; use crate::renderer::tree::tree_label; @@ -101,7 +101,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.clear()?; - let mut ifs = collect_all_interfaces(); + let mut ifs = get_all_interfaces(); // Collect (target IF only or all) if let Some(ref name) = target_iface { ifs.retain(|it| &it.name == name); @@ -151,7 +151,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { } KeyCode::Char('o') => sort = sort.cycle(), KeyCode::Char('r') => { - ifs = collect_all_interfaces(); + ifs = get_all_interfaces(); if let Some(ref name) = target_iface { ifs.retain(|it| &it.name == name); } diff --git a/src/net/iface.rs b/src/net/iface.rs index be17ef4..3804d1c 100644 --- a/src/net/iface.rs +++ b/src/net/iface.rs @@ -20,7 +20,7 @@ const VPN_NAME_PATTERNS: &[&str] = &[ "expressvpn", ]; -pub fn collect_all_interfaces() -> Vec { +pub fn get_all_interfaces() -> Vec { netdev::get_interfaces() } From aeb1ba6d992cffddacbdcb9e2797a8d6c1462ed9 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 00:38:20 +0900 Subject: [PATCH 05/24] Add socket sub-command --- Cargo.lock | 242 ++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + src/cli.rs | 51 +++++++++ src/cmd/mod.rs | 1 + src/cmd/socket.rs | 203 ++++++++++++++++++++++++++++++++++++ src/main.rs | 3 + src/net/addr.rs | 53 ++++++++++ src/net/mod.rs | 2 + src/net/socket.rs | 14 +++ src/renderer/json.rs | 10 ++ src/renderer/yaml.rs | 10 ++ 11 files changed, 577 insertions(+), 13 deletions(-) create mode 100644 src/cmd/socket.rs create mode 100644 src/net/addr.rs create mode 100644 src/net/socket.rs diff --git a/Cargo.lock b/Cargo.lock index deab05f..829758d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -85,6 +94,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -126,6 +141,30 @@ dependencies = [ "virtue", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.4", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.106", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.4" @@ -138,6 +177,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -160,6 +205,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -172,6 +226,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.48" @@ -261,7 +326,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags", + "bitflags 2.9.4", "crossterm_winapi", "libc", "mio 0.8.11", @@ -277,7 +342,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "crossterm_winapi", "document-features", "parking_lot", @@ -470,6 +535,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.15.5" @@ -751,7 +822,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -812,12 +883,33 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libproc" +version = "0.14.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a54ad7278b8bc5301d5ffd2a94251c004feb971feba96c971ea4063645990757" +dependencies = [ + "bindgen", + "errno", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -881,6 +973,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -937,7 +1035,7 @@ dependencies = [ "ipnet", "libc", "mac-addr", - "netlink-packet-core", + "netlink-packet-core 0.8.1", "netlink-packet-route", "netlink-sys", "once_cell", @@ -946,6 +1044,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + [[package]] name = "netlink-packet-core" version = "0.8.1" @@ -961,10 +1070,37 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" dependencies = [ - "bitflags", + "bitflags 2.9.4", "libc", "log", - "netlink-packet-core", + "netlink-packet-core 0.8.1", +] + +[[package]] +name = "netlink-packet-sock-diag" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a495cb1de50560a7cd12fdcf023db70eec00e340df81be31cedbbfd4aadd6b76" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core 0.7.0", + "netlink-packet-utils", + "smallvec", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", ] [[package]] @@ -985,7 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59eb911513f79dbbd83c7cb5ec059f1c4631c1b1f52f1703f52538d50a9aa200" dependencies = [ "libc", - "netlink-packet-core", + "netlink-packet-core 0.8.1", "netlink-packet-route", "netlink-sys", "serde", @@ -993,6 +1129,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "netsock" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e58f2fae01a0d3809c0918aeb86db4e99b16b6ee1fbbe7e9daff8992a969cf" +dependencies = [ + "bitflags 2.9.4", + "libproc", + "log", + "netlink-packet-core 0.7.0", + "netlink-packet-sock-diag", + "netlink-packet-utils", + "netlink-sys", + "num-derive", + "num-traits", + "serde", + "thiserror 2.0.17", + "windows-sys 0.59.0", +] + [[package]] name = "nifa" version = "0.3.1" @@ -1008,6 +1164,7 @@ dependencies = [ "ndb-oui", "netdev", "netroute", + "netsock", "os_info", "ratatui", "reqwest", @@ -1021,12 +1178,42 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.37.3" @@ -1279,7 +1466,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cassowary", "crossterm 0.27.0", "indoc", @@ -1298,9 +1485,38 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.9.4", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "reqwest" version = "0.12.23" @@ -1372,7 +1588,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", @@ -1459,7 +1675,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags", + "bitflags 2.9.4", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1700,7 +1916,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1877,7 +2093,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.9.4", "bytes", "futures-util", "http", diff --git a/Cargo.toml b/Cargo.toml index 3ca4303..a8c0049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde_yaml = { version = "0.9" } mac-addr = { version = "0.3.0" } netdev = { version = "0.39", features = ["serde"] } netroute = { version = "0.3", features = ["serde"] } +netsock = { version = "0.5", features = ["serde"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } clap = { version = "4.5", features = ["derive", "cargo"] } diff --git a/src/cli.rs b/src/cli.rs index 4f4ead6..f3dee73 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,6 +49,8 @@ pub enum Command { Route(RouteArgs), /// Show neighbor table (ARP/NDP) Neigh(NeighArgs), + /// Show open sockets + Socket(SocketArgs), } /// List command arguments @@ -169,3 +171,52 @@ pub struct NeighArgs { #[arg(long)] pub output: Option, } + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum SocketProto { + Tcp, + Udp, + All, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum SocketFamily { + Ipv4, + Ipv6, + All, +} + +#[derive(Args, Debug)] +pub struct SocketArgs { + /// Protocol filter + #[arg(long, value_enum, default_value = "all")] + pub proto: SocketProto, + + /// Address family filter + #[arg(long, value_enum, default_value = "all")] + pub family: SocketFamily, + + /// TCP state filter (established, listen, time_wait, all) + #[arg(long)] + pub state: Option, + + /// Filter by local or remote port + #[arg(long)] + pub port: Option, + + /// Filter by PID + #[arg(long)] + pub pid: Option, + + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + + /// Export in json/yaml + #[arg(long, default_value_t = false)] + pub export: bool, + + /// Output file for export + #[arg(long)] + pub output: Option, +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 48752e4..1045699 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -5,3 +5,4 @@ pub mod public; pub mod show; pub mod route; pub mod neigh; +pub mod socket; diff --git a/src/cmd/socket.rs b/src/cmd/socket.rs new file mode 100644 index 0000000..0d9720a --- /dev/null +++ b/src/cmd/socket.rs @@ -0,0 +1,203 @@ +use std::net::IpAddr; +use comfy_table::{Row, Cell}; +use termtree::Tree; +use anyhow::Result; +use netsock::socket::ProtocolSocketInfo; +use crate::cli::{Cli, OutputFormat, SocketArgs, SocketFamily, SocketProto}; +use crate::fs::export; +use crate::net::addr::AddressFamily; +use crate::net::socket::collect_sockets; +use crate::renderer::tree::tree_label; + +use netsock::{ + protocol::ProtocolFlags, + family::AddressFamilyFlags, + socket::SocketInfo, +}; + +pub fn show_sockets(cli: &Cli, args: &SocketArgs) -> Result<()> { + let pf = match args.proto { + SocketProto::Tcp => ProtocolFlags::TCP, + SocketProto::Udp => ProtocolFlags::UDP, + SocketProto::All => ProtocolFlags::TCP | ProtocolFlags::UDP, + }; + + let af = match args.family { + SocketFamily::Ipv4 => AddressFamilyFlags::IPV4, + SocketFamily::Ipv6 => AddressFamilyFlags::IPV6, + SocketFamily::All => AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, + }; + + let mut socks = collect_sockets(pf, af)?; + + if let Some(pid) = args.pid { + socks.retain(|s| s.is_owned_by_pid(pid)); + } + if let Some(port) = args.port { + socks.retain(|s| { + s.local_port() == port || s.remote_port() == Some(port) + }); + } + if let Some(ref state) = args.state { + let want = state.to_lowercase(); + socks.retain(|s| { + match &s.protocol_socket_info { + ProtocolSocketInfo::Tcp(tcp_sock) => { + let sock_state = tcp_sock.state.to_string().to_lowercase(); + want == "all" || sock_state == want + } + _ => false, + } + }); + } + + if args.export { + export(cli.format, args.output.as_deref(), &socks)?; + } + + match args.format { + OutputFormat::Tree => print_socket_tree(&socks), + OutputFormat::Table => print_socket_table(&socks), + OutputFormat::Json => crate::renderer::json::pretty_print_json(&socks)?, + OutputFormat::Yaml => crate::renderer::yaml::print_yaml(&socks)?, + } + + Ok(()) +} + +fn fmt_sock_addr(ip: IpAddr, port: u16) -> String { + match ip { + IpAddr::V4(v4) => format!("{}:{}", v4, port), + IpAddr::V6(v6) => format!("[{}]:{}", v6, port), + } +} + +fn fmt_processes(s: &SocketInfo) -> (String, String) { + if s.processes.is_empty() { + return ("-".to_string(), "-".to_string()); + } + + let first = &s.processes[0]; + let pid = if s.processes.len() == 1 { + format!("{}", first.pid) + } else { + format!("{} (+{})", first.pid, s.processes.len() - 1) + }; + + let name = if s.processes.len() == 1 { + first.name.clone() + } else { + format!("{} (+{})", first.name, s.processes.len() - 1) + }; + + (pid, name) +} + +pub fn print_socket_table(socks: &[SocketInfo]) { + if socks.is_empty() { + println!("(no sockets)"); + return; + } + + let mut table = crate::renderer::table::make_table(&["Proto", "Fam", "Local Address", "Remote Address", "State", "PID", "Process"]); + + for s in socks { + let fam = AddressFamily::from_ip_addr(&s.local_addr()); + let (proto, fam, local, remote, state) = match &s.protocol_socket_info { + ProtocolSocketInfo::Tcp(info) => { + ( + "TCP".to_string(), + fam.to_string(), + fmt_sock_addr(info.local_addr, info.local_port), + fmt_sock_addr(info.remote_addr, info.remote_port), + format!("{:?}", info.state), + ) + } + ProtocolSocketInfo::Udp(info) => { + ( + "UDP".to_string(), + fam.to_string(), + fmt_sock_addr(info.local_addr, info.local_port), + "-".to_string(), + "-".to_string(), + ) + } + }; + + let (pid, pname) = fmt_processes(s); + + table.add_row(Row::from(vec![ + Cell::new(proto), + Cell::new(fam), + Cell::new(local), + Cell::new(remote), + Cell::new(state), + Cell::new(pid), + Cell::new(pname), + ])); + } + + println!("{table}"); +} + +pub fn print_socket_tree(socks: &[SocketInfo]) { + let host = crate::net::sys::hostname(); + let mut root = Tree::new(tree_label(format!("Sockets on {}", host))); + + if socks.is_empty() { + root.push(Tree::new(tree_label("(no sockets)"))); + println!("{root}"); + return; + } + + for s in socks { + let mut node = match &s.protocol_socket_info { + ProtocolSocketInfo::Tcp(info) => { + let fam = match info.local_addr { + IpAddr::V4(_) => "IPv4", + IpAddr::V6(_) => "IPv6", + }; + let title = format!( + "TCP [{}] {} -> {} ({:?})", + fam, + fmt_sock_addr(info.local_addr, info.local_port), + fmt_sock_addr(info.remote_addr, info.remote_port), + info.state, + ); + Tree::new(tree_label(title)) + } + ProtocolSocketInfo::Udp(info) => { + let fam = match info.local_addr { + IpAddr::V4(_) => "IPv4", + IpAddr::V6(_) => "IPv6", + }; + let title = format!( + "UDP [{}] {}", + fam, + fmt_sock_addr(info.local_addr, info.local_port), + ); + Tree::new(tree_label(title)) + } + }; + + // Processes + if s.processes.is_empty() { + node.push(Tree::new(tree_label("Process: (unknown)"))); + } else { + let mut procs = Tree::new(tree_label("Processes")); + for p in &s.processes { + procs.push(Tree::new(tree_label(format!("{} ({})", p.pid, p.name)))); + } + node.push(procs); + } + + #[cfg(target_os = "linux")] + { + node.push(Tree::new(tree_label(format!("UID: {}", s.uid)))); + node.push(Tree::new(tree_label(format!("Inode: {}", s.inode)))); + } + root.push(node); + } + + println!("{root}"); +} diff --git a/src/main.rs b/src/main.rs index 32ef4f2..7db4a89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,9 @@ async fn main() -> Result<()> { Some(Command::Neigh(args)) => { cmd::neigh::run_neigh(&cli, args)?; } + Some(Command::Socket(args)) => { + cmd::socket::show_sockets(&cli, args)?; + } }; Ok(()) } diff --git a/src/net/addr.rs b/src/net/addr.rs new file mode 100644 index 0000000..00f69e0 --- /dev/null +++ b/src/net/addr.rs @@ -0,0 +1,53 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +pub enum AddressFamily { + Ipv4, + Ipv6, +} + +impl AddressFamily { + pub fn from_ip_addr(ip: &IpAddr) -> Self { + match ip { + IpAddr::V4(_) => AddressFamily::Ipv4, + IpAddr::V6(_) => AddressFamily::Ipv6, + } + } + + #[allow(dead_code)] + pub fn from_socket_addr(sock: &SocketAddr) -> Self { + match sock { + SocketAddr::V4(_) => AddressFamily::Ipv4, + SocketAddr::V6(_) => AddressFamily::Ipv6, + } + } + + pub fn unspecified_ip(&self) -> IpAddr { + match self { + AddressFamily::Ipv4 => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + AddressFamily::Ipv6 => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + } + } + + pub fn unspecified_sock(&self) -> SocketAddr { + SocketAddr::new(self.unspecified_ip(), 0) + } +} + +impl ToString for AddressFamily { + fn to_string(&self) -> String { + match self { + AddressFamily::Ipv4 => "IPv4".to_string(), + AddressFamily::Ipv6 => "IPv6".to_string(), + } + } +} + +#[allow(dead_code)] +pub fn unwrap_or_unspecified_ip(ip: Option, family: AddressFamily) -> IpAddr { + ip.unwrap_or_else(|| family.unspecified_ip()) +} + +#[allow(dead_code)] +pub fn unwrap_or_unspecified_sock(ip: Option, family: AddressFamily) -> SocketAddr { + ip.unwrap_or_else(|| family.unspecified_sock()) +} diff --git a/src/net/mod.rs b/src/net/mod.rs index 8f4cffe..abdc049 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -2,3 +2,5 @@ pub mod iface; pub mod neigh; pub mod sys; pub mod route; +pub mod socket; +pub mod addr; diff --git a/src/net/socket.rs b/src/net/socket.rs new file mode 100644 index 0000000..e8d5967 --- /dev/null +++ b/src/net/socket.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use netsock::{ + family::AddressFamilyFlags, + protocol::ProtocolFlags, + socket::SocketInfo, + get_sockets, +}; + +pub fn collect_sockets( + proto: ProtocolFlags, + family: AddressFamilyFlags, +) -> Result> { + get_sockets(family, proto).map_err(|e| anyhow::anyhow!("Failed to collect sockets: {}", e)) +} diff --git a/src/renderer/json.rs b/src/renderer/json.rs index 3bd28bd..eb483c3 100644 --- a/src/renderer/json.rs +++ b/src/renderer/json.rs @@ -1,3 +1,5 @@ +use anyhow::Result; +use serde::Serialize; use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; @@ -14,3 +16,11 @@ pub fn print_snapshot_json(sys: &SysInfo, default_iface: Option) { let json = serde_json::to_string_pretty(&snapshot).unwrap(); println!("{}", json); } + +pub fn pretty_print_json( + data: &T, +) -> Result<()> { + let json = serde_json::to_string_pretty(data)?; + println!("{}", json); + Ok(()) +} diff --git a/src/renderer/yaml.rs b/src/renderer/yaml.rs index cbbfa4d..8be40d3 100644 --- a/src/renderer/yaml.rs +++ b/src/renderer/yaml.rs @@ -1,3 +1,5 @@ +use anyhow::Result; +use serde::Serialize; use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; @@ -14,3 +16,11 @@ pub fn print_snapshot_yaml(sys: &SysInfo, default_iface: Option) { let yaml = serde_yaml::to_string(&snapshot).unwrap(); println!("{}", yaml); } + +pub fn print_yaml( + data: &T, +) -> Result<()> { + let yaml = serde_yaml::to_string(data)?; + println!("{}", yaml); + Ok(()) +} From 6e934025c6a1dc129b853cd847945a9ea43c7739 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 12:18:39 +0900 Subject: [PATCH 06/24] Add fields to neigh tree and table --- src/cmd/neigh.rs | 105 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs index 3343598..a47df0b 100644 --- a/src/cmd/neigh.rs +++ b/src/cmd/neigh.rs @@ -1,5 +1,9 @@ +use std::net::IpAddr; + use anyhow::Result; +use mac_addr::MacAddr; use crate::cli::{Cli, OutputFormat, NeighArgs}; +use crate::db::oui::is_oui_db_initialized; use crate::net::neigh; use crate::renderer::table::make_table; use termtree::Tree; @@ -32,7 +36,9 @@ pub fn run_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { Ok(()) } -fn print_neigh_tree(table: &std::collections::HashMap) { +fn print_neigh_tree(table: &std::collections::HashMap) { + let iface = netdev::get_default_interface().unwrap(); + let self_ips: Vec = iface.ip_addrs(); let host = crate::net::sys::hostname(); let mut root = Tree::new(tree_label(format!("Neighbors (ARP/NDP) on {}", host))); @@ -44,13 +50,51 @@ fn print_neigh_tree(table: &std::collections::HashMap {}", ip, mac)); + let mut ip_node = Tree::new(tree_label(ip.to_string())); + ip_node.push(Tree::new(format!("MAC: {}", mac))); + // Vendor lookup + if is_oui_db_initialized() && *mac != MacAddr::zero() && !mac.is_broadcast() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + ip_node.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + + // Classify tags + let mut tags = Vec::new(); + if self_ips.contains(&ip) { + tags.push("Self".to_string()); + } + if let Some(gw) = &iface.gateway { + match ip { + IpAddr::V4(ipv4) => { + if gw.ipv4.contains(&ipv4) { + tags.push("Gateway".to_string()); + } + } + IpAddr::V6(ipv6) => { + if gw.ipv6.contains(&ipv6) { + tags.push("Gateway".to_string()); + } + } + } + } + + if iface.dns_servers.contains(&ip) { + tags.push("DNS".to_string()); + } + + if !tags.is_empty() { + ip_node.push(Tree::new(format!("Tags: {}", tags.join(", ")))); + } + match ip { std::net::IpAddr::V4(_) => { - v4.push(leaf); + v4.push(ip_node); }, std::net::IpAddr::V6(_) => { - v6.push(leaf); + v6.push(ip_node); }, } } @@ -60,14 +104,61 @@ fn print_neigh_tree(table: &std::collections::HashMap) { - let mut tbl = make_table(&["IP ADDRESS", "MAC ADDRESS"]); +fn print_neigh_table(table: &std::collections::HashMap) { + let iface = netdev::get_default_interface().unwrap(); + let self_ips: Vec = iface.ip_addrs(); + + let mut tbl = make_table(&["IP", "MAC", "Vendor", "Tags"]); let mut rows: Vec<_> = table.iter().collect(); rows.sort_by(|(a, _), (b, _)| a.to_string().cmp(&b.to_string())); for (ip, mac) in rows { - tbl.add_row(vec![ip.to_string(), mac.to_string()]); + // Vendor lookup + let vendor = if is_oui_db_initialized() && *mac != MacAddr::zero() && !mac.is_broadcast() { + let oui_db = crate::db::oui::oui_db(); + oui_db + .lookup_mac(mac) + .map(|v| v.vendor_detail.as_deref().unwrap_or(&v.vendor).to_string()) + } else { + None + }; + + // Classify tags + let mut tags = Vec::new(); + + // Self + if self_ips.contains(ip) { + tags.push("Self".to_string()); + } + + // Gateway + if let Some(gw) = &iface.gateway { + match ip { + IpAddr::V4(ipv4) => { + if gw.ipv4.contains(ipv4) { + tags.push("Gateway".into()); + } + } + IpAddr::V6(ipv6) => { + if gw.ipv6.contains(ipv6) { + tags.push("Gateway".into()); + } + } + } + } + + // DNS + if iface.dns_servers.contains(ip) { + tags.push("DNS".to_string()); + } + + tbl.add_row(vec![ + ip.to_string(), + mac.to_string(), + vendor.unwrap_or_else(|| "-".into()), + if tags.is_empty() { "-".into() } else { tags.join(", ") }, + ]); } println!("{tbl}"); From 8e27a8b778e48dfd5e387208cf8ff76795114353 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 12:32:31 +0900 Subject: [PATCH 07/24] Rename functions --- src/cmd/neigh.rs | 2 +- src/cmd/route.rs | 2 +- src/main.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs index a47df0b..08aae10 100644 --- a/src/cmd/neigh.rs +++ b/src/cmd/neigh.rs @@ -9,7 +9,7 @@ use crate::renderer::table::make_table; use termtree::Tree; use crate::renderer::tree::tree_label; -pub fn run_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { +pub fn show_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { let table = neigh::get_neighbor_table()?; // HashMap if args.export { diff --git a/src/cmd/route.rs b/src/cmd/route.rs index 1b01cc7..7d75d1e 100644 --- a/src/cmd/route.rs +++ b/src/cmd/route.rs @@ -6,7 +6,7 @@ use netroute::{RouteEntry, RouteFamily, RouteFlag}; use termtree::Tree; use crate::renderer::tree::tree_label; -pub fn run_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { +pub fn show_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { let mut routes = route::list_routes()?; // family filter diff --git a/src/main.rs b/src/main.rs index 7db4a89..910e3f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,10 +38,10 @@ async fn main() -> Result<()> { cmd::public::show_public_ip_info(&cli, args).await?; } Some(Command::Route(args)) => { - cmd::route::run_route(&cli, args)?; + cmd::route::show_route(&cli, args)?; } Some(Command::Neigh(args)) => { - cmd::neigh::run_neigh(&cli, args)?; + cmd::neigh::show_neigh(&cli, args)?; } Some(Command::Socket(args)) => { cmd::socket::show_sockets(&cli, args)?; From ea0b8c1cfab464c749f3349263f7a322d6747eb2 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 16:49:34 +0900 Subject: [PATCH 08/24] Rename sub-command --- src/cli.rs | 14 +++++++------- src/cmd/{show.rs => iface.rs} | 4 ++-- src/cmd/{list.rs => ifaces.rs} | 4 ++-- src/cmd/mod.rs | 6 +++--- src/cmd/{os.rs => system.rs} | 0 src/main.rs | 14 +++++++------- 6 files changed, 21 insertions(+), 21 deletions(-) rename src/cmd/{show.rs => iface.rs} (98%) rename src/cmd/{list.rs => ifaces.rs} (98%) rename src/cmd/{os.rs => system.rs} (100%) diff --git a/src/cli.rs b/src/cli.rs index f3dee73..0d1a8bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,13 +36,13 @@ pub enum OutputFormat { #[derive(Debug, Subcommand)] pub enum Command { /// Show all interfaces - List(ListArgs), + Ifaces(IfacesArgs), /// Show details for specified interface - Show(ShowArgs), + Iface(IfaceArgs), /// Monitor traffic statistics for all interfaces Monitor(MonitorArgs), /// Show OS/network stack/permission information - Os, + System, /// Show public IP information Public(PublicArgs), /// Show routing tables (IPv4/IPv6) @@ -53,9 +53,9 @@ pub enum Command { Socket(SocketArgs), } -/// List command arguments +/// Ifaces command arguments #[derive(Args, Debug)] -pub struct ListArgs { +pub struct IfacesArgs { /// Filter by name (supports partial match) #[arg(long)] pub name_like: Option, @@ -88,9 +88,9 @@ pub struct ListArgs { pub output: Option, } -/// Show command arguments +/// Iface command arguments #[derive(Args, Debug)] -pub struct ShowArgs { +pub struct IfaceArgs { /// Show details for specified interface pub iface: String, /// Output format diff --git a/src/cmd/show.rs b/src/cmd/iface.rs similarity index 98% rename from src/cmd/show.rs rename to src/cmd/iface.rs index d752b6d..d87fb98 100644 --- a/src/cmd/show.rs +++ b/src/cmd/iface.rs @@ -2,7 +2,7 @@ use netdev::Interface; use termtree::Tree; use mac_addr::MacAddr; use crate::cli::Cli; -use crate::cli::ShowArgs; +use crate::cli::IfaceArgs; use crate::db::oui::is_oui_db_initialized; use crate::net; use crate::renderer; @@ -11,7 +11,7 @@ use crate::renderer::fmt_flags; use crate::renderer::tree::tree_label; /// Show specified interface details -pub fn show_interface(_cli: &Cli, args: &ShowArgs) { +pub fn show_interface(_cli: &Cli, args: &IfaceArgs) { match net::iface::get_interface_by_name(&args.iface) { Some(iface) => { if args.export { diff --git a/src/cmd/list.rs b/src/cmd/ifaces.rs similarity index 98% rename from src/cmd/list.rs rename to src/cmd/ifaces.rs index 4fe08f1..62b9244 100644 --- a/src/cmd/list.rs +++ b/src/cmd/ifaces.rs @@ -1,5 +1,5 @@ use crate::cli::Cli; -use crate::cli::ListArgs; +use crate::cli::IfacesArgs; use crate::db::oui::is_oui_db_initialized; use crate::net; use crate::renderer; @@ -28,7 +28,7 @@ pub fn show_interfaces(cli: &Cli) { } } -pub fn list_interfaces(_cli: &Cli, args: &ListArgs) { +pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) { let mut interfaces: Vec = net::iface::get_all_interfaces(); // Apply filters diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 1045699..a0755ef 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,8 +1,8 @@ -pub mod list; +pub mod ifaces; pub mod monitor; -pub mod os; +pub mod system; pub mod public; -pub mod show; +pub mod iface; pub mod route; pub mod neigh; pub mod socket; diff --git a/src/cmd/os.rs b/src/cmd/system.rs similarity index 100% rename from src/cmd/os.rs rename to src/cmd/system.rs diff --git a/src/main.rs b/src/main.rs index 910e3f6..01f4a3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,16 +20,16 @@ async fn main() -> Result<()> { match &cli.command { None => { - cmd::list::show_interfaces(&cli); + cmd::ifaces::show_interfaces(&cli); } - Some(Command::List(args)) => { - cmd::list::list_interfaces(&cli, args); + Some(Command::Ifaces(args)) => { + cmd::ifaces::list_interfaces(&cli, args); } - Some(Command::Show(args)) => { - cmd::show::show_interface(&cli, args); + Some(Command::Iface(args)) => { + cmd::iface::show_interface(&cli, args); } - Some(Command::Os) => { - cmd::os::show_system_net_stack(&cli); + Some(Command::System) => { + cmd::system::show_system_net_stack(&cli); } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; From a982b59a599d0b407a8b0de9546d35f4803076de Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 17:27:23 +0900 Subject: [PATCH 09/24] Change default behavior --- src/cli.rs | 24 +++++--- src/cmd/iface.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++ src/cmd/ifaces.rs | 18 ------ src/cmd/socket.rs | 4 +- src/cmd/system.rs | 8 +-- src/main.rs | 6 +- 6 files changed, 164 insertions(+), 36 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0d1a8bd..598dce6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,14 +8,6 @@ use crate::cmd::monitor::{SortKey, Unit}; #[derive(Debug, Parser)] #[command(name = "nifa", author, version, about = "nifa - Cross-platform CLI tool for network information", long_about = None)] pub struct Cli { - /// Show only default interface - #[arg(short, long)] - pub default: bool, - - /// Output format - #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] - pub format: OutputFormat, - /// With vendor info (OUI lookup) #[arg(long, default_value_t = false)] pub with_vendor: bool, @@ -42,7 +34,7 @@ pub enum Command { /// Monitor traffic statistics for all interfaces Monitor(MonitorArgs), /// Show OS/network stack/permission information - System, + System(SystemArgs), /// Show public IP information Public(PublicArgs), /// Show routing tables (IPv4/IPv6) @@ -121,6 +113,20 @@ pub struct MonitorArgs { pub unit: Unit, } +/// System command arguments +#[derive(Args, Debug)] +pub struct SystemArgs { + /// Output format + #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] + pub format: OutputFormat, + /// Export data (JSON/YAML only) + #[arg(long, default_value_t = false)] + pub export: bool, + /// Output file for export + #[arg(long)] + pub output: Option, +} + #[derive(Args, Debug)] pub struct PublicArgs { /// IPv4 only diff --git a/src/cmd/iface.rs b/src/cmd/iface.rs index d87fb98..262c2c8 100644 --- a/src/cmd/iface.rs +++ b/src/cmd/iface.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use netdev::Interface; use termtree::Tree; use mac_addr::MacAddr; @@ -10,6 +11,19 @@ use crate::renderer::fmt_bps; use crate::renderer::fmt_flags; use crate::renderer::tree::tree_label; +/// Default action with no subcommand +pub fn show_default_interface(_cli: &Cli) -> Result<()> { + let iface: Interface = net::iface::get_default_interface() + .ok_or_else(|| anyhow::anyhow!("No default interface found"))?; + // Render output + print_default_interface_tree(&iface); + + // Print note about nifa help + println!(); + println!("Tip: Run 'nifa --help' to see available commands and options."); + Ok(()) +} + /// Show specified interface details pub fn show_interface(_cli: &Cli, args: &IfaceArgs) { match net::iface::get_interface_by_name(&args.iface) { @@ -45,6 +59,132 @@ pub fn show_interface(_cli: &Cli, args: &IfaceArgs) { } } +/// Print detailed information of a single interface in a tree structure. +fn print_default_interface_tree(iface: &Interface) { + let host = crate::net::sys::hostname(); + let title = format!( + "Default Network Interface on {}", + host + ); + let mut root = Tree::new(tree_label(title)); + + // flat fields (no General section) + root.push(Tree::new(format!("Index: {}", iface.index))); + root.push(Tree::new(format!("Name: {}", iface.name))); + + if let Some(fn_name) = &iface.friendly_name { + root.push(Tree::new(format!("Friendly Name: {}", fn_name))); + } + if let Some(desc) = &iface.description { + root.push(Tree::new(format!("Description: {}", desc))); + } + + root.push(Tree::new(format!("Type: {:?}", iface.if_type))); + root.push(Tree::new(format!("State: {:?}", iface.oper_state))); + + if let Some(mac) = &iface.mac_addr { + root.push(Tree::new(format!("MAC: {}", mac))); + + if is_oui_db_initialized() && *mac != MacAddr::zero() { + let oui_db = crate::db::oui::oui_db(); + if let Some(vendor) = oui_db.lookup_mac(mac) { + let vendor_name = vendor.vendor_detail.as_deref().unwrap_or(&vendor.vendor); + root.push(Tree::new(format!("Vendor: {}", vendor_name))); + } + } + } + + if let Some(mtu) = iface.mtu { + root.push(Tree::new(format!("MTU: {}", mtu))); + } + + // link speeds (humanized bps) + if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { + let mut speed = Tree::new(tree_label("Link Speed")); + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); + } + root.push(speed); + } + + // flags + root.push(Tree::new(format!("Flags: {}", fmt_flags(iface.flags)))); + + // ---- Addresses ---- + if !iface.ipv4.is_empty() { + let mut ipv4_tree = Tree::new(tree_label("IPv4")); + for net in &iface.ipv4 { + ipv4_tree.push(Tree::new(net.to_string())); + } + root.push(ipv4_tree); + } + + if !iface.ipv6.is_empty() { + let mut ipv6_tree = Tree::new(tree_label("IPv6")); + for (i, net) in iface.ipv6.iter().enumerate() { + let mut label = net.to_string(); + if let Some(scope) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", scope)); + } + ipv6_tree.push(Tree::new(label)); + } + root.push(ipv6_tree); + } + + // ---- DNS ---- + if !iface.dns_servers.is_empty() { + let mut dns_tree = Tree::new(tree_label("DNS")); + for dns in &iface.dns_servers { + dns_tree.push(Tree::new(dns.to_string())); + } + root.push(dns_tree); + } + + // ---- Gateway ---- + if let Some(gw) = &iface.gateway { + let mut gw_node = Tree::new(tree_label("Gateway")); + gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); + if !gw.ipv4.is_empty() { + let mut gw4 = Tree::new(tree_label("IPv4")); + for ip in &gw.ipv4 { + gw4.push(Tree::new(ip.to_string())); + } + gw_node.push(gw4); + } + if !gw.ipv6.is_empty() { + let mut gw6 = Tree::new(tree_label("IPv6")); + for ip in &gw.ipv6 { + gw6.push(Tree::new(ip.to_string())); + } + gw_node.push(gw6); + } + root.push(gw_node); + } + + // ---- Statistics (snapshot) ---- + if let Some(st) = &iface.stats { + let mut stats_node = Tree::new(tree_label("Statistics (snapshot)")); + stats_node.push(Tree::new(format!("RX bytes: {}", st.rx_bytes))); + stats_node.push(Tree::new(format!("TX bytes: {}", st.tx_bytes))); + root.push(stats_node); + } + + let vpn_heuristic = crate::net::iface::detect_vpn_like(&iface); + if vpn_heuristic.is_vpn_like { + let mut heuristic_node = Tree::new(tree_label("Heuristic")); + heuristic_node.push(Tree::new(format!( + "VPN-like: {}", + vpn_heuristic.is_vpn_like + ))); + root.push(heuristic_node); + } + + println!("{}", root); +} + /// Print detailed information of a single interface in a tree structure. fn print_interface_detail_tree(iface: &Interface) { let host = crate::net::sys::hostname(); diff --git a/src/cmd/ifaces.rs b/src/cmd/ifaces.rs index 62b9244..44ccf1f 100644 --- a/src/cmd/ifaces.rs +++ b/src/cmd/ifaces.rs @@ -10,24 +10,6 @@ use netdev::Interface; use netdev::interface::state::OperState; use termtree::Tree; -/// Default action with no subcommand -pub fn show_interfaces(cli: &Cli) { - let interfaces: Vec = if cli.default { - net::iface::get_default_interface() - .into_iter() - .collect() - } else { - net::iface::get_all_interfaces() - }; - // Render output - match cli.format { - crate::cli::OutputFormat::Tree => print_interface_tree(&interfaces), - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), - crate::cli::OutputFormat::Table => print_interface_table(&interfaces), - } -} - pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) { let mut interfaces: Vec = net::iface::get_all_interfaces(); diff --git a/src/cmd/socket.rs b/src/cmd/socket.rs index 0d9720a..3e14d0e 100644 --- a/src/cmd/socket.rs +++ b/src/cmd/socket.rs @@ -15,7 +15,7 @@ use netsock::{ socket::SocketInfo, }; -pub fn show_sockets(cli: &Cli, args: &SocketArgs) -> Result<()> { +pub fn show_sockets(_cli: &Cli, args: &SocketArgs) -> Result<()> { let pf = match args.proto { SocketProto::Tcp => ProtocolFlags::TCP, SocketProto::Udp => ProtocolFlags::UDP, @@ -52,7 +52,7 @@ pub fn show_sockets(cli: &Cli, args: &SocketArgs) -> Result<()> { } if args.export { - export(cli.format, args.output.as_deref(), &socks)?; + export(args.format, args.output.as_deref(), &socks)?; } match args.format { diff --git a/src/cmd/system.rs b/src/cmd/system.rs index c47b13e..71c4b7c 100644 --- a/src/cmd/system.rs +++ b/src/cmd/system.rs @@ -3,13 +3,13 @@ use termtree::Tree; use url::Url; use mac_addr::MacAddr; -use crate::{cli::Cli, db::oui::is_oui_db_initialized, net::sys::SysInfo, renderer::{fmt_bps, tree::tree_label}}; +use crate::{cli::{Cli, SystemArgs}, db::oui::is_oui_db_initialized, net::sys::SysInfo, renderer::{fmt_bps, tree::tree_label}}; /// Show system network stack details -pub fn show_system_net_stack(cli: &Cli) { +pub fn show_system_net_stack(_cli: &Cli, args: &SystemArgs) { let sys_info = crate::net::sys::system_info(); let default_iface_opt = crate::net::iface::get_default_interface(); - match cli.format { + match args.format { crate::cli::OutputFormat::Tree => { print_system_with_default_iface(&sys_info, default_iface_opt) } @@ -22,7 +22,7 @@ pub fn show_system_net_stack(cli: &Cli) { _ => { tracing::error!( "Unsupported format for show system network stack: {:?}", - cli.format + args.format ); } } diff --git a/src/main.rs b/src/main.rs index 01f4a3f..6cfcc79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ async fn main() -> Result<()> { match &cli.command { None => { - cmd::ifaces::show_interfaces(&cli); + cmd::iface::show_default_interface(&cli)?; } Some(Command::Ifaces(args)) => { cmd::ifaces::list_interfaces(&cli, args); @@ -28,8 +28,8 @@ async fn main() -> Result<()> { Some(Command::Iface(args)) => { cmd::iface::show_interface(&cli, args); } - Some(Command::System) => { - cmd::system::show_system_net_stack(&cli); + Some(Command::System(args)) => { + cmd::system::show_system_net_stack(&cli, args); } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; From 793af90fd2c7134bf38ef558f0346bef4cc886f8 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 18:07:23 +0900 Subject: [PATCH 10/24] Update monitor TUI --- src/cli.rs | 2 +- src/cmd/monitor.rs | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 598dce6..d3736eb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -109,7 +109,7 @@ pub struct MonitorArgs { #[arg(short = 'd', long, default_value = "1")] pub interval: u64, /// Display unit (bytes or bits) - #[arg(long, value_enum, default_value_t=Unit::Bytes)] + #[arg(long, value_enum, default_value_t=Unit::default())] pub unit: Unit, } diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index 4f89b24..a22e531 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -52,13 +52,13 @@ impl SortKey { #[derive(Clone, Copy, Debug, ValueEnum)] pub enum Unit { - Bytes, Bits, + Bytes, } impl Default for Unit { fn default() -> Self { - Unit::Bytes + Unit::Bits } } @@ -80,6 +80,9 @@ struct RowData { index: u32, name: String, friendly_name: Option, + state: String, + mac_addr: Option, + ipv4_addr: Option, total: u64, total_tx: u64, total_rx: u64, @@ -227,6 +230,9 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { index: itf.index, name: itf.name.clone(), friendly_name: itf.friendly_name.clone(), + state: itf.oper_state.as_str().to_string(), + mac_addr: itf.mac_addr.as_ref().map(|m| m.to_string()), + ipv4_addr: itf.ipv4.first().map(|n| n.addr().to_string()), total_rx: st.rx_bytes, total_tx: st.tx_bytes, total: st.rx_bytes + st.tx_bytes, @@ -272,7 +278,9 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let header = Row::new(vec![ Span::styled("IFACE", Style::default().add_modifier(Modifier::BOLD)), - Span::styled("Total", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("STATE", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("MAC", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("IPv4", Style::default().add_modifier(Modifier::BOLD)), Span::styled("Total RX", Style::default().add_modifier(Modifier::BOLD)), Span::styled("Total TX", Style::default().add_modifier(Modifier::BOLD)), Span::styled("RX/s", Style::default().add_modifier(Modifier::BOLD)), @@ -282,9 +290,11 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let rows_iter = rows_cache.iter().enumerate().map(|(i, r)| { let base = Row::new(vec![ Span::raw(platform_if_name(r)), - Span::raw(human_total(r.total, args.unit)), - Span::raw(human_total(r.total_rx, args.unit)), - Span::raw(human_total(r.total_tx, args.unit)), + Span::raw(r.state.clone()), + Span::raw(r.mac_addr.clone().unwrap_or_default()), + Span::raw(r.ipv4_addr.clone().unwrap_or_default()), + Span::raw(human_total(r.total_rx, Unit::Bytes)), + Span::raw(human_total(r.total_tx, Unit::Bytes)), Span::raw(human_rate(r.rx, args.unit)), Span::raw(human_rate(r.tx, args.unit)), ]); @@ -298,11 +308,13 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Table let table = Table::new(rows_iter, [ Constraint::Length(max_name_len), + Constraint::Length(8), + Constraint::Length(18), Constraint::Length(14), - Constraint::Length(14), - Constraint::Length(14), - Constraint::Length(14), - Constraint::Length(14), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), ]) .header(header) .block(Block::default().borders(Borders::ALL).title(title)) From 243f5c9538c062f9c09f79572344a0b1a72b5538 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 20:45:08 +0900 Subject: [PATCH 11/24] Improve monitor TUI popup visibility --- src/cmd/monitor.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index a22e531..3203708 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -338,30 +338,41 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { if let Some(iface) = ifs.iter().find(|it| &it.index == sel_if_index) { let area = centered_rect(66, 60, size); - // Background fill (black) - // f.render_widget(Block::default().style(Style::default().bg(Color::Black)), size); + // Render gray overlay behind the popup + let overlay = Block::default() + .style(Style::default().bg(Color::DarkGray)); + f.render_widget(overlay, size); - // Clear the area first + // Clear the area for the popup f.render_widget(Clear, area); + // Render the modal body let block = Block::default() - .title(format!("Details: {} (Esc to close — ↑/↓/w/s scroll)", iface.name)) + .title(format!( + "Details: {} (Esc to close — ↑/↓/w/s scroll)", + iface.name + )) .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)); + .style( + Style::default() + .bg(Color::Black) + .fg(Color::White), + ); let inner = block.inner(area); // Detail text (tree string created by termtree) let detail_text = iface_to_text(iface); - // Estimate content height (based on line breaks) let content_lines = detail_text.lines().count() as u16; // Visible lines in the popup let visible_lines = inner.height; - - // Clamp to scroll limit - let max_scroll = content_lines.saturating_sub(visible_lines).saturating_add(2); - if popup_scroll > max_scroll { popup_scroll = max_scroll; } + let max_scroll = content_lines + .saturating_sub(visible_lines) + .saturating_add(2); + if popup_scroll > max_scroll { + popup_scroll = max_scroll; + } let paragraph = Paragraph::new(Text::raw(detail_text)) .block(block) From fbfde58c5d80303c54d1def9aaf6dd110851abd5 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 21:06:46 +0900 Subject: [PATCH 12/24] Remove unnecessary non-ASCII characters --- src/cmd/monitor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index 3203708..939b169 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -272,7 +272,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Header let unit_label = match args.unit { Unit::Bytes => "bytes", Unit::Bits => "bits" }; let title = format!( - "nifa monitor — sort:{:?} — unit:{} — interval:{}s {}", + "nifa monitor - sort:{:?} - unit:{} - interval:{}s {}", sort, unit_label, args.interval, target_iface.as_deref().unwrap_or("(all)") ); @@ -349,7 +349,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Render the modal body let block = Block::default() .title(format!( - "Details: {} (Esc to close — ↑/↓/w/s scroll)", + "Details: {} (Esc to close - ↑/↓/w/s scroll)", iface.name )) .borders(Borders::ALL) From a5d4a3282140b1e6052b91e948e5306e6d52d8ea Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 21:15:48 +0900 Subject: [PATCH 13/24] Update sub-command help text --- src/cli.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d3736eb..fc779da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,9 +31,9 @@ pub enum Command { Ifaces(IfacesArgs), /// Show details for specified interface Iface(IfaceArgs), - /// Monitor traffic statistics for all interfaces + /// Monitor traffic statistics for interfaces in TUI Monitor(MonitorArgs), - /// Show OS/network stack/permission information + /// Show OS / kernel / proxy / default interface System(SystemArgs), /// Show public IP information Public(PublicArgs), @@ -41,7 +41,7 @@ pub enum Command { Route(RouteArgs), /// Show neighbor table (ARP/NDP) Neigh(NeighArgs), - /// Show open sockets + /// Show open TCP/UDP sockets and associated processes Socket(SocketArgs), } From c0201b3f9eb364cb79f713ca1f11fbc55cb92712 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 22:11:30 +0900 Subject: [PATCH 14/24] Update export option --- src/cli.rs | 48 +++++++++++++++++++++++++------------------- src/cmd/iface.rs | 46 ++++++++++++++++++++++-------------------- src/cmd/ifaces.rs | 37 +++++++++++++++++++--------------- src/cmd/neigh.rs | 51 ++++++++++++++++++++++++++++------------------- src/cmd/public.rs | 28 ++++++++++++++------------ src/cmd/route.rs | 37 ++++++++++++++-------------------- src/cmd/socket.rs | 27 +++++++++++++++---------- src/cmd/system.rs | 46 +++++++++++++++++++++++++++--------------- src/fs.rs | 15 ++++---------- src/main.rs | 6 +++--- 10 files changed, 187 insertions(+), 154 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index fc779da..c108957 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,6 +25,12 @@ pub enum OutputFormat { Yaml, } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ExportFormat { + Json, + Yaml, +} + #[derive(Debug, Subcommand)] pub enum Command { /// Show all interfaces @@ -72,9 +78,9 @@ pub struct IfacesArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -88,9 +94,9 @@ pub struct IfaceArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -119,9 +125,9 @@ pub struct SystemArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -138,9 +144,9 @@ pub struct PublicArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -157,9 +163,9 @@ pub struct RouteArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -170,9 +176,9 @@ pub struct NeighArgs { /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data (JSON/YAML only) - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] pub output: Option, @@ -218,9 +224,9 @@ pub struct SocketArgs { #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export in json/yaml - #[arg(long, default_value_t = false)] - pub export: bool, + /// Export data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, /// Output file for export #[arg(long)] diff --git a/src/cmd/iface.rs b/src/cmd/iface.rs index 262c2c8..9285824 100644 --- a/src/cmd/iface.rs +++ b/src/cmd/iface.rs @@ -25,30 +25,31 @@ pub fn show_default_interface(_cli: &Cli) -> Result<()> { } /// Show specified interface details -pub fn show_interface(_cli: &Cli, args: &IfaceArgs) { +pub fn show_interface(_cli: &Cli, args: &IfaceArgs) -> Result<()> { match net::iface::get_interface_by_name(&args.iface) { Some(iface) => { - if args.export { - crate::fs::export( - args.format, - args.output.as_deref(), - &iface, - ).unwrap_or_else(|e| { - tracing::error!("Export failed: {}", e); - }); - } else { - // Render output - match args.format { - crate::cli::OutputFormat::Tree => { - print_interface_detail_tree(&iface) - } - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), - _ => { - tracing::error!( - "Unsupported format for show interface: {:?}", - args.format - ); + match args.export { + Some(export_format) => { + // Export to file in specified format + crate::fs::export( + export_format, + args.output.as_deref(), + &iface, + )?; + return Ok(()); + } + None => { + // Render output + match args.format { + crate::cli::OutputFormat::Tree => print_interface_detail_tree(&iface), + crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), + crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), + _ => { + tracing::error!( + "Unsupported format for show interface: {:?}", + args.format + ); + } } } } @@ -57,6 +58,7 @@ pub fn show_interface(_cli: &Cli, args: &IfaceArgs) { tracing::error!("Interface '{}' not found", args.iface); } } + Ok(()) } /// Print detailed information of a single interface in a tree structure. diff --git a/src/cmd/ifaces.rs b/src/cmd/ifaces.rs index 44ccf1f..9fc2afd 100644 --- a/src/cmd/ifaces.rs +++ b/src/cmd/ifaces.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use crate::cli::Cli; use crate::cli::IfacesArgs; use crate::db::oui::is_oui_db_initialized; @@ -10,7 +11,7 @@ use netdev::Interface; use netdev::interface::state::OperState; use termtree::Tree; -pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) { +pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) -> Result<()> { let mut interfaces: Vec = net::iface::get_all_interfaces(); // Apply filters @@ -36,23 +37,27 @@ pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) { interfaces.retain(|iface| !iface.ipv6.is_empty()); } - if args.export { - crate::fs::export( - args.format, - args.output.as_deref(), - &interfaces, - ).unwrap_or_else(|e| { - tracing::error!("Export failed: {}", e); - }); - }else{ - // Render output - match args.format { - crate::cli::OutputFormat::Tree => print_interface_tree(&interfaces), - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), - crate::cli::OutputFormat::Table => print_interface_table(&interfaces), + match args.export { + Some(export_format) => { + // Export to file in specified format + crate::fs::export( + export_format, + args.output.as_deref(), + &interfaces, + )?; + return Ok(()); + } + None => { + // Render output + match args.format { + crate::cli::OutputFormat::Tree => print_interface_tree(&interfaces), + crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&interfaces), + crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&interfaces), + crate::cli::OutputFormat::Table => print_interface_table(&interfaces), + } } } + Ok(()) } /// Print the network interfaces in a tree structure. diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs index 08aae10..dbf1fe3 100644 --- a/src/cmd/neigh.rs +++ b/src/cmd/neigh.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::net::IpAddr; use anyhow::Result; @@ -8,34 +9,44 @@ use crate::net::neigh; use crate::renderer::table::make_table; use termtree::Tree; use crate::renderer::tree::tree_label; +use netdev::NetworkDevice; pub fn show_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { - let table = neigh::get_neighbor_table()?; // HashMap - - if args.export { - crate::fs::export( - args.format, - args.output.as_deref(), - &table, - ).unwrap_or_else(|e| { - tracing::error!("Export failed: {}", e); - }); - } else { - match args.format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&table)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&table)?), - OutputFormat::Table => { - print_neigh_table(&table); - } - OutputFormat::Tree => { - print_neigh_tree(&table); + let table: HashMap = neigh::get_neighbor_table()?; + match args.export { + Some(export_format) => { + let devices = map_to_devices(table); + crate::fs::export( + export_format, + args.output.as_deref(), + &devices, + )?; + return Ok(()); + } + None => { + match args.format { + OutputFormat::Tree => print_neigh_tree(&table), + OutputFormat::Table => print_neigh_table(&table), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&table)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&table)?), } } } - Ok(()) } +fn map_to_devices(map: HashMap) -> Vec { + map.into_iter().map(|(ip, mac)| { + let mut device = NetworkDevice::new(); + device.mac_addr = mac; + match ip { + IpAddr::V4(v4) => device.ipv4.push(v4), + IpAddr::V6(v6) => device.ipv6.push(v6), + } + device + }).collect() +} + fn print_neigh_tree(table: &std::collections::HashMap) { let iface = netdev::get_default_interface().unwrap(); let self_ips: Vec = iface.ip_addrs(); diff --git a/src/cmd/public.rs b/src/cmd/public.rs index e913ac8..f85de58 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -55,19 +55,21 @@ pub async fn show_public_ip_info(_cli: &Cli, args: &PublicArgs) -> Result<()> { let default_iface_opt = crate::net::iface::get_default_interface(); - if args.export { - crate::fs::export( - args.format, - args.output.as_deref(), - &out, - ).unwrap_or_else(|e| { - tracing::error!("Export failed: {}", e); - }); - } else { - match args.format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), - _ => print_public_ip_tree(&out, default_iface_opt), + match args.export { + Some(export_date) => { + crate::fs::export( + export_date, + args.output.as_deref(), + &out, + )?; + return Ok(()); + } + None => { + match args.format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), + _ => print_public_ip_tree(&out, default_iface_opt), + } } } Ok(()) diff --git a/src/cmd/route.rs b/src/cmd/route.rs index 7d75d1e..45cdb98 100644 --- a/src/cmd/route.rs +++ b/src/cmd/route.rs @@ -16,31 +16,24 @@ pub fn show_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { RouteFamilyOpt::Ipv6 => r.family == RouteFamily::Ipv6, }); - if args.export { - crate::fs::export( - args.format, - args.output.as_deref(), - &routes, - ).unwrap_or_else(|e| { - tracing::error!("Export failed: {}", e); - }); - } else { - match args.format { - OutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&routes)?); - } - OutputFormat::Yaml => { - println!("{}", serde_yaml::to_string(&routes)?); - } - OutputFormat::Table => { - print_route_table(&routes); - } - OutputFormat::Tree => { - print_route_tree(&routes); + match args.export { + Some(export_format) => { + crate::fs::export( + export_format, + args.output.as_deref(), + &routes, + )?; + return Ok(()); + } + None => { + match args.format { + OutputFormat::Tree => print_route_tree(&routes), + OutputFormat::Table => print_route_table(&routes), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&routes)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&routes)?), } } } - Ok(()) } diff --git a/src/cmd/socket.rs b/src/cmd/socket.rs index 3e14d0e..f90b88f 100644 --- a/src/cmd/socket.rs +++ b/src/cmd/socket.rs @@ -51,17 +51,24 @@ pub fn show_sockets(_cli: &Cli, args: &SocketArgs) -> Result<()> { }); } - if args.export { - export(args.format, args.output.as_deref(), &socks)?; - } - - match args.format { - OutputFormat::Tree => print_socket_tree(&socks), - OutputFormat::Table => print_socket_table(&socks), - OutputFormat::Json => crate::renderer::json::pretty_print_json(&socks)?, - OutputFormat::Yaml => crate::renderer::yaml::print_yaml(&socks)?, + match args.export { + Some(export_format) => { + export( + export_format, + args.output.as_deref(), + &socks, + )?; + return Ok(()); + } + None => { + match args.format { + OutputFormat::Tree => print_socket_tree(&socks), + OutputFormat::Table => print_socket_table(&socks), + OutputFormat::Json => crate::renderer::json::pretty_print_json(&socks)?, + OutputFormat::Yaml => crate::renderer::yaml::print_yaml(&socks)?, + } + } } - Ok(()) } diff --git a/src/cmd/system.rs b/src/cmd/system.rs index 71c4b7c..101d378 100644 --- a/src/cmd/system.rs +++ b/src/cmd/system.rs @@ -2,30 +2,44 @@ use netdev::Interface; use termtree::Tree; use url::Url; use mac_addr::MacAddr; +use anyhow::Result; use crate::{cli::{Cli, SystemArgs}, db::oui::is_oui_db_initialized, net::sys::SysInfo, renderer::{fmt_bps, tree::tree_label}}; /// Show system network stack details -pub fn show_system_net_stack(_cli: &Cli, args: &SystemArgs) { +pub fn show_system_net_stack(_cli: &Cli, args: &SystemArgs) -> Result<()> { let sys_info = crate::net::sys::system_info(); let default_iface_opt = crate::net::iface::get_default_interface(); - match args.format { - crate::cli::OutputFormat::Tree => { - print_system_with_default_iface(&sys_info, default_iface_opt) - } - crate::cli::OutputFormat::Json => { - crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) - } - crate::cli::OutputFormat::Yaml => { - crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) - } - _ => { - tracing::error!( - "Unsupported format for show system network stack: {:?}", - args.format - ); + match args.export { + Some(export_format) => { + crate::fs::export( + export_format, + args.output.as_deref(), + &sys_info, + )?; + return Ok(()); + } + None => { + match args.format { + crate::cli::OutputFormat::Tree => { + print_system_with_default_iface(&sys_info, default_iface_opt) + } + crate::cli::OutputFormat::Json => { + crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) + } + crate::cli::OutputFormat::Yaml => { + crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) + } + _ => { + tracing::error!( + "Unsupported format for show system network stack: {:?}", + args.format + ); + } + } } } + Ok(()) } /// Mask username/password in proxy URL for privacy diff --git a/src/fs.rs b/src/fs.rs index 5929c0c..5f76849 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -2,23 +2,16 @@ use anyhow::{Context, Result}; use serde::Serialize; use std::{fs, io::Write, path::Path}; -use crate::cli::OutputFormat; +use crate::cli::ExportFormat; pub fn export( - format: OutputFormat, + format: ExportFormat, output: Option<&Path>, data: &T, ) -> Result<()> { let (bytes, ext_default) = match format { - OutputFormat::Json => (serde_json::to_vec_pretty(data)?, "json"), - OutputFormat::Yaml => (serde_yaml::to_string(data)?.into_bytes(), "yaml"), - other => { - tracing::warn!( - "note: --export with {:?} is not supported; falling back to JSON.", - other - ); - (serde_json::to_vec_pretty(data)?, "json") - } + ExportFormat::Json => (serde_json::to_vec_pretty(data)?, "json"), + ExportFormat::Yaml => (serde_yaml::to_string(data)?.into_bytes(), "yaml"), }; if let Some(path) = output { diff --git a/src/main.rs b/src/main.rs index 6cfcc79..1b3e923 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,13 +23,13 @@ async fn main() -> Result<()> { cmd::iface::show_default_interface(&cli)?; } Some(Command::Ifaces(args)) => { - cmd::ifaces::list_interfaces(&cli, args); + cmd::ifaces::list_interfaces(&cli, args)?; } Some(Command::Iface(args)) => { - cmd::iface::show_interface(&cli, args); + cmd::iface::show_interface(&cli, args)?; } Some(Command::System(args)) => { - cmd::system::show_system_net_stack(&cli, args); + cmd::system::show_system_net_stack(&cli, args)?; } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; From c67ecb0daf0999b54965f91f2b286ec6e5b8f231 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 22:22:18 +0900 Subject: [PATCH 15/24] Move vendor lookup option to sub-commands --- src/cli.rs | 13 +++++++++---- src/cmd/iface.rs | 5 ++++- src/cmd/ifaces.rs | 4 ++++ src/cmd/neigh.rs | 5 +++++ src/main.rs | 4 ---- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index c108957..7c6563f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,10 +8,6 @@ use crate::cmd::monitor::{SortKey, Unit}; #[derive(Debug, Parser)] #[command(name = "nifa", author, version, about = "nifa - Cross-platform CLI tool for network information", long_about = None)] pub struct Cli { - /// With vendor info (OUI lookup) - #[arg(long, default_value_t = false)] - pub with_vendor: bool, - /// Subcommand #[command(subcommand)] pub command: Option, @@ -84,6 +80,9 @@ pub struct IfacesArgs { /// Output file for export #[arg(long)] pub output: Option, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, } /// Iface command arguments @@ -100,6 +99,9 @@ pub struct IfaceArgs { /// Output file for export #[arg(long)] pub output: Option, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, } /// Monitor command arguments @@ -182,6 +184,9 @@ pub struct NeighArgs { /// Output file for export #[arg(long)] pub output: Option, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, } #[derive(Debug, Clone, Copy, ValueEnum)] diff --git a/src/cmd/iface.rs b/src/cmd/iface.rs index 9285824..9e5a685 100644 --- a/src/cmd/iface.rs +++ b/src/cmd/iface.rs @@ -14,7 +14,7 @@ use crate::renderer::tree::tree_label; /// Default action with no subcommand pub fn show_default_interface(_cli: &Cli) -> Result<()> { let iface: Interface = net::iface::get_default_interface() - .ok_or_else(|| anyhow::anyhow!("No default interface found"))?; + .ok_or_else(|| anyhow::anyhow!("No default interface found"))?; // Render output print_default_interface_tree(&iface); @@ -26,6 +26,9 @@ pub fn show_default_interface(_cli: &Cli) -> Result<()> { /// Show specified interface details pub fn show_interface(_cli: &Cli, args: &IfaceArgs) -> Result<()> { + if args.vendor { + crate::db::oui::init_oui_db()?; + } match net::iface::get_interface_by_name(&args.iface) { Some(iface) => { match args.export { diff --git a/src/cmd/ifaces.rs b/src/cmd/ifaces.rs index 9fc2afd..91cdfe7 100644 --- a/src/cmd/ifaces.rs +++ b/src/cmd/ifaces.rs @@ -12,6 +12,10 @@ use netdev::interface::state::OperState; use termtree::Tree; pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) -> Result<()> { + if args.vendor { + crate::db::oui::init_oui_db()?; + } + let mut interfaces: Vec = net::iface::get_all_interfaces(); // Apply filters diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs index dbf1fe3..cf67122 100644 --- a/src/cmd/neigh.rs +++ b/src/cmd/neigh.rs @@ -12,7 +12,12 @@ use crate::renderer::tree::tree_label; use netdev::NetworkDevice; pub fn show_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { + if args.vendor { + crate::db::oui::init_oui_db()?; + } + let table: HashMap = neigh::get_neighbor_table()?; + match args.export { Some(export_format) => { let devices = map_to_devices(table); diff --git a/src/main.rs b/src/main.rs index 1b3e923..ccf5134 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,10 +14,6 @@ use cli::{Cli, Command}; async fn main() -> Result<()> { let cli = Cli::parse(); - if cli.with_vendor { - db::oui::init_oui_db()?; - } - match &cli.command { None => { cmd::iface::show_default_interface(&cli)?; From 1daffb402f7568d648dc408b57acc7bc0c5bbc51 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 23:43:05 +0900 Subject: [PATCH 16/24] Add logger --- Cargo.lock | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- src/cli.rs | 24 ++++++++ src/log.rs | 27 +++++++++ src/main.rs | 4 ++ src/time.rs | 13 ++++ 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/log.rs create mode 100644 src/time.rs diff --git a/Cargo.lock b/Cargo.lock index 829758d..eb5f6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -226,6 +235,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -693,6 +715,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -877,6 +923,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -1154,6 +1206,7 @@ name = "nifa" version = "0.3.1" dependencies = [ "anyhow", + "chrono", "clap", "comfy-table", "crossterm 0.27.0", @@ -1174,6 +1227,7 @@ dependencies = [ "termtree", "tokio", "tracing", + "tracing-subscriber", "url", "windows-sys 0.59.0", ] @@ -1188,6 +1242,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1760,6 +1823,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1977,6 +2049,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -2146,6 +2227,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "chrono", + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing-core", + "tracing-log", ] [[package]] @@ -2220,6 +2329,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "virtue" version = "0.0.18" @@ -2382,6 +2497,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -2394,6 +2544,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index a8c0049..f499207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ license = "MIT" [dependencies] anyhow = { version = "1" } +chrono = { version = "0.4" } tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml = { version = "0.9" } @@ -34,7 +36,6 @@ crossterm = "0.27" humansize = "2.1" url = "2.5" comfy-table = "7.2" -#tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } #home = { version = "0.5" } [target.'cfg(unix)'.dependencies] diff --git a/src/cli.rs b/src/cli.rs index 7c6563f..50d7b71 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,11 +8,35 @@ use crate::cmd::monitor::{SortKey, Unit}; #[derive(Debug, Parser)] #[command(name = "nifa", author, version, about = "nifa - Cross-platform CLI tool for network information", long_about = None)] pub struct Cli { + /// Set log level + #[arg(short = 'l', long, value_enum, default_value_t = LogLevel::Error)] + pub log_level: LogLevel, /// Subcommand #[command(subcommand)] pub command: Option, } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn to_tracing_level(&self) -> tracing::Level { + match self { + LogLevel::Error => tracing::Level::ERROR, + LogLevel::Warn => tracing::Level::WARN, + LogLevel::Info => tracing::Level::INFO, + LogLevel::Debug => tracing::Level::DEBUG, + LogLevel::Trace => tracing::Level::TRACE, + } + } +} + #[derive(Debug, Clone, Copy, ValueEnum)] pub enum OutputFormat { Tree, diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..4315c6e --- /dev/null +++ b/src/log.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use tracing::{Level, level_filters::LevelFilter}; +use tracing_subscriber::{Layer, filter::Targets, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::{cli::LogLevel, time::LocalDateTime}; + +pub fn init_logger(log_level: &LogLevel) -> Result<()> { + let level: Level = log_level.to_tracing_level(); + + // Filter: disable everything except `nifa` + let filter = Targets::new() + .with_default(LevelFilter::OFF) + .with_target("nifa", level); + + // Build subscriber layer + let fmt_layer = fmt::layer() + .with_target(false) + .with_timer(LocalDateTime) + .with_filter(filter); + + // Compose registry + formatting layer + tracing_subscriber::registry() + .with(fmt_layer) + .init(); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index ccf5134..201e18e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,8 @@ mod db; mod model; mod renderer; mod fs; +mod log; +mod time; use cli::{Cli, Command}; @@ -14,6 +16,8 @@ use cli::{Cli, Command}; async fn main() -> Result<()> { let cli = Cli::parse(); + log::init_logger(&cli.log_level)?; + match &cli.command { None => { cmd::iface::show_default_interface(&cli)?; diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..c57b61f --- /dev/null +++ b/src/time.rs @@ -0,0 +1,13 @@ +use tracing_subscriber::fmt::time::FormatTime; +use std::fmt; +use chrono::Local; + +/// DateTime format for logging that includes date, time, and timezone (YYYY-MM-DD HH:MM:SS.mmmmmm+00:00) +/// Same as `ChronoLocal::rfc_3339()` but with a custom format +pub struct LocalDateTime; + +impl FormatTime for LocalDateTime { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> fmt::Result { + write!(w, "{}", Local::now().format("%Y-%m-%d %H:%M:%S%.6f%:z")) + } +} From a442802d0351d7e95f72c5201e193f4e912d0d1b Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 23:52:44 +0900 Subject: [PATCH 17/24] Add short args --- src/cli.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 50d7b71..48858e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -102,7 +102,7 @@ pub struct IfacesArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, /// With vendor info (OUI lookup) #[arg(long, default_value_t = false)] @@ -121,7 +121,7 @@ pub struct IfaceArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, /// With vendor info (OUI lookup) #[arg(long, default_value_t = false)] @@ -155,7 +155,7 @@ pub struct SystemArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, } @@ -174,7 +174,7 @@ pub struct PublicArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, } @@ -193,7 +193,7 @@ pub struct RouteArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, } @@ -206,7 +206,7 @@ pub struct NeighArgs { #[arg(long, value_enum)] pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, /// With vendor info (OUI lookup) #[arg(long, default_value_t = false)] @@ -258,6 +258,6 @@ pub struct SocketArgs { pub export: Option, /// Output file for export - #[arg(long)] + #[arg(short = 'o', long)] pub output: Option, } From 634110b1d2a48b74a0d02db80e52badb5ef6474f Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 23:58:02 +0900 Subject: [PATCH 18/24] Reorder sub-command --- src/cli.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 48858e0..aa4d3af 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -59,16 +59,16 @@ pub enum Command { Iface(IfaceArgs), /// Monitor traffic statistics for interfaces in TUI Monitor(MonitorArgs), - /// Show OS / kernel / proxy / default interface - System(SystemArgs), - /// Show public IP information - Public(PublicArgs), /// Show routing tables (IPv4/IPv6) Route(RouteArgs), /// Show neighbor table (ARP/NDP) Neigh(NeighArgs), /// Show open TCP/UDP sockets and associated processes Socket(SocketArgs), + /// Show public IP information + Public(PublicArgs), + /// Show OS / kernel / proxy / default interface + System(SystemArgs), } /// Ifaces command arguments From 6cd4828535fa9ac45cd8e12fcd1f5747ca7282b1 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sat, 15 Nov 2025 23:58:51 +0900 Subject: [PATCH 19/24] Format code --- src/cli.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index aa4d3af..7ef034f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -232,31 +232,24 @@ pub struct SocketArgs { /// Protocol filter #[arg(long, value_enum, default_value = "all")] pub proto: SocketProto, - /// Address family filter #[arg(long, value_enum, default_value = "all")] pub family: SocketFamily, - /// TCP state filter (established, listen, time_wait, all) #[arg(long)] pub state: Option, - /// Filter by local or remote port #[arg(long)] pub port: Option, - /// Filter by PID #[arg(long)] pub pid: Option, - /// Output format #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Tree)] pub format: OutputFormat, - /// Export data instead of printing to stdout #[arg(long, value_enum)] pub export: Option, - /// Output file for export #[arg(short = 'o', long)] pub output: Option, From 402f34455d1f090b100ba653c940fd479d77dd58 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 16 Nov 2025 00:10:26 +0900 Subject: [PATCH 20/24] Update description --- Cargo.toml | 2 +- README.md | 2 +- src/cli.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f499207..629be7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "nifa" version = "0.3.1" edition = "2024" authors = ["shellrow "] -description = "Cross-platform CLI tool for network information" +description = "Cross-platform network inspection tool" repository = "https://github.com/shellrow/nifa" homepage = "https://github.com/shellrow/nifa" documentation = "https://github.com/shellrow/nifa" diff --git a/README.md b/README.md index b9a53ec..1326c8f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [license-badge]: https://img.shields.io/crates/l/nifa.svg # nifa [![Crates.io][crates-badge]][crates-url] ![License][license-badge] -Cross-platform CLI tool for network information +Cross-platform network inspection tool ## Features - List all network interfaces with detailed information diff --git a/src/cli.rs b/src/cli.rs index 7ef034f..f289e32 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,9 +4,9 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use crate::cmd::monitor::{SortKey, Unit}; -/// nifa - Cross-platform CLI tool for network information +/// nifa - Cross-platform network inspection tool #[derive(Debug, Parser)] -#[command(name = "nifa", author, version, about = "nifa - Cross-platform CLI tool for network information", long_about = None)] +#[command(name = "nifa", author, version, about = "nifa - Cross-platform network inspection tool", long_about = None)] pub struct Cli { /// Set log level #[arg(short = 'l', long, value_enum, default_value_t = LogLevel::Error)] From df93f808fb4f7f607775f92fb5c99d1b9190edaa Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 16 Nov 2025 00:12:04 +0900 Subject: [PATCH 21/24] Format code with cargo fmt --- src/cli.rs | 6 ++- src/cmd/iface.rs | 27 +++++------ src/cmd/ifaces.rs | 26 +++++----- src/cmd/mod.rs | 6 +-- src/cmd/monitor.rs | 2 +- src/cmd/neigh.rs | 70 ++++++++++++++------------- src/cmd/public.rs | 22 ++++----- src/cmd/route.rs | 69 +++++++++++++++++---------- src/cmd/socket.rs | 94 +++++++++++++++++-------------------- src/cmd/system.rs | 51 ++++++++++---------- src/fs.rs | 16 ++----- src/log.rs | 8 ++-- src/main.rs | 6 +-- src/net/mod.rs | 4 +- src/net/neigh/os/linux.rs | 6 +-- src/net/neigh/os/windows.rs | 36 +++++++------- src/net/socket.rs | 5 +- src/renderer/json.rs | 8 ++-- src/renderer/mod.rs | 2 +- src/renderer/table.rs | 4 +- src/renderer/yaml.rs | 8 ++-- src/time.rs | 4 +- 22 files changed, 238 insertions(+), 242 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f289e32..544e65e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -179,7 +179,11 @@ pub struct PublicArgs { } #[derive(clap::ValueEnum, Clone, Copy, Debug)] -pub enum RouteFamilyOpt { All, Ipv4, Ipv6 } +pub enum RouteFamilyOpt { + All, + Ipv4, + Ipv6, +} #[derive(Args, Debug)] pub struct RouteArgs { diff --git a/src/cmd/iface.rs b/src/cmd/iface.rs index 9e5a685..72b4d7a 100644 --- a/src/cmd/iface.rs +++ b/src/cmd/iface.rs @@ -1,7 +1,3 @@ -use anyhow::Result; -use netdev::Interface; -use termtree::Tree; -use mac_addr::MacAddr; use crate::cli::Cli; use crate::cli::IfaceArgs; use crate::db::oui::is_oui_db_initialized; @@ -10,6 +6,10 @@ use crate::renderer; use crate::renderer::fmt_bps; use crate::renderer::fmt_flags; use crate::renderer::tree::tree_label; +use anyhow::Result; +use mac_addr::MacAddr; +use netdev::Interface; +use termtree::Tree; /// Default action with no subcommand pub fn show_default_interface(_cli: &Cli) -> Result<()> { @@ -34,19 +34,19 @@ pub fn show_interface(_cli: &Cli, args: &IfaceArgs) -> Result<()> { match args.export { Some(export_format) => { // Export to file in specified format - crate::fs::export( - export_format, - args.output.as_deref(), - &iface, - )?; + crate::fs::export(export_format, args.output.as_deref(), &iface)?; return Ok(()); } None => { // Render output match args.format { crate::cli::OutputFormat::Tree => print_interface_detail_tree(&iface), - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), + crate::cli::OutputFormat::Json => { + renderer::json::print_interface_json(&[iface]) + } + crate::cli::OutputFormat::Yaml => { + renderer::yaml::print_interface_yaml(&[iface]) + } _ => { tracing::error!( "Unsupported format for show interface: {:?}", @@ -67,10 +67,7 @@ pub fn show_interface(_cli: &Cli, args: &IfaceArgs) -> Result<()> { /// Print detailed information of a single interface in a tree structure. fn print_default_interface_tree(iface: &Interface) { let host = crate::net::sys::hostname(); - let title = format!( - "Default Network Interface on {}", - host - ); + let title = format!("Default Network Interface on {}", host); let mut root = Tree::new(tree_label(title)); // flat fields (no General section) diff --git a/src/cmd/ifaces.rs b/src/cmd/ifaces.rs index 91cdfe7..02ef798 100644 --- a/src/cmd/ifaces.rs +++ b/src/cmd/ifaces.rs @@ -1,4 +1,3 @@ -use anyhow::Result; use crate::cli::Cli; use crate::cli::IfacesArgs; use crate::db::oui::is_oui_db_initialized; @@ -6,6 +5,7 @@ use crate::net; use crate::renderer; use crate::renderer::table::make_table; use crate::renderer::tree::tree_label; +use anyhow::Result; use mac_addr::MacAddr; use netdev::Interface; use netdev::interface::state::OperState; @@ -15,7 +15,7 @@ pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) -> Result<()> { if args.vendor { crate::db::oui::init_oui_db()?; } - + let mut interfaces: Vec = net::iface::get_all_interfaces(); // Apply filters @@ -44,11 +44,7 @@ pub fn list_interfaces(_cli: &Cli, args: &IfacesArgs) -> Result<()> { match args.export { Some(export_format) => { // Export to file in specified format - crate::fs::export( - export_format, - args.output.as_deref(), - &interfaces, - )?; + crate::fs::export(export_format, args.output.as_deref(), &interfaces)?; return Ok(()); } None => { @@ -179,14 +175,18 @@ fn print_interface_tree(ifaces: &[Interface]) { } fn print_interface_table(ifs: &[Interface]) { - let mut table = make_table(&["INDEX", "NAME", "TYPE", "STATE", "MAC", "IPv4", "IPv6", "MTU"]); + let mut table = make_table(&[ + "INDEX", "NAME", "TYPE", "STATE", "MAC", "IPv4", "IPv6", "MTU", + ]); for iface in ifs { - let ipv4 = iface.ipv4 + let ipv4 = iface + .ipv4 .iter() .map(|n| n.addr().to_string()) .collect::>() .join(", "); - let ipv6 = iface.ipv6 + let ipv6 = iface + .ipv6 .iter() .map(|n| n.addr().to_string()) .collect::>() @@ -197,12 +197,14 @@ fn print_interface_table(ifs: &[Interface]) { iface.name.clone(), iface.if_type.name(), iface.oper_state.to_string(), - iface.mac_addr + iface + .mac_addr .map(|m| m.to_string()) .unwrap_or_else(|| "-".into()), ipv4, ipv6, - iface.mtu + iface + .mtu .map(|m| m.to_string()) .unwrap_or_else(|| "-".into()), ]); diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index a0755ef..4fe9183 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,8 +1,8 @@ +pub mod iface; pub mod ifaces; pub mod monitor; -pub mod system; +pub mod neigh; pub mod public; -pub mod iface; pub mod route; -pub mod neigh; pub mod socket; +pub mod system; diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index 939b169..eed86e4 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -26,8 +26,8 @@ use termtree::Tree; use crate::cli::Cli; use crate::cli::MonitorArgs; use crate::net::iface::get_all_interfaces; -use crate::renderer::{fmt_bps, fmt_flags}; use crate::renderer::tree::tree_label; +use crate::renderer::{fmt_bps, fmt_flags}; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum SortKey { diff --git a/src/cmd/neigh.rs b/src/cmd/neigh.rs index cf67122..4d3ef4e 100644 --- a/src/cmd/neigh.rs +++ b/src/cmd/neigh.rs @@ -1,15 +1,15 @@ use std::collections::HashMap; use std::net::IpAddr; -use anyhow::Result; -use mac_addr::MacAddr; -use crate::cli::{Cli, OutputFormat, NeighArgs}; +use crate::cli::{Cli, NeighArgs, OutputFormat}; use crate::db::oui::is_oui_db_initialized; use crate::net::neigh; use crate::renderer::table::make_table; -use termtree::Tree; use crate::renderer::tree::tree_label; +use anyhow::Result; +use mac_addr::MacAddr; use netdev::NetworkDevice; +use termtree::Tree; pub fn show_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { if args.vendor { @@ -17,39 +17,35 @@ pub fn show_neigh(_cli: &Cli, args: &NeighArgs) -> Result<()> { } let table: HashMap = neigh::get_neighbor_table()?; - + match args.export { Some(export_format) => { let devices = map_to_devices(table); - crate::fs::export( - export_format, - args.output.as_deref(), - &devices, - )?; + crate::fs::export(export_format, args.output.as_deref(), &devices)?; return Ok(()); } - None => { - match args.format { - OutputFormat::Tree => print_neigh_tree(&table), - OutputFormat::Table => print_neigh_table(&table), - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&table)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&table)?), - } - } + None => match args.format { + OutputFormat::Tree => print_neigh_tree(&table), + OutputFormat::Table => print_neigh_table(&table), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&table)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&table)?), + }, } Ok(()) } fn map_to_devices(map: HashMap) -> Vec { - map.into_iter().map(|(ip, mac)| { - let mut device = NetworkDevice::new(); - device.mac_addr = mac; - match ip { - IpAddr::V4(v4) => device.ipv4.push(v4), - IpAddr::V6(v6) => device.ipv6.push(v6), - } - device - }).collect() + map.into_iter() + .map(|(ip, mac)| { + let mut device = NetworkDevice::new(); + device.mac_addr = mac; + match ip { + IpAddr::V4(v4) => device.ipv4.push(v4), + IpAddr::V6(v6) => device.ipv6.push(v6), + } + device + }) + .collect() } fn print_neigh_tree(table: &std::collections::HashMap) { @@ -62,7 +58,7 @@ fn print_neigh_tree(table: &std::collections::HashMap) { let mut v6 = Tree::new(tree_label("IPv6")); let mut keys: Vec<_> = table.keys().cloned().collect(); - keys.sort_by(|a,b| a.to_string().cmp(&b.to_string())); + keys.sort_by(|a, b| a.to_string().cmp(&b.to_string())); for ip in keys { let mac = table.get(&ip).unwrap(); @@ -108,15 +104,19 @@ fn print_neigh_tree(table: &std::collections::HashMap) { match ip { std::net::IpAddr::V4(_) => { v4.push(ip_node); - }, + } std::net::IpAddr::V6(_) => { v6.push(ip_node); - }, + } } } - if !v4.leaves.is_empty() { root.push(v4); } - if !v6.leaves.is_empty() { root.push(v6); } + if !v4.leaves.is_empty() { + root.push(v4); + } + if !v6.leaves.is_empty() { + root.push(v6); + } println!("{}", root); } @@ -173,7 +173,11 @@ fn print_neigh_table(table: &std::collections::HashMap) { ip.to_string(), mac.to_string(), vendor.unwrap_or_else(|| "-".into()), - if tags.is_empty() { "-".into() } else { tags.join(", ") }, + if tags.is_empty() { + "-".into() + } else { + tags.join(", ") + }, ]); } diff --git a/src/cmd/public.rs b/src/cmd/public.rs index f85de58..be2b0eb 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result}; -use netdev::Interface; use mac_addr::MacAddr; +use netdev::Interface; use reqwest::Client; -use termtree::Tree; use std::time::Duration; +use termtree::Tree; use crate::cli::{Cli, OutputFormat, PublicArgs}; use crate::db::oui::is_oui_db_initialized; @@ -57,20 +57,14 @@ pub async fn show_public_ip_info(_cli: &Cli, args: &PublicArgs) -> Result<()> { match args.export { Some(export_date) => { - crate::fs::export( - export_date, - args.output.as_deref(), - &out, - )?; + crate::fs::export(export_date, args.output.as_deref(), &out)?; return Ok(()); } - None => { - match args.format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), - _ => print_public_ip_tree(&out, default_iface_opt), - } - } + None => match args.format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&out)?), + _ => print_public_ip_tree(&out, default_iface_opt), + }, } Ok(()) } diff --git a/src/cmd/route.rs b/src/cmd/route.rs index 45cdb98..412d40f 100644 --- a/src/cmd/route.rs +++ b/src/cmd/route.rs @@ -1,10 +1,10 @@ -use anyhow::Result; use crate::cli::{Cli, OutputFormat, RouteArgs, RouteFamilyOpt}; use crate::net::route; use crate::renderer::table::make_table; +use crate::renderer::tree::tree_label; +use anyhow::Result; use netroute::{RouteEntry, RouteFamily, RouteFlag}; use termtree::Tree; -use crate::renderer::tree::tree_label; pub fn show_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { let mut routes = route::list_routes()?; @@ -18,21 +18,15 @@ pub fn show_route(_cli: &Cli, args: &RouteArgs) -> Result<()> { match args.export { Some(export_format) => { - crate::fs::export( - export_format, - args.output.as_deref(), - &routes, - )?; + crate::fs::export(export_format, args.output.as_deref(), &routes)?; return Ok(()); } - None => { - match args.format { - OutputFormat::Tree => print_route_tree(&routes), - OutputFormat::Table => print_route_table(&routes), - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&routes)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&routes)?), - } - } + None => match args.format { + OutputFormat::Tree => print_route_tree(&routes), + OutputFormat::Table => print_route_table(&routes), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&routes)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&routes)?), + }, } Ok(()) } @@ -83,15 +77,19 @@ fn print_route_tree(routes: &[RouteEntry]) { match r.family { RouteFamily::Ipv4 => { v4.push(node); - }, + } RouteFamily::Ipv6 => { v6.push(node); - }, + } } } - if !v4.leaves.is_empty() { root.push(v4); } - if !v6.leaves.is_empty() { root.push(v6); } + if !v4.leaves.is_empty() { + root.push(v4); + } + if !v6.leaves.is_empty() { + root.push(v6); + } println!("{}", root); } @@ -99,14 +97,29 @@ fn print_route_table(routes: &[RouteEntry]) { let mut table = make_table(&["FAMILY", "DESTINATION", "VIA/NH", "DEV", "METRIC", "FLAGS"]); for r in routes { - let fam = match r.family { RouteFamily::Ipv4 => "v4", RouteFamily::Ipv6 => "v6" }; - let via = r.gateway.map(|g| g.to_string()) + let fam = match r.family { + RouteFamily::Ipv4 => "v4", + RouteFamily::Ipv6 => "v6", + }; + let via = r + .gateway + .map(|g| g.to_string()) .unwrap_or_else(|| if r.on_link { "link".into() } else { "-".into() }); let dev = r.ifname.as_deref().unwrap_or("-"); let metric = r.metric.map(|m| m.to_string()).unwrap_or("-".into()); - let flags = r.flags.iter().map(RouteFlag::short).collect::>().join(""); + let flags = r + .flags + .iter() + .map(RouteFlag::short) + .collect::>() + .join(""); table.add_row(vec![ - fam, &r.destination.to_string(), &via, dev, &metric, &flags, + fam, + &r.destination.to_string(), + &via, + dev, + &metric, + &flags, ]); } @@ -114,6 +127,12 @@ fn print_route_table(routes: &[RouteEntry]) { } fn flags_short(r: &RouteEntry) -> String { - if r.flags.is_empty() { return "-".into(); } - r.flags.iter().map(RouteFlag::short).collect::>().join("") + if r.flags.is_empty() { + return "-".into(); + } + r.flags + .iter() + .map(RouteFlag::short) + .collect::>() + .join("") } diff --git a/src/cmd/socket.rs b/src/cmd/socket.rs index f90b88f..2fff929 100644 --- a/src/cmd/socket.rs +++ b/src/cmd/socket.rs @@ -1,19 +1,15 @@ -use std::net::IpAddr; -use comfy_table::{Row, Cell}; -use termtree::Tree; -use anyhow::Result; -use netsock::socket::ProtocolSocketInfo; use crate::cli::{Cli, OutputFormat, SocketArgs, SocketFamily, SocketProto}; use crate::fs::export; use crate::net::addr::AddressFamily; use crate::net::socket::collect_sockets; use crate::renderer::tree::tree_label; +use anyhow::Result; +use comfy_table::{Cell, Row}; +use netsock::socket::ProtocolSocketInfo; +use std::net::IpAddr; +use termtree::Tree; -use netsock::{ - protocol::ProtocolFlags, - family::AddressFamilyFlags, - socket::SocketInfo, -}; +use netsock::{family::AddressFamilyFlags, protocol::ProtocolFlags, socket::SocketInfo}; pub fn show_sockets(_cli: &Cli, args: &SocketArgs) -> Result<()> { let pf = match args.proto { @@ -34,40 +30,30 @@ pub fn show_sockets(_cli: &Cli, args: &SocketArgs) -> Result<()> { socks.retain(|s| s.is_owned_by_pid(pid)); } if let Some(port) = args.port { - socks.retain(|s| { - s.local_port() == port || s.remote_port() == Some(port) - }); + socks.retain(|s| s.local_port() == port || s.remote_port() == Some(port)); } if let Some(ref state) = args.state { let want = state.to_lowercase(); - socks.retain(|s| { - match &s.protocol_socket_info { - ProtocolSocketInfo::Tcp(tcp_sock) => { - let sock_state = tcp_sock.state.to_string().to_lowercase(); - want == "all" || sock_state == want - } - _ => false, + socks.retain(|s| match &s.protocol_socket_info { + ProtocolSocketInfo::Tcp(tcp_sock) => { + let sock_state = tcp_sock.state.to_string().to_lowercase(); + want == "all" || sock_state == want } + _ => false, }); } match args.export { Some(export_format) => { - export( - export_format, - args.output.as_deref(), - &socks, - )?; + export(export_format, args.output.as_deref(), &socks)?; return Ok(()); } - None => { - match args.format { - OutputFormat::Tree => print_socket_tree(&socks), - OutputFormat::Table => print_socket_table(&socks), - OutputFormat::Json => crate::renderer::json::pretty_print_json(&socks)?, - OutputFormat::Yaml => crate::renderer::yaml::print_yaml(&socks)?, - } - } + None => match args.format { + OutputFormat::Tree => print_socket_tree(&socks), + OutputFormat::Table => print_socket_table(&socks), + OutputFormat::Json => crate::renderer::json::pretty_print_json(&socks)?, + OutputFormat::Yaml => crate::renderer::yaml::print_yaml(&socks)?, + }, } Ok(()) } @@ -106,29 +92,33 @@ pub fn print_socket_table(socks: &[SocketInfo]) { return; } - let mut table = crate::renderer::table::make_table(&["Proto", "Fam", "Local Address", "Remote Address", "State", "PID", "Process"]); + let mut table = crate::renderer::table::make_table(&[ + "Proto", + "Fam", + "Local Address", + "Remote Address", + "State", + "PID", + "Process", + ]); for s in socks { let fam = AddressFamily::from_ip_addr(&s.local_addr()); let (proto, fam, local, remote, state) = match &s.protocol_socket_info { - ProtocolSocketInfo::Tcp(info) => { - ( - "TCP".to_string(), - fam.to_string(), - fmt_sock_addr(info.local_addr, info.local_port), - fmt_sock_addr(info.remote_addr, info.remote_port), - format!("{:?}", info.state), - ) - } - ProtocolSocketInfo::Udp(info) => { - ( - "UDP".to_string(), - fam.to_string(), - fmt_sock_addr(info.local_addr, info.local_port), - "-".to_string(), - "-".to_string(), - ) - } + ProtocolSocketInfo::Tcp(info) => ( + "TCP".to_string(), + fam.to_string(), + fmt_sock_addr(info.local_addr, info.local_port), + fmt_sock_addr(info.remote_addr, info.remote_port), + format!("{:?}", info.state), + ), + ProtocolSocketInfo::Udp(info) => ( + "UDP".to_string(), + fam.to_string(), + fmt_sock_addr(info.local_addr, info.local_port), + "-".to_string(), + "-".to_string(), + ), }; let (pid, pname) = fmt_processes(s); diff --git a/src/cmd/system.rs b/src/cmd/system.rs index 101d378..5787c20 100644 --- a/src/cmd/system.rs +++ b/src/cmd/system.rs @@ -1,10 +1,15 @@ +use anyhow::Result; +use mac_addr::MacAddr; use netdev::Interface; use termtree::Tree; use url::Url; -use mac_addr::MacAddr; -use anyhow::Result; -use crate::{cli::{Cli, SystemArgs}, db::oui::is_oui_db_initialized, net::sys::SysInfo, renderer::{fmt_bps, tree::tree_label}}; +use crate::{ + cli::{Cli, SystemArgs}, + db::oui::is_oui_db_initialized, + net::sys::SysInfo, + renderer::{fmt_bps, tree::tree_label}, +}; /// Show system network stack details pub fn show_system_net_stack(_cli: &Cli, args: &SystemArgs) -> Result<()> { @@ -12,32 +17,26 @@ pub fn show_system_net_stack(_cli: &Cli, args: &SystemArgs) -> Result<()> { let default_iface_opt = crate::net::iface::get_default_interface(); match args.export { Some(export_format) => { - crate::fs::export( - export_format, - args.output.as_deref(), - &sys_info, - )?; + crate::fs::export(export_format, args.output.as_deref(), &sys_info)?; return Ok(()); } - None => { - match args.format { - crate::cli::OutputFormat::Tree => { - print_system_with_default_iface(&sys_info, default_iface_opt) - } - crate::cli::OutputFormat::Json => { - crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) - } - crate::cli::OutputFormat::Yaml => { - crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) - } - _ => { - tracing::error!( - "Unsupported format for show system network stack: {:?}", - args.format - ); - } + None => match args.format { + crate::cli::OutputFormat::Tree => { + print_system_with_default_iface(&sys_info, default_iface_opt) } - } + crate::cli::OutputFormat::Json => { + crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) + } + crate::cli::OutputFormat::Yaml => { + crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) + } + _ => { + tracing::error!( + "Unsupported format for show system network stack: {:?}", + args.format + ); + } + }, } Ok(()) } diff --git a/src/fs.rs b/src/fs.rs index 5f76849..258c17d 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -4,11 +4,7 @@ use std::{fs, io::Write, path::Path}; use crate::cli::ExportFormat; -pub fn export( - format: ExportFormat, - output: Option<&Path>, - data: &T, -) -> Result<()> { +pub fn export(format: ExportFormat, output: Option<&Path>, data: &T) -> Result<()> { let (bytes, ext_default) = match format { ExportFormat::Json => (serde_json::to_vec_pretty(data)?, "json"), ExportFormat::Yaml => (serde_yaml::to_string(data)?.into_bytes(), "yaml"), @@ -16,13 +12,11 @@ pub fn export( if let Some(path) = output { atomic_write(path, &bytes, ext_default)?; - tracing::info!( - "Exported {} bytes to {}", - bytes.len(), - path.display() - ); + tracing::info!("Exported {} bytes to {}", bytes.len(), path.display()); } else { - std::io::stdout().write_all(&bytes).context("write stdout")?; + std::io::stdout() + .write_all(&bytes) + .context("write stdout")?; } Ok(()) diff --git a/src/log.rs b/src/log.rs index 4315c6e..cb9b103 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,6 +1,8 @@ use anyhow::Result; use tracing::{Level, level_filters::LevelFilter}; -use tracing_subscriber::{Layer, filter::Targets, fmt, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{ + Layer, filter::Targets, fmt, layer::SubscriberExt, util::SubscriberInitExt, +}; use crate::{cli::LogLevel, time::LocalDateTime}; @@ -19,9 +21,7 @@ pub fn init_logger(log_level: &LogLevel) -> Result<()> { .with_filter(filter); // Compose registry + formatting layer - tracing_subscriber::registry() - .with(fmt_layer) - .init(); + tracing_subscriber::registry().with(fmt_layer).init(); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 201e18e..6f224c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,12 @@ use anyhow::Result; use clap::Parser; mod cli; mod cmd; -mod net; mod db; -mod model; -mod renderer; mod fs; mod log; +mod model; +mod net; +mod renderer; mod time; use cli::{Cli, Command}; diff --git a/src/net/mod.rs b/src/net/mod.rs index abdc049..2c9c5cc 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1,6 +1,6 @@ +pub mod addr; pub mod iface; pub mod neigh; -pub mod sys; pub mod route; pub mod socket; -pub mod addr; +pub mod sys; diff --git a/src/net/neigh/os/linux.rs b/src/net/neigh/os/linux.rs index 03ecf14..5f2b6ae 100644 --- a/src/net/neigh/os/linux.rs +++ b/src/net/neigh/os/linux.rs @@ -1,10 +1,10 @@ use netdev::MacAddr; use netlink_packet_core::{NLM_F_DUMP, NLM_F_REQUEST, NetlinkMessage, NetlinkPayload}; use netlink_packet_route::{ - neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourMessage}, RouteNetlinkMessage, + neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourMessage}, }; -use netlink_sys::{protocols::NETLINK_ROUTE, Socket, SocketAddr}; +use netlink_sys::{Socket, SocketAddr, protocols::NETLINK_ROUTE}; use std::{ collections::HashMap, io::{self, ErrorKind}, @@ -14,7 +14,7 @@ use std::{ }; const SEQ_BASE: u32 = 0x6E_70_6C_73; // npls (netpulsar) -const RECV_BUFSZ: usize = 1 << 20; // 1MB +const RECV_BUFSZ: usize = 1 << 20; // 1MB const RECV_TIMEOUT: Duration = Duration::from_secs(2); const NLMSG_ALIGNTO: usize = 4; const MIN_NLMSG_HEADER_LEN: usize = 16; diff --git a/src/net/neigh/os/windows.rs b/src/net/neigh/os/windows.rs index 4c1890d..a074cb0 100644 --- a/src/net/neigh/os/windows.rs +++ b/src/net/neigh/os/windows.rs @@ -1,17 +1,17 @@ -use std::{collections::HashMap, io, net::{IpAddr, Ipv4Addr, Ipv6Addr}, ptr}; use netdev::MacAddr; +use std::{ + collections::HashMap, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + ptr, +}; use windows_sys::Win32::{ Foundation::NO_ERROR, + NetworkManagement::IpHelper::{FreeMibTable, GetIpNetTable2, MIB_IPNET_ROW2, MIB_IPNET_TABLE2}, Networking::WinSock::{ - ADDRESS_FAMILY, AF_INET, AF_INET6, SOCKADDR_INET, - IN_ADDR, IN6_ADDR, - NlnsDelay, NlnsPermanent, NlnsProbe, NlnsReachable, NlnsStale - }, - NetworkManagement::IpHelper::{ - FreeMibTable, - GetIpNetTable2, - MIB_IPNET_ROW2, MIB_IPNET_TABLE2, + ADDRESS_FAMILY, AF_INET, AF_INET6, IN_ADDR, IN6_ADDR, NlnsDelay, NlnsPermanent, NlnsProbe, + NlnsReachable, NlnsStale, SOCKADDR_INET, }, }; @@ -35,7 +35,10 @@ fn dump_ipnet(af: ADDRESS_FAMILY) -> io::Result> { let mut table_ptr: *mut MIB_IPNET_TABLE2 = ptr::null_mut(); let ret = GetIpNetTable2(af, &mut table_ptr); if ret != NO_ERROR { - return Err(io::Error::new(io::ErrorKind::Other, format!("GetIpNetTable2 failed: {ret}"))); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("GetIpNetTable2 failed: {ret}"), + )); } if table_ptr.is_null() { return Ok(out); @@ -43,7 +46,8 @@ fn dump_ipnet(af: ADDRESS_FAMILY) -> io::Result> { // free on scope exit let table: &MIB_IPNET_TABLE2 = &*table_ptr; - let rows: &[MIB_IPNET_ROW2] = std::slice::from_raw_parts(table.Table.as_ptr(), table.NumEntries as usize); + let rows: &[MIB_IPNET_ROW2] = + std::slice::from_raw_parts(table.Table.as_ptr(), table.NumEntries as usize); for row in rows { if row.PhysicalAddressLength != 6 { @@ -78,11 +82,7 @@ fn dump_ipnet(af: ADDRESS_FAMILY) -> io::Result> { fn is_interesting_state(state: i32) -> bool { matches!( state, - NlnsPermanent - | NlnsReachable - | NlnsStale - | NlnsDelay - | NlnsProbe + NlnsPermanent | NlnsReachable | NlnsStale | NlnsDelay | NlnsProbe ) } @@ -93,7 +93,9 @@ fn sockaddr_inet_to_ip(sa: &SOCKADDR_INET) -> Option { AF_INET => { let IN_ADDR { S_un: s } = sa.Ipv4.sin_addr; let bytes = s.S_un_b; - Some(IpAddr::V4(Ipv4Addr::new(bytes.s_b1, bytes.s_b2, bytes.s_b3, bytes.s_b4))) + Some(IpAddr::V4(Ipv4Addr::new( + bytes.s_b1, bytes.s_b2, bytes.s_b3, bytes.s_b4, + ))) } AF_INET6 => { let IN6_ADDR { u: u6 } = sa.Ipv6.sin6_addr; diff --git a/src/net/socket.rs b/src/net/socket.rs index e8d5967..ee65fc5 100644 --- a/src/net/socket.rs +++ b/src/net/socket.rs @@ -1,9 +1,6 @@ use anyhow::Result; use netsock::{ - family::AddressFamilyFlags, - protocol::ProtocolFlags, - socket::SocketInfo, - get_sockets, + family::AddressFamilyFlags, get_sockets, protocol::ProtocolFlags, socket::SocketInfo, }; pub fn collect_sockets( diff --git a/src/renderer/json.rs b/src/renderer/json.rs index eb483c3..be47575 100644 --- a/src/renderer/json.rs +++ b/src/renderer/json.rs @@ -1,7 +1,7 @@ +use crate::{model::snapshot::Snapshot, net::sys::SysInfo}; use anyhow::Result; -use serde::Serialize; -use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; +use serde::Serialize; pub fn print_interface_json(ifaces: &[Interface]) { let json = serde_json::to_string_pretty(ifaces).unwrap(); @@ -17,9 +17,7 @@ pub fn print_snapshot_json(sys: &SysInfo, default_iface: Option) { println!("{}", json); } -pub fn pretty_print_json( - data: &T, -) -> Result<()> { +pub fn pretty_print_json(data: &T) -> Result<()> { let json = serde_json::to_string_pretty(data)?; println!("{}", json); Ok(()) diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index c264776..9a16455 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,7 +1,7 @@ pub mod json; +pub mod table; pub mod tree; pub mod yaml; -pub mod table; pub fn fmt_bps(bps: u64) -> String { const K: f64 = 1_000.0; diff --git a/src/renderer/table.rs b/src/renderer/table.rs index 1130ba9..fa76c26 100644 --- a/src/renderer/table.rs +++ b/src/renderer/table.rs @@ -1,7 +1,5 @@ use comfy_table::{ - presets::UTF8_FULL, - modifiers::UTF8_ROUND_CORNERS, - Table, ContentArrangement, Color, Cell, + Cell, Color, ContentArrangement, Table, modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, }; /// Create a preconfigured comfy-table instance with consistent styling. diff --git a/src/renderer/yaml.rs b/src/renderer/yaml.rs index 8be40d3..4cc8091 100644 --- a/src/renderer/yaml.rs +++ b/src/renderer/yaml.rs @@ -1,7 +1,7 @@ +use crate::{model::snapshot::Snapshot, net::sys::SysInfo}; use anyhow::Result; -use serde::Serialize; -use crate::{net::sys::SysInfo, model::snapshot::Snapshot}; use netdev::Interface; +use serde::Serialize; pub fn print_interface_yaml(ifaces: &[Interface]) { let yaml = serde_yaml::to_string(ifaces).unwrap(); @@ -17,9 +17,7 @@ pub fn print_snapshot_yaml(sys: &SysInfo, default_iface: Option) { println!("{}", yaml); } -pub fn print_yaml( - data: &T, -) -> Result<()> { +pub fn print_yaml(data: &T) -> Result<()> { let yaml = serde_yaml::to_string(data)?; println!("{}", yaml); Ok(()) diff --git a/src/time.rs b/src/time.rs index c57b61f..0b09dfb 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,6 +1,6 @@ -use tracing_subscriber::fmt::time::FormatTime; -use std::fmt; use chrono::Local; +use std::fmt; +use tracing_subscriber::fmt::time::FormatTime; /// DateTime format for logging that includes date, time, and timezone (YYYY-MM-DD HH:MM:SS.mmmmmm+00:00) /// Same as `ChronoLocal::rfc_3339()` but with a custom format From abc556466b7085ee9d0471ae1195fcd500978a87 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 16 Nov 2025 00:18:33 +0900 Subject: [PATCH 22/24] Update README.md --- README.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1326c8f..aab3e26 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,18 @@ [license-badge]: https://img.shields.io/crates/l/nifa.svg # nifa [![Crates.io][crates-badge]][crates-url] ![License][license-badge] -Cross-platform network inspection tool +Cross-platform network inspection tool - a modern, read-only alternative to classic network commands. ## Features - List all network interfaces with detailed information -- Show complete details of a specific interface -- Monitor live traffic statistics in TUI -- Export snapshot in JSON or YAML for automation +- Inspect a specific interface with full metadata (addresses, DNS, gateway, speeds, flags, stats) +- View routing tables (IPv4/IPv6) +- View neighbor table (ARP/NDP) with optional vendor (OUI) lookup +- Inspect open TCP/UDP sockets with process association +- Monitor live per-interface traffic statistics in a TUI - Fetch your public IPv4/IPv6 -- Display system information along with default interface +- Display OS, kernel, proxy, permission capabilities, and default interface info +- Export view as JSON/YAML for automation ## Supported Platforms - **Linux** @@ -33,7 +36,7 @@ powershell -ExecutionPolicy Bypass -c "irm https://github.com/shellrow/nifa/rele ``` ### From Releases -You can download archives of precompiled binaries from the [releases](https://github.com/shellrow/nifa/releases) +You can download precompiled binaries from the [releases](https://github.com/shellrow/nifa/releases) ### Using Cargo @@ -46,24 +49,24 @@ cargo install nifa Usage: nifa [OPTIONS] [COMMAND] Commands: - list Show all interfaces - show Show details for specified interface - monitor Monitor traffic statistics for all interfaces - os Show OS/network stack/permission information - export Export snapshot as JSON/YAML + ifaces Show all interfaces + iface Show details for specified interface + monitor Monitor traffic statistics for interfaces in TUI + route Show routing tables (IPv4/IPv6) + neigh Show neighbor table (ARP/NDP) + socket Show open TCP/UDP sockets and associated processes public Show public IP information + system Show OS / kernel / proxy / default interface help Print this message or the help of the given subcommand(s) Options: - -d, --default Show only default interface - -f, --format Output format [default: tree] [possible values: tree, json, yaml] - --with-vendor With vendor info (OUI lookup) - -h, --help Print help - -V, --version Print version + -l, --log-level Set log level [default: error] [possible values: error, warn, info, debug, trace] + -h, --help Print help + -V, --version Print version ``` See `nifa -h` for more detail. ## Note for Developers If you are looking for a Rust library for network interface, -please check out [netdev](https://github.com/shellrow/netdev). +consider using [netdev](https://github.com/shellrow/netdev). From b93974d97166ceed9886c0ba338275a770824c90 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 16 Nov 2025 00:22:52 +0900 Subject: [PATCH 23/24] Bump version to 0.4.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb5f6cf..92917d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1203,7 +1203,7 @@ dependencies = [ [[package]] name = "nifa" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 629be7f..d70db25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nifa" -version = "0.3.1" +version = "0.4.0" edition = "2024" authors = ["shellrow "] description = "Cross-platform network inspection tool" From 026c7467d73d85414140ac45a58f1d8c736c53e5 Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sun, 16 Nov 2025 00:29:21 +0900 Subject: [PATCH 24/24] Add linux dependencies --- Cargo.lock | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 6 +++ 2 files changed, 111 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 92917d8..2a28e3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -498,6 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -506,6 +522,40 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -518,10 +568,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1155,6 +1211,20 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.17", +] + [[package]] name = "netlink-sys" version = "0.8.7" @@ -1162,8 +1232,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" dependencies = [ "bytes", + "futures", "libc", "log", + "tokio", ] [[package]] @@ -1216,11 +1288,15 @@ dependencies = [ "mac-addr", "ndb-oui", "netdev", + "netlink-packet-core 0.8.1", + "netlink-packet-route", + "netlink-sys", "netroute", "netsock", "os_info", "ratatui", "reqwest", + "rtnetlink", "serde", "serde_json", "serde_yaml", @@ -1232,6 +1308,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1633,6 +1721,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtnetlink" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08fd15aa4c64c34d0b3178e45ec6dad313a9f02b193376d501668a7950264bb7" +dependencies = [ + "futures", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc-demangle" version = "0.1.26" diff --git a/Cargo.toml b/Cargo.toml index d70db25..10c61d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,12 @@ comfy-table = "7.2" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +netlink-packet-core = "0.8" +netlink-packet-route = "0.25" +netlink-sys = "0.8" +rtnetlink = "0.18" + [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_System_SystemInformation", "Wdk_System_SystemServices"] }