diff --git a/Cargo.lock b/Cargo.lock index 7399bcd..2a28e3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,30 @@ 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" 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" @@ -85,6 +103,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 +150,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 +186,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 +214,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 +235,30 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.48" @@ -218,6 +305,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" @@ -250,7 +348,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", @@ -260,6 +358,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -321,6 +433,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 +454,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" @@ -360,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" @@ -367,6 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -375,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" @@ -387,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]] @@ -426,6 +613,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" @@ -578,6 +771,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" @@ -707,7 +924,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", ] @@ -762,24 +979,63 @@ 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" 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" +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" @@ -810,12 +1066,27 @@ 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" 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" @@ -850,28 +1121,29 @@ 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", - "netlink-packet-core", + "mac-addr", + "netlink-packet-core 0.8.1", "netlink-packet-route", "netlink-sys", "once_cell", @@ -880,6 +1152,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" @@ -895,10 +1178,51 @@ 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 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]] +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", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.17", ] [[package]] @@ -908,41 +1232,139 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" dependencies = [ "bytes", + "futures", "libc", "log", + "tokio", +] + +[[package]] +name = "netroute" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59eb911513f79dbbd83c7cb5ec059f1c4631c1b1f52f1703f52538d50a9aa200" +dependencies = [ + "libc", + "netlink-packet-core 0.8.1", + "netlink-packet-route", + "netlink-sys", + "serde", + "thiserror 1.0.69", + "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" +version = "0.4.0" dependencies = [ "anyhow", + "chrono", "clap", - "crossterm", + "comfy-table", + "crossterm 0.27.0", "hostname", "humansize", "libc", + "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", "termtree", "tokio", "tracing", + "tracing-subscriber", "url", "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "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" 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" @@ -1098,7 +1520,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -1119,7 +1541,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -1195,9 +1617,9 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cassowary", - "crossterm", + "crossterm 0.27.0", "indoc", "itertools", "lru", @@ -1205,7 +1627,7 @@ dependencies = [ "stability", "strum", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1214,9 +1636,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" @@ -1270,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" @@ -1282,6 +1750,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 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.32" @@ -1362,7 +1843,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", @@ -1447,6 +1928,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" @@ -1603,7 +2093,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", ] @@ -1624,13 +2114,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]] @@ -1644,6 +2154,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" @@ -1760,7 +2279,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", @@ -1813,6 +2332,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]] @@ -1839,6 +2386,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" @@ -1881,6 +2434,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" @@ -2043,6 +2602,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" @@ -2055,6 +2649,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 f7bbe29..10c61d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "nifa" -version = "0.3.1" +version = "0.4.0" 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" @@ -14,28 +14,39 @@ 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" } -netdev = { version = "0.38", features = ["serde"] } +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"] } 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" url = "2.5" -#tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } +comfy-table = "7.2" #home = { version = "0.5" } [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"] } diff --git a/README.md b/README.md index b9a53ec..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 CLI tool for network information +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). diff --git a/src/cli.rs b/src/cli.rs index 2a88cca..544e65e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,30 +4,49 @@ 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 { - /// 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, - + /// 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, + Table, + Json, + Yaml, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ExportFormat { Json, Yaml, } @@ -35,22 +54,26 @@ pub enum OutputFormat { #[derive(Debug, Subcommand)] pub enum Command { /// Show all interfaces - List(ListArgs), + Ifaces(IfacesArgs), /// Show details for specified interface - Show(ShowArgs), - /// Monitor traffic statistics for all interfaces + Iface(IfaceArgs), + /// Monitor traffic statistics for interfaces in TUI Monitor(MonitorArgs), - /// Show OS/network stack/permission information - Os, - /// Export snapshot as JSON/YAML - Export(ExportArgs), + /// 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), } -/// 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, @@ -72,13 +95,37 @@ 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 instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, + /// Output file for export + #[arg(short = 'o', long)] + pub output: Option, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, } -/// 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 + #[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, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, } /// Monitor command arguments @@ -94,15 +141,21 @@ 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, } -/// Export command arguments +/// System command arguments #[derive(Args, Debug)] -pub struct ExportArgs { - /// Output file - #[arg(short, long)] +pub struct SystemArgs { + /// 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, } @@ -114,4 +167,94 @@ 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 instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, + /// Output file for export + #[arg(short = 'o', 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 instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, + /// Output file for export + #[arg(short = 'o', 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 instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, + /// Output file for export + #[arg(short = 'o', long)] + pub output: Option, + /// With vendor info (OUI lookup) + #[arg(long, default_value_t = false)] + pub vendor: bool, +} + +#[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 data instead of printing to stdout + #[arg(long, value_enum)] + pub export: Option, + /// Output file for export + #[arg(short = 'o', long)] + pub output: Option, } diff --git a/src/cmd/export.rs b/src/cmd/export.rs deleted file mode 100644 index 39704b8..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::collector::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/iface.rs b/src/cmd/iface.rs new file mode 100644 index 0000000..72b4d7a --- /dev/null +++ b/src/cmd/iface.rs @@ -0,0 +1,315 @@ +use crate::cli::Cli; +use crate::cli::IfaceArgs; +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; +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<()> { + 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) -> Result<()> { + if args.vendor { + crate::db::oui::init_oui_db()?; + } + match net::iface::get_interface_by_name(&args.iface) { + Some(iface) => { + 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 + ); + } + } + } + } + } + None => { + tracing::error!("Interface '{}' not found", args.iface); + } + } + Ok(()) +} + +/// 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(); + 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/cmd/ifaces.rs b/src/cmd/ifaces.rs new file mode 100644 index 0000000..02ef798 --- /dev/null +++ b/src/cmd/ifaces.rs @@ -0,0 +1,214 @@ +use crate::cli::Cli; +use crate::cli::IfacesArgs; +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 anyhow::Result; +use mac_addr::MacAddr; +use netdev::Interface; +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 + if let Some(name_like) = &args.name_like { + interfaces.retain(|iface| iface.name.contains(name_like)); + } + if args.up { + interfaces.retain(|iface| iface.oper_state == OperState::Up); + } + if args.down { + interfaces.retain(|iface| iface.oper_state == OperState::Down); + } + if args.phy { + interfaces.retain(|iface| iface.is_physical()); + } + if args.virt { + interfaces.retain(|iface| !iface.is_physical()); + } + if args.ipv4 { + interfaces.retain(|iface| !iface.ipv4.is_empty()); + } + if args.ipv6 { + interfaces.retain(|iface| !iface.ipv6.is_empty()); + } + + 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. +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/list.rs b/src/cmd/list.rs deleted file mode 100644 index 059945e..0000000 --- a/src/cmd/list.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::cli::Cli; -use crate::cli::ListArgs; -use crate::collector; -use crate::renderer; -use netdev::Interface; -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() - .into_iter() - .collect() - } else { - collector::iface::collect_all_interfaces() - }; - // 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), - } -} - -pub fn list_interfaces(cli: &Cli, args: &ListArgs) { - let mut interfaces: Vec = collector::iface::collect_all_interfaces(); - - // Apply filters - if let Some(name_like) = &args.name_like { - interfaces.retain(|iface| iface.name.contains(name_like)); - } - if args.up { - interfaces.retain(|iface| iface.oper_state == OperState::Up); - } - if args.down { - interfaces.retain(|iface| iface.oper_state == OperState::Down); - } - if args.phy { - interfaces.retain(|iface| iface.is_physical()); - } - if args.virt { - interfaces.retain(|iface| !iface.is_physical()); - } - if args.ipv4 { - interfaces.retain(|iface| !iface.ipv4.is_empty()); - } - if args.ipv6 { - 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), - } -} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 9c839a5..4fe9183 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,8 @@ -pub mod export; -pub mod list; +pub mod iface; +pub mod ifaces; pub mod monitor; -pub mod os; +pub mod neigh; pub mod public; -pub mod show; +pub mod route; +pub mod socket; +pub mod system; diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index f978358..eed86e4 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -25,8 +25,9 @@ use termtree::Tree; use crate::cli::Cli; use crate::cli::MonitorArgs; -use crate::collector::iface::collect_all_interfaces; -use crate::renderer::tree::{fmt_bps, fmt_flags, tree_label}; +use crate::net::iface::get_all_interfaces; +use crate::renderer::tree::tree_label; +use crate::renderer::{fmt_bps, fmt_flags}; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum SortKey { @@ -51,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 } } @@ -79,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, @@ -100,7 +104,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); @@ -150,7 +154,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); } @@ -226,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, @@ -265,13 +272,15 @@ 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)") ); 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)), @@ -281,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)), ]); @@ -297,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)) @@ -325,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) @@ -474,7 +498,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/neigh.rs b/src/cmd/neigh.rs new file mode 100644 index 0000000..4d3ef4e --- /dev/null +++ b/src/cmd/neigh.rs @@ -0,0 +1,185 @@ +use std::collections::HashMap; +use std::net::IpAddr; + +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 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 { + 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); + 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(); + 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 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(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); + } + println!("{}", root); +} + +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 { + // 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}"); +} diff --git a/src/cmd/os.rs b/src/cmd/os.rs deleted file mode 100644 index 829319f..0000000 --- a/src/cmd/os.rs +++ /dev/null @@ -1,18 +0,0 @@ -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(); - match cli.format { - crate::cli::OutputFormat::Tree => { - crate::renderer::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) - } - } -} diff --git a/src/cmd/public.rs b/src/cmd/public.rs index 08327c7..be2b0eb 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -1,10 +1,15 @@ use anyhow::{Context, Result}; +use mac_addr::MacAddr; +use netdev::Interface; use reqwest::Client; use std::time::Duration; +use termtree::Tree; 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() @@ -48,12 +53,18 @@ 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)?), - 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(()) } @@ -168,3 +179,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..412d40f --- /dev/null +++ b/src/cmd/route.rs @@ -0,0 +1,138 @@ +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; + +pub fn show_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, + }); + + 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(()) +} + +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 deleted file mode 100644 index 9262a96..0000000 --- a/src/cmd/show.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::cli::Cli; -use crate::cli::ShowArgs; -use crate::collector; -use crate::renderer; - -/// Show specified interface details -pub fn show_interface(cli: &Cli, args: &ShowArgs) { - match collector::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) - } - crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), - crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), - } - } - None => { - tracing::error!("Interface '{}' not found", args.iface); - } - } -} diff --git a/src/cmd/socket.rs b/src/cmd/socket.rs new file mode 100644 index 0000000..2fff929 --- /dev/null +++ b/src/cmd/socket.rs @@ -0,0 +1,200 @@ +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::{family::AddressFamilyFlags, protocol::ProtocolFlags, 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, + }); + } + + 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(()) +} + +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/cmd/system.rs b/src/cmd/system.rs new file mode 100644 index 0000000..5787c20 --- /dev/null +++ b/src/cmd/system.rs @@ -0,0 +1,245 @@ +use anyhow::Result; +use mac_addr::MacAddr; +use netdev::Interface; +use termtree::Tree; +use url::Url; + +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<()> { + let sys_info = crate::net::sys::system_info(); + 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)?; + 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 +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/collector/mod.rs b/src/collector/mod.rs deleted file mode 100644 index 1b4875c..0000000 --- a/src/collector/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub mod iface; -pub mod sys; - -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(); - Ok(Snapshot { sys, interfaces }) -} diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..258c17d --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result}; +use serde::Serialize; +use std::{fs, io::Write, path::Path}; + +use crate::cli::ExportFormat; + +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"), + }; + + 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/log.rs b/src/log.rs new file mode 100644 index 0000000..cb9b103 --- /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 a6076b8..6f224c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,13 @@ use anyhow::Result; use clap::Parser; mod cli; mod cmd; -mod collector; mod db; +mod fs; +mod log; mod model; +mod net; mod renderer; +mod time; use cli::{Cli, Command}; @@ -13,25 +16,20 @@ use cli::{Cli, Command}; async fn main() -> Result<()> { let cli = Cli::parse(); - if cli.with_vendor { - db::oui::init_oui_db()?; - } + log::init_logger(&cli.log_level)?; match &cli.command { None => { - cmd::list::show_interfaces(&cli); + cmd::iface::show_default_interface(&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::Export(args)) => { - cmd::export::export_snapshot(&cli, args)?; + Some(Command::System(args)) => { + cmd::system::show_system_net_stack(&cli, args)?; } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; @@ -39,6 +37,15 @@ async fn main() -> Result<()> { Some(Command::Public(args)) => { cmd::public::show_public_ip_info(&cli, args).await?; } + Some(Command::Route(args)) => { + cmd::route::show_route(&cli, args)?; + } + Some(Command::Neigh(args)) => { + cmd::neigh::show_neigh(&cli, args)?; + } + Some(Command::Socket(args)) => { + cmd::socket::show_sockets(&cli, args)?; + } }; Ok(()) } 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/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/collector/iface.rs b/src/net/iface.rs similarity index 98% rename from src/collector/iface.rs rename to src/net/iface.rs index be17ef4..3804d1c 100644 --- a/src/collector/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() } diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..2c9c5cc --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,6 @@ +pub mod addr; +pub mod iface; +pub mod neigh; +pub mod route; +pub mod socket; +pub mod sys; 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..5f2b6ae --- /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::{ + RouteNetlinkMessage, + neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourMessage}, +}; +use netlink_sys::{Socket, SocketAddr, protocols::NETLINK_ROUTE}; +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..a074cb0 --- /dev/null +++ b/src/net/neigh/os/windows.rs @@ -0,0 +1,108 @@ +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, IN_ADDR, IN6_ADDR, NlnsDelay, NlnsPermanent, NlnsProbe, + NlnsReachable, NlnsStale, SOCKADDR_INET, + }, +}; + +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/net/socket.rs b/src/net/socket.rs new file mode 100644 index 0000000..ee65fc5 --- /dev/null +++ b/src/net/socket.rs @@ -0,0 +1,11 @@ +use anyhow::Result; +use netsock::{ + family::AddressFamilyFlags, get_sockets, protocol::ProtocolFlags, socket::SocketInfo, +}; + +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/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..be47575 100644 --- a/src/renderer/json.rs +++ b/src/renderer/json.rs @@ -1,5 +1,7 @@ -use crate::{collector::sys::SysInfo, model::snapshot::Snapshot}; +use crate::{model::snapshot::Snapshot, net::sys::SysInfo}; +use anyhow::Result; use netdev::Interface; +use serde::Serialize; pub fn print_interface_json(ifaces: &[Interface]) { let json = serde_json::to_string_pretty(ifaces).unwrap(); @@ -14,3 +16,9 @@ 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/mod.rs b/src/renderer/mod.rs index 7aeaedb..9a16455 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,3 +1,22 @@ pub mod json; +pub mod table; pub mod tree; pub mod yaml; + +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..fa76c26 --- /dev/null +++ b/src/renderer/table.rs @@ -0,0 +1,25 @@ +use comfy_table::{ + Cell, Color, ContentArrangement, Table, modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, +}; + +/// 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 c376ad2..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::{collector::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::collector::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::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); -} - -/// 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 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::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); -} - -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::collector::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::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); -} - -pub fn print_public_ip_tree(out: &PublicOut, default_iface: Option) { - let host = crate::collector::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::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); -} diff --git a/src/renderer/yaml.rs b/src/renderer/yaml.rs index c7dcef2..4cc8091 100644 --- a/src/renderer/yaml.rs +++ b/src/renderer/yaml.rs @@ -1,5 +1,7 @@ -use crate::{collector::sys::SysInfo, model::snapshot::Snapshot}; +use crate::{model::snapshot::Snapshot, net::sys::SysInfo}; +use anyhow::Result; use netdev::Interface; +use serde::Serialize; pub fn print_interface_yaml(ifaces: &[Interface]) { let yaml = serde_yaml::to_string(ifaces).unwrap(); @@ -14,3 +16,9 @@ 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(()) +} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..0b09dfb --- /dev/null +++ b/src/time.rs @@ -0,0 +1,13 @@ +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 +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")) + } +}