diff --git a/Cargo.lock b/Cargo.lock index 7910e2a..09d1f20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -944,13 +944,14 @@ dependencies = [ [[package]] name = "nifa" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", "crossterm", "hostname", "humansize", + "libc", "ndb-oui", "netdev", "os_info", @@ -963,6 +964,7 @@ dependencies = [ "tokio", "tracing", "url", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a325a1..f3e2eff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nifa" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["shellrow "] description = "Cross-platform CLI tool for network information" @@ -33,6 +33,12 @@ url = "2.5" #tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } #home = { version = "0.5" } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_System_SystemInformation", "Wdk_System_SystemServices"] } + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/README.md b/README.md index 6cffaa7..b9a53ec 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ [crates-badge]: https://img.shields.io/crates/v/nifa.svg [crates-url]: https://crates.io/crates/nifa +[license-badge]: https://img.shields.io/crates/l/nifa.svg -# nifa [![Crates.io][crates-badge]][crates-url] +# nifa [![Crates.io][crates-badge]][crates-url] ![License][license-badge] Cross-platform CLI tool for network information ## Features @@ -17,6 +18,29 @@ Cross-platform CLI tool for network information - **macOS** - **Windows** +## Installation + +### Install prebuilt binaries via shell script + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/shellrow/nifa/releases/latest/download/nifa-installer.sh | sh +``` + +### Install prebuilt binaries via powershell script + +```sh +powershell -ExecutionPolicy Bypass -c "irm https://github.com/shellrow/nifa/releases/latest/download/nifa-installer.ps1 | iex" +``` + +### From Releases +You can download archives of precompiled binaries from the [releases](https://github.com/shellrow/nifa/releases) + +### Using Cargo + +```sh +cargo install nifa +``` + ## Usage ``` Usage: nifa [OPTIONS] [COMMAND] @@ -38,7 +62,7 @@ Options: -V, --version Print version ``` -See nifa -h for more detail. +See `nifa -h` for more detail. ## Note for Developers If you are looking for a Rust library for network interface, diff --git a/src/cmd/public.rs b/src/cmd/public.rs index ccd7c4f..08327c7 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -48,10 +48,12 @@ 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(); + 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), + _ => print_public_ip_tree(&out, default_iface_opt), } Ok(()) } diff --git a/src/collector/iface.rs b/src/collector/iface.rs index 787744f..97f05b6 100644 --- a/src/collector/iface.rs +++ b/src/collector/iface.rs @@ -1,4 +1,24 @@ use netdev::Interface; +use netdev::interface::InterfaceType; + +/// Common patterns that indicate a VPN/tunnel adapter +const VPN_NAME_PATTERNS: &[&str] = &[ + "tun", + "tap", + "wg", + "tailscale", + "zerotier", + "zt", + "openvpn", + "ovpn", + "ipsec", + "utun", + "vpn", + "adapter", + "wan miniport", + "nord", + "expressvpn", +]; pub fn collect_all_interfaces() -> Vec { netdev::get_interfaces() @@ -20,3 +40,97 @@ pub fn get_interface_by_name(name: &str) -> Option { } None } + +#[derive(Debug)] +pub struct VpnHeuristic { + pub is_vpn_like: bool, + #[allow(dead_code)] + pub score: i32, + #[allow(dead_code)] + pub signals: Vec, +} + +/// Check if the given interface looks like a VPN interface using simple heuristics. +pub fn detect_vpn_like(default_if: &Interface) -> VpnHeuristic { + let mut score = 0; + let mut sig = Vec::new(); + + // Check InterfaceType + match default_if.if_type { + InterfaceType::Tunnel | InterfaceType::Ppp | InterfaceType::ProprietaryVirtual => { + score += 4; + sig.push(format!("type={:?}", default_if.if_type)); + } + _ => {} + } + + // Check name patterns + let name = default_if.name.to_lowercase(); + if VPN_NAME_PATTERNS.iter().any(|p| name.contains(p)) { + score += 3; + sig.push(format!("name={}", default_if.name)); + } + + // Check friendly_name patterns + if let Some(fname) = &default_if.friendly_name { + let fname_lower = fname.to_lowercase(); + if VPN_NAME_PATTERNS.iter().any(|p| fname_lower.contains(p)) { + score += 3; + sig.push(format!("friendly_name={}", fname)); + } + } + + // Check MTU + if let Some(mtu) = default_if.mtu { + if mtu < 1500 { + // Likely VPN MTU + score += if (1410..=1460).contains(&mtu) { 2 } else { 1 }; + sig.push(format!("mtu={}", mtu)); + } + } + + // Check if IPv4 is 10/8 or 100.64/10 + let v4_inner_like = default_if.ipv4.iter().any(|n| { + let ip = n.addr(); + let oct = ip.octets(); + oct[0] == 10 || (oct[0] == 100 && (oct[1] & 0b1100_0000) == 0b0100_0000) // 100.64.0.0/10 + }); + if v4_inner_like { + score += 2; + sig.push("ipv4=private(10/8 or 100.64/10)".into()); + } + + // Check if DNS is 100.64/10 + let dns_any_100_64 = default_if.dns_servers.iter().any(|ip| { + if let std::net::IpAddr::V4(v4) = ip { + let o = v4.octets(); + o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000 + } else { + false + } + }); + if dns_any_100_64 { + score += 1; + sig.push("dns=100.64.0.0/10".into()); + } + + // Check if the type is clearly not physical + match default_if.if_type { + InterfaceType::Ethernet + | InterfaceType::Wireless80211 + | InterfaceType::GigabitEthernet + | InterfaceType::FastEthernetT + | InterfaceType::FastEthernetFx => {} + _ => { + score += 1; + sig.push(format!("type-other={:?}", default_if.if_type)); + } + } + + let is_vpn_like = score >= 5; + VpnHeuristic { + is_vpn_like, + score, + signals: sig, + } +} diff --git a/src/collector/sys.rs b/src/collector/sys.rs index 1ad6ca5..d076b4f 100644 --- a/src/collector/sys.rs +++ b/src/collector/sys.rs @@ -5,6 +5,7 @@ pub struct SysInfo { pub hostname: String, pub os_type: String, pub os_version: String, + pub kernel_version: Option, pub edition: String, pub codename: String, pub bitness: String, @@ -50,10 +51,13 @@ pub fn system_info() -> SysInfo { let proxy = collect_proxy_env(); + let kernel_version = kernel_version(); + SysInfo { hostname, os_type, os_version, + kernel_version, edition, codename, bitness, @@ -79,3 +83,57 @@ pub fn collect_proxy_env() -> ProxyEnv { no_proxy: pick("no_proxy"), } } + +#[cfg(target_os = "linux")] +/// Linux-specific: get kernel version from /proc/version +fn kernel_version() -> Option { + if let Ok(contents) = std::fs::read_to_string("/proc/version") { + let parts: Vec<&str> = contents.split_whitespace().collect(); + if parts.len() >= 3 { + return Some(format!("{} {} {}", parts[0], parts[1], parts[2])); + } + } + None +} + +#[cfg(target_os = "macos")] +/// macOS-specific: get kernel version using `uname` +fn kernel_version() -> Option { + use libc::utsname; + use std::ffi::CStr; + unsafe { + let mut uts: utsname = std::mem::zeroed(); + if libc::uname(&mut uts) == 0 { + let ver = CStr::from_ptr(uts.version.as_ptr()).to_string_lossy(); + let ver_short = ver.split(':').next().unwrap_or(&ver); + return Some(ver_short.trim().to_string()); + } + } + return None; +} + +#[cfg(target_os = "windows")] +/// Windows-specific: get kernel version using `RtlGetVersion` +fn kernel_version() -> Option { + use windows_sys::Wdk::System::SystemServices::RtlGetVersion; + use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW; + unsafe { + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..std::mem::zeroed() + }; + let status = RtlGetVersion(&mut info as *mut _ as *mut _); + if status == 0 { + let major = info.dwMajorVersion; + let minor = info.dwMinorVersion; + let build = info.dwBuildNumber; + return Some(format!("Windows NT Kernel {major}.{minor}.{build}")); + } + } + return None; +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn kernel_version() -> Option { + None +} diff --git a/src/renderer/tree.rs b/src/renderer/tree.rs index 905c900..c376ad2 100644 --- a/src/renderer/tree.rs +++ b/src/renderer/tree.rs @@ -137,6 +137,18 @@ pub fn print_interface_tree(ifaces: &[Interface]) { node.push(gw_node); } + if iface.default { + let vpn_heuristic = crate::collector::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); @@ -256,6 +268,16 @@ pub fn print_interface_detail_tree(iface: &Interface) { root.push(stats_node); } + let vpn_heuristic = crate::collector::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); } @@ -272,6 +294,9 @@ pub fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option) { let host = crate::collector::sys::hostname(); let mut root = Tree::new(tree_label(format!("Public IPs on {}", host))); @@ -498,5 +533,116 @@ pub fn print_public_ip_tree(out: &PublicOut) { 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::collector::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); }