From 240107e00ffd34200ce6b5f05ef44acc8ae79a15 Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Fri, 16 Jan 2026 00:38:03 +0800 Subject: [PATCH] Add base subcrates --- .cargo/config.toml | 2 + .gitignore | 2 + Cargo.lock | 1061 +++++++++++++++++++++ Cargo.toml | 9 + crates/wtfnet-cli/Cargo.toml | 22 + crates/wtfnet-cli/src/main.rs | 736 ++++++++++++++ crates/wtfnet-core/Cargo.toml | 12 + crates/wtfnet-core/src/lib.rs | 487 ++++++++++ crates/wtfnet-platform-linux/Cargo.toml | 16 + crates/wtfnet-platform-linux/src/lib.rs | 411 ++++++++ crates/wtfnet-platform-windows/Cargo.toml | 16 + crates/wtfnet-platform-windows/src/lib.rs | 529 ++++++++++ crates/wtfnet-platform/Cargo.toml | 9 + crates/wtfnet-platform/src/lib.rs | 118 +++ docs/implementation_notes.md | 660 +++++++++++++ docs/requirement_docs.md | 938 ++++++++++++++++++ docs/status.md | 53 + 17 files changed, 5081 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/wtfnet-cli/Cargo.toml create mode 100644 crates/wtfnet-cli/src/main.rs create mode 100644 crates/wtfnet-core/Cargo.toml create mode 100644 crates/wtfnet-core/src/lib.rs create mode 100644 crates/wtfnet-platform-linux/Cargo.toml create mode 100644 crates/wtfnet-platform-linux/src/lib.rs create mode 100644 crates/wtfnet-platform-windows/Cargo.toml create mode 100644 crates/wtfnet-platform-windows/src/lib.rs create mode 100644 crates/wtfnet-platform/Cargo.toml create mode 100644 crates/wtfnet-platform/src/lib.rs create mode 100644 docs/implementation_notes.md create mode 100644 docs/requirement_docs.md create mode 100644 docs/status.md diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9de3f80 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["-L", "C:/npcap-sdk-1.15/Lib/x64"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a727c0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/data diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..310a55a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1061 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[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.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[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 = "network-interface" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a43439bf756eed340bdf8feba761e2d50c7d47175d87545cd5cbe4a137c4d1" +dependencies = [ + "cc", + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[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", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[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 = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wtfnet-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tokio", + "wtfnet-core", + "wtfnet-platform", + "wtfnet-platform-linux", + "wtfnet-platform-windows", +] + +[[package]] +name = "wtfnet-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "time", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "wtfnet-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "wtfnet-core", +] + +[[package]] +name = "wtfnet-platform-linux" +version = "0.1.0" +dependencies = [ + "async-trait", + "network-interface", + "resolv-conf", + "rustls-native-certs", + "sha1", + "sha2", + "time", + "wtfnet-core", + "wtfnet-platform", + "x509-parser", +] + +[[package]] +name = "wtfnet-platform-windows" +version = "0.1.0" +dependencies = [ + "async-trait", + "network-interface", + "regex", + "rustls-native-certs", + "sha1", + "sha2", + "time", + "wtfnet-core", + "wtfnet-platform", + "x509-parser", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..343b28f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = [ + "crates/wtfnet-core", + "crates/wtfnet-cli", + "crates/wtfnet-platform", + "crates/wtfnet-platform-windows", + "crates/wtfnet-platform-linux", +] diff --git a/crates/wtfnet-cli/Cargo.toml b/crates/wtfnet-cli/Cargo.toml new file mode 100644 index 0000000..f41aadb --- /dev/null +++ b/crates/wtfnet-cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wtfnet-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "wtfn" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +wtfnet-core = { path = "../wtfnet-core" } +wtfnet-platform = { path = "../wtfnet-platform" } + +[target.'cfg(windows)'.dependencies] +wtfnet-platform-windows = { path = "../wtfnet-platform-windows" } + +[target.'cfg(target_os = "linux")'.dependencies] +wtfnet-platform-linux = { path = "../wtfnet-platform-linux" } diff --git a/crates/wtfnet-cli/src/main.rs b/crates/wtfnet-cli/src/main.rs new file mode 100644 index 0000000..99980aa --- /dev/null +++ b/crates/wtfnet-cli/src/main.rs @@ -0,0 +1,736 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use wtfnet_core::{ + init_logging, CommandEnvelope, CommandInfo, ErrItem, ExitKind, LogFormat, LogLevel, + LoggingConfig, Meta, +}; +use wtfnet_platform::{Platform, PlatformError}; + +#[derive(Parser, Debug)] +#[command( + name = "wtfn", + version, + about = "WTFnet CLI toolbox", + arg_required_else_help = true +)] +struct Cli { + #[arg(long)] + json: bool, + #[arg(long)] + pretty: bool, + #[arg(long)] + no_color: bool, + #[arg(long)] + quiet: bool, + #[arg(short = 'v', action = clap::ArgAction::Count)] + verbose: u8, + #[arg(long)] + log_level: Option, + #[arg(long)] + log_format: Option, + #[arg(long)] + log_file: Option, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Sys { + #[command(subcommand)] + command: SysCommand, + }, + Ports { + #[command(subcommand)] + command: PortsCommand, + }, + Neigh { + #[command(subcommand)] + command: NeighCommand, + }, + Cert { + #[command(subcommand)] + command: CertCommand, + }, +} + +#[derive(Subcommand, Debug)] +enum SysCommand { + Ifaces, + Ip(SysIpArgs), + Route(SysRouteArgs), + Dns, +} + +#[derive(Subcommand, Debug)] +enum PortsCommand { + Listen(PortsListenArgs), + Who(PortsWhoArgs), +} + +#[derive(Subcommand, Debug)] +enum NeighCommand { + List(NeighListArgs), +} + +#[derive(Subcommand, Debug)] +enum CertCommand { + Roots, +} + +#[derive(Parser, Debug, Clone)] +struct SysIpArgs { + #[arg(long)] + all: bool, + #[arg(long)] + iface: Option, +} + +#[derive(Parser, Debug, Clone)] +struct SysRouteArgs { + #[arg(long)] + ipv4: bool, + #[arg(long)] + ipv6: bool, + #[arg(long)] + to: Option, +} + +#[derive(Parser, Debug, Clone)] +struct PortsListenArgs { + #[arg(long)] + tcp: bool, + #[arg(long)] + udp: bool, + #[arg(long)] + port: Option, +} + +#[derive(Parser, Debug, Clone)] +struct PortsWhoArgs { + target: String, +} + +#[derive(Parser, Debug, Clone)] +struct NeighListArgs { + #[arg(long)] + ipv4: bool, + #[arg(long)] + ipv6: bool, + #[arg(long)] + iface: Option, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let config = logging_config_from_cli(&cli); + if let Err(err) = init_logging(&config) { + eprintln!("failed to initialize logging: {err}"); + std::process::exit(ExitKind::Failed.code()); + } + + let exit_code = match &cli.command { + Commands::Sys { + command: SysCommand::Ifaces, + } => handle_sys_ifaces(&cli).await, + Commands::Sys { + command: SysCommand::Ip(args), + } => handle_sys_ip(&cli, args.clone()).await, + Commands::Sys { + command: SysCommand::Route(args), + } => handle_sys_route(&cli, args.clone()).await, + Commands::Sys { + command: SysCommand::Dns, + } => handle_sys_dns(&cli).await, + Commands::Ports { + command: PortsCommand::Listen(args), + } => handle_ports_listen(&cli, args.clone()).await, + Commands::Ports { + command: PortsCommand::Who(args), + } => handle_ports_who(&cli, args.clone()).await, + Commands::Neigh { + command: NeighCommand::List(args), + } => handle_neigh_list(&cli, args.clone()).await, + Commands::Cert { + command: CertCommand::Roots, + } => handle_cert_roots(&cli).await, + }; + + std::process::exit(exit_code); +} + +fn platform() -> Platform { + #[cfg(windows)] + { + return wtfnet_platform_windows::platform(); + } + + #[cfg(target_os = "linux")] + { + return wtfnet_platform_linux::platform(); + } + + #[cfg(not(any(windows, target_os = "linux")))] + { + panic!("unsupported platform"); + } +} + +async fn handle_sys_ifaces(cli: &Cli) -> i32 { + let result = platform().sys.interfaces().await; + match result { + Ok(interfaces) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("sys ifaces", Vec::new()); + let envelope = CommandEnvelope::new(meta, command, interfaces); + let json = if cli.pretty { + serde_json::to_string_pretty(&envelope) + } else { + serde_json::to_string(&envelope) + }; + match json { + Ok(payload) => { + println!("{payload}"); + ExitKind::Ok.code() + } + Err(err) => { + eprintln!("failed to serialize json: {err}"); + ExitKind::Failed.code() + } + } + } else { + for iface in interfaces { + println!("{}", iface.name); + if let Some(index) = iface.index { + println!(" index: {index}"); + } + if let Some(mac) = iface.mac { + println!(" mac: {mac}"); + } + if let Some(mtu) = iface.mtu { + println!(" mtu: {mtu}"); + } + if let Some(is_up) = iface.is_up { + println!(" state: {}", if is_up { "up" } else { "down" }); + } + for addr in iface.addresses { + let prefix = addr + .prefix_len + .map(|value| format!("/{value}")) + .unwrap_or_default(); + if let Some(scope) = addr.scope { + println!(" addr: {}{} ({})", addr.ip, prefix, scope); + } else { + println!(" addr: {}{}", addr.ip, prefix); + } + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_sys_ip(cli: &Cli, args: SysIpArgs) -> i32 { + let result = platform().sys.interfaces().await; + match result { + Ok(interfaces) => { + let filtered = filter_interfaces_for_ip(interfaces, &args); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let mut command_args = Vec::new(); + if args.all { + command_args.push("--all".to_string()); + } + if let Some(iface) = args.iface.as_ref() { + command_args.push("--iface".to_string()); + command_args.push(iface.clone()); + } + let command = CommandInfo::new("sys ip", command_args); + let envelope = CommandEnvelope::new(meta, command, filtered); + emit_json(cli, &envelope) + } else { + for iface in filtered { + println!("{}", iface.name); + for addr in iface.addresses { + let prefix = addr + .prefix_len + .map(|value| format!("/{value}")) + .unwrap_or_default(); + if let Some(scope) = addr.scope { + println!(" addr: {}{} ({})", addr.ip, prefix, scope); + } else { + println!(" addr: {}{}", addr.ip, prefix); + } + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_sys_route(cli: &Cli, args: SysRouteArgs) -> i32 { + let result = platform().sys.routes().await; + match result { + Ok(routes) => { + let filtered = filter_routes(routes, &args); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let mut command_args = Vec::new(); + if args.ipv4 { + command_args.push("--ipv4".to_string()); + } + if args.ipv6 { + command_args.push("--ipv6".to_string()); + } + if let Some(target) = args.to.as_ref() { + command_args.push("--to".to_string()); + command_args.push(target.clone()); + } + let command = CommandInfo::new("sys route", command_args); + let envelope = CommandEnvelope::new(meta, command, filtered); + emit_json(cli, &envelope) + } else { + for route in filtered { + let gateway = route.gateway.unwrap_or_else(|| "-".to_string()); + let iface = route.interface.unwrap_or_else(|| "-".to_string()); + if let Some(metric) = route.metric { + println!( + "{} via {} dev {} metric {}", + route.destination, gateway, iface, metric + ); + } else { + println!("{} via {} dev {}", route.destination, gateway, iface); + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_sys_dns(cli: &Cli) -> i32 { + let result = platform().sys.dns_config().await; + match result { + Ok(snapshot) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("sys dns", Vec::new()); + let envelope = CommandEnvelope::new(meta, command, snapshot); + emit_json(cli, &envelope) + } else { + println!("servers:"); + if snapshot.servers.is_empty() { + println!(" -"); + } else { + for server in snapshot.servers { + println!(" {server}"); + } + } + println!("search:"); + if snapshot.search_domains.is_empty() { + println!(" -"); + } else { + for domain in snapshot.search_domains { + println!(" {domain}"); + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_ports_listen(cli: &Cli, args: PortsListenArgs) -> i32 { + let result = platform().ports.listening().await; + match result { + Ok(sockets) => { + let filtered = filter_ports(sockets, &args); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let mut command_args = Vec::new(); + if args.tcp { + command_args.push("--tcp".to_string()); + } + if args.udp { + command_args.push("--udp".to_string()); + } + if let Some(port) = args.port { + command_args.push("--port".to_string()); + command_args.push(port.to_string()); + } + let command = CommandInfo::new("ports listen", command_args); + let envelope = CommandEnvelope::new(meta, command, filtered); + emit_json(cli, &envelope) + } else { + for socket in filtered { + if let Some(state) = socket.state.as_ref() { + println!( + "{} {} {} pid={}", + socket.proto, + socket.local_addr, + state, + socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) + ); + } else { + println!( + "{} {} pid={}", + socket.proto, + socket.local_addr, + socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) + ); + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_ports_who(cli: &Cli, args: PortsWhoArgs) -> i32 { + let port = match parse_port_arg(&args.target) { + Some(port) => port, + None => { + eprintln!("invalid port: {}", args.target); + return ExitKind::Usage.code(); + } + }; + let result = platform().ports.who_owns(port).await; + match result { + Ok(sockets) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("ports who", vec![args.target]); + let envelope = CommandEnvelope::new(meta, command, sockets); + emit_json(cli, &envelope) + } else { + for socket in sockets { + println!( + "{} {} pid={}", + socket.proto, + socket.local_addr, + socket.pid.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()) + ); + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_neigh_list(cli: &Cli, args: NeighListArgs) -> i32 { + let result = platform().neigh.neighbors().await; + match result { + Ok(neighbors) => { + let filtered = filter_neighbors(neighbors, &args); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let mut command_args = Vec::new(); + if args.ipv4 { + command_args.push("--ipv4".to_string()); + } + if args.ipv6 { + command_args.push("--ipv6".to_string()); + } + if let Some(iface) = args.iface.as_ref() { + command_args.push("--iface".to_string()); + command_args.push(iface.clone()); + } + let command = CommandInfo::new("neigh list", command_args); + let envelope = CommandEnvelope::new(meta, command, filtered); + emit_json(cli, &envelope) + } else { + for entry in filtered { + let mac = entry.mac.unwrap_or_else(|| "-".to_string()); + let iface = entry.interface.unwrap_or_else(|| "-".to_string()); + if let Some(state) = entry.state { + println!("{} {} {} {}", entry.ip, mac, iface, state); + } else { + println!("{} {} {}", entry.ip, mac, iface); + } + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +async fn handle_cert_roots(cli: &Cli) -> i32 { + let result = platform().cert.trusted_roots().await; + match result { + Ok(roots) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("cert roots", Vec::new()); + let envelope = CommandEnvelope::new(meta, command, roots); + emit_json(cli, &envelope) + } else { + for root in roots { + println!("subject: {}", root.subject); + println!("issuer: {}", root.issuer); + println!("valid: {} -> {}", root.not_before, root.not_after); + println!("sha256: {}", root.sha256); + println!("---"); + } + ExitKind::Ok.code() + } + } + Err(err) => emit_platform_error(cli, err), + } +} + +fn filter_interfaces_for_ip( + interfaces: Vec, + args: &SysIpArgs, +) -> Vec { + let mut filtered = Vec::new(); + for mut iface in interfaces { + if let Some(filter) = args.iface.as_ref() { + if iface.name != *filter { + continue; + } + } + + if !args.all { + iface.addresses.retain(|addr| !is_loopback_ip(&addr.ip)); + } + + if iface.addresses.is_empty() { + continue; + } + + filtered.push(iface); + } + filtered +} + +fn filter_routes( + routes: Vec, + args: &SysRouteArgs, +) -> Vec { + let mut filtered = Vec::new(); + for route in routes { + if args.ipv4 && !is_ipv4_route(&route.destination) { + continue; + } + if args.ipv6 && !is_ipv6_route(&route.destination) { + continue; + } + if let Some(target) = args.to.as_ref() { + if route.destination != *target { + continue; + } + } + filtered.push(route); + } + filtered +} + +fn is_loopback_ip(value: &str) -> bool { + value + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn is_ipv4_route(value: &str) -> bool { + value.parse::().is_ok() +} + +fn is_ipv6_route(value: &str) -> bool { + value.parse::().is_ok() +} + +fn filter_ports( + sockets: Vec, + args: &PortsListenArgs, +) -> Vec { + let mut filtered = Vec::new(); + for socket in sockets { + if args.tcp && socket.proto != "tcp" { + continue; + } + if args.udp && socket.proto != "udp" { + continue; + } + if let Some(port) = args.port { + if extract_port(&socket.local_addr) != Some(port) { + continue; + } + } + filtered.push(socket); + } + filtered +} + +fn filter_neighbors( + neighbors: Vec, + args: &NeighListArgs, +) -> Vec { + let mut filtered = Vec::new(); + for entry in neighbors { + if args.ipv4 && !is_ipv4_addr(&entry.ip) { + continue; + } + if args.ipv6 && !is_ipv6_addr(&entry.ip) { + continue; + } + if let Some(iface) = args.iface.as_ref() { + if entry.interface.as_deref() != Some(iface.as_str()) { + continue; + } + } + filtered.push(entry); + } + filtered +} + +fn is_ipv4_addr(value: &str) -> bool { + value.parse::().is_ok() +} + +fn is_ipv6_addr(value: &str) -> bool { + value.parse::().is_ok() +} + +fn extract_port(value: &str) -> Option { + if let Some(pos) = value.rfind(':') { + return value[pos + 1..].parse::().ok(); + } + None +} + +fn parse_port_arg(value: &str) -> Option { + if let Ok(port) = value.parse::() { + return Some(port); + } + extract_port(value) +} + +fn emit_platform_error(cli: &Cli, err: PlatformError) -> i32 { + let code = err.code.clone(); + let message = err.message.clone(); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("sys ifaces", Vec::new()); + let mut envelope = CommandEnvelope::new(meta, command, serde_json::Value::Null); + envelope.errors.push(ErrItem::new(code.clone(), message.clone())); + let json = if cli.pretty { + serde_json::to_string_pretty(&envelope) + } else { + serde_json::to_string(&envelope) + }; + if let Ok(payload) = json { + println!("{payload}"); + } else if let Ok(payload) = serde_json::to_string(&envelope) { + println!("{payload}"); + } + } else { + eprintln!("{message}"); + } + + match code { + wtfnet_core::ErrorCode::PermissionDenied => ExitKind::Permission.code(), + wtfnet_core::ErrorCode::Timeout => ExitKind::Timeout.code(), + wtfnet_core::ErrorCode::InvalidArgs => ExitKind::Usage.code(), + wtfnet_core::ErrorCode::Partial => ExitKind::Partial.code(), + _ => ExitKind::Failed.code(), + } +} + +fn emit_json(cli: &Cli, envelope: &CommandEnvelope) -> i32 { + let json = if cli.pretty { + serde_json::to_string_pretty(envelope) + } else { + serde_json::to_string(envelope) + }; + match json { + Ok(payload) => { + println!("{payload}"); + ExitKind::Ok.code() + } + Err(err) => { + eprintln!("failed to serialize json: {err}"); + ExitKind::Failed.code() + } + } +} + +fn logging_config_from_cli(cli: &Cli) -> LoggingConfig { + if cli.quiet { + return LoggingConfig { + level: LogLevel::Error, + format: parse_log_format(cli.log_format.as_deref()) + .or_else(env_log_format) + .unwrap_or(LogFormat::Text), + log_file: cli.log_file.clone().or_else(env_log_file), + }; + } + + let level = parse_log_level(cli.log_level.as_deref()) + .or_else(env_log_level) + .unwrap_or_else(|| level_from_verbosity(cli.verbose)); + + LoggingConfig { + level, + format: parse_log_format(cli.log_format.as_deref()) + .or_else(env_log_format) + .unwrap_or(LogFormat::Text), + log_file: cli.log_file.clone().or_else(env_log_file), + } +} + +fn level_from_verbosity(count: u8) -> LogLevel { + match count { + 0 => LogLevel::Info, + 1 => LogLevel::Debug, + _ => LogLevel::Trace, + } +} + +fn parse_log_level(value: Option<&str>) -> Option { + match value?.to_ascii_lowercase().as_str() { + "error" => Some(LogLevel::Error), + "warn" => Some(LogLevel::Warn), + "info" => Some(LogLevel::Info), + "debug" => Some(LogLevel::Debug), + "trace" => Some(LogLevel::Trace), + _ => None, + } +} + +fn parse_log_format(value: Option<&str>) -> Option { + match value?.to_ascii_lowercase().as_str() { + "text" => Some(LogFormat::Text), + "json" => Some(LogFormat::Json), + _ => None, + } +} + +fn env_log_level() -> Option { + std::env::var("NETTOOL_LOG_LEVEL") + .ok() + .as_deref() + .and_then(|value| parse_log_level(Some(value))) +} + +fn env_log_format() -> Option { + std::env::var("NETTOOL_LOG_FORMAT") + .ok() + .as_deref() + .and_then(|value| parse_log_format(Some(value))) +} + +fn env_log_file() -> Option { + std::env::var("NETTOOL_LOG_FILE").ok().map(PathBuf::from) +} diff --git a/crates/wtfnet-core/Cargo.toml b/crates/wtfnet-core/Cargo.toml new file mode 100644 index 0000000..7be00b9 --- /dev/null +++ b/crates/wtfnet-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wtfnet-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = { version = "0.3", features = ["formatting"] } +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } diff --git a/crates/wtfnet-core/src/lib.rs b/crates/wtfnet-core/src/lib.rs new file mode 100644 index 0000000..62b2555 --- /dev/null +++ b/crates/wtfnet-core/src/lib.rs @@ -0,0 +1,487 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use tracing_subscriber::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandEnvelope { + pub meta: Meta, + pub command: CommandInfo, + pub data: T, + pub warnings: Vec, + pub errors: Vec, +} + +impl CommandEnvelope { + pub fn new(meta: Meta, command: CommandInfo, data: T) -> Self { + Self { + meta, + command, + data, + warnings: Vec::new(), + errors: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Meta { + pub tool: String, + pub version: String, + pub timestamp: String, + pub os: String, + pub arch: String, + pub privileges: Privileges, +} + +impl Meta { + pub fn new(tool: impl Into, version: impl Into, is_admin: bool) -> Self { + Self { + tool: tool.into(), + version: version.into(), + timestamp: now_rfc3339(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + privileges: Privileges { + is_admin, + notes: Vec::new(), + }, + } + } + + pub fn add_privilege_note(&mut self, note: impl Into) { + self.privileges.notes.push(note.into()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Privileges { + pub is_admin: bool, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandInfo { + pub name: String, + pub args: Vec, +} + +impl CommandInfo { + pub fn new(name: impl Into, args: Vec) -> Self { + Self { + name: name.into(), + args, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarnItem { + pub code: String, + pub message: String, + pub details: Option, +} + +impl WarnItem { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + details: None, + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = Some(details); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrItem { + pub code: ErrorCode, + pub message: String, + pub details: Option, +} + +impl ErrItem { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = Some(details); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorCode { + PermissionDenied, + Timeout, + NotSupported, + IoError, + InvalidArgs, + Partial, + Unknown, +} + +#[derive(Debug, Clone, Copy)] +pub enum ExitKind { + Ok, + Failed, + Usage, + Permission, + Timeout, + Partial, +} + +impl ExitKind { + pub fn code(self) -> i32 { + match self { + ExitKind::Ok => 0, + ExitKind::Failed => 1, + ExitKind::Usage => 2, + ExitKind::Permission => 3, + ExitKind::Timeout => 4, + ExitKind::Partial => 5, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum LogFormat { + Text, + Json, +} + +impl LogFormat { + pub fn parse(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "text" => Some(LogFormat::Text), + "json" => Some(LogFormat::Json), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn parse(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "error" => Some(LogLevel::Error), + "warn" => Some(LogLevel::Warn), + "info" => Some(LogLevel::Info), + "debug" => Some(LogLevel::Debug), + "trace" => Some(LogLevel::Trace), + _ => None, + } + } + + fn to_level_filter(self) -> tracing_subscriber::filter::LevelFilter { + match self { + LogLevel::Error => tracing_subscriber::filter::LevelFilter::ERROR, + LogLevel::Warn => tracing_subscriber::filter::LevelFilter::WARN, + LogLevel::Info => tracing_subscriber::filter::LevelFilter::INFO, + LogLevel::Debug => tracing_subscriber::filter::LevelFilter::DEBUG, + LogLevel::Trace => tracing_subscriber::filter::LevelFilter::TRACE, + } + } +} + +#[derive(Debug, Clone)] +pub struct LoggingConfig { + pub level: LogLevel, + pub format: LogFormat, + pub log_file: Option, +} + +pub struct LoggingHandle { + _guard: Option, +} + +pub fn init_logging(config: &LoggingConfig) -> Result> { + let level_filter = config.level.to_level_filter(); + match config.format { + LogFormat::Text => init_logging_text(level_filter, config.log_file.as_ref()), + LogFormat::Json => init_logging_json(level_filter, config.log_file.as_ref()), + } +} + +fn init_logging_text( + level_filter: tracing_subscriber::filter::LevelFilter, + log_file: Option<&PathBuf>, +) -> Result> { + let (writer, guard) = logging_writer(log_file)?; + let layer = tracing_subscriber::fmt::layer() + .with_writer(writer) + .with_filter(level_filter); + tracing_subscriber::registry().with(layer).init(); + Ok(LoggingHandle { _guard: guard }) +} + +fn init_logging_json( + level_filter: tracing_subscriber::filter::LevelFilter, + log_file: Option<&PathBuf>, +) -> Result> { + let (writer, guard) = logging_writer(log_file)?; + let layer = tracing_subscriber::fmt::layer() + .with_writer(writer) + .json() + .with_filter(level_filter); + tracing_subscriber::registry().with(layer).init(); + Ok(LoggingHandle { _guard: guard }) +} + +fn logging_writer( + log_file: Option<&PathBuf>, +) -> Result< + ( + tracing_subscriber::fmt::writer::BoxMakeWriter, + Option, + ), + Box, +> { + use tracing_subscriber::fmt::writer::{BoxMakeWriter, MakeWriterExt}; + + if let Some(path) = log_file { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + let (non_blocking, guard) = tracing_appender::non_blocking(file); + let writer = std::io::stderr.and(non_blocking); + Ok((BoxMakeWriter::new(writer), Some(guard))) + } else { + Ok((BoxMakeWriter::new(std::io::stderr), None)) + } +} + +fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub geoip: GeoIpConfig, + pub dns: DnsConfig, + pub probe: ProbeConfig, + pub http: HttpConfig, + pub logging: LoggingSettings, +} + +impl Default for Config { + fn default() -> Self { + Self { + geoip: GeoIpConfig::default(), + dns: DnsConfig::default(), + probe: ProbeConfig::default(), + http: HttpConfig::default(), + logging: LoggingSettings::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeoIpConfig { + pub country_db: Option, + pub asn_db: Option, +} + +impl Default for GeoIpConfig { + fn default() -> Self { + Self { + country_db: None, + asn_db: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsConfig { + pub detect_servers: Vec, + pub timeout_ms: u64, + pub repeat: u32, +} + +impl Default for DnsConfig { + fn default() -> Self { + Self { + detect_servers: vec![ + "1.1.1.1".to_string(), + "8.8.8.8".to_string(), + "9.9.9.9".to_string(), + ], + timeout_ms: 2000, + repeat: 3, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProbeConfig { + pub timeout_ms: u64, + pub count: u32, +} + +impl Default for ProbeConfig { + fn default() -> Self { + Self { + timeout_ms: 800, + count: 4, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpConfig { + pub timeout_ms: u64, + pub follow_redirects: u32, + pub max_body_bytes: u64, +} + +impl Default for HttpConfig { + fn default() -> Self { + Self { + timeout_ms: 3000, + follow_redirects: 3, + max_body_bytes: 8192, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingSettings { + pub level: String, + pub format: String, + pub file: Option, +} + +impl Default for LoggingSettings { + fn default() -> Self { + Self { + level: "info".to_string(), + format: "text".to_string(), + file: None, + } + } +} + +#[derive(Debug)] +pub enum ConfigError { + Io(std::io::Error), + Parse(serde_json::Error), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::Io(err) => write!(f, "config io error: {err}"), + ConfigError::Parse(err) => write!(f, "config parse error: {err}"), + } + } +} + +impl std::error::Error for ConfigError {} + +impl From for ConfigError { + fn from(err: std::io::Error) -> Self { + ConfigError::Io(err) + } +} + +impl From for ConfigError { + fn from(err: serde_json::Error) -> Self { + ConfigError::Parse(err) + } +} + +pub fn load_config(path: Option<&Path>) -> Result { + let mut config = match path { + Some(path) => load_config_from_path(path)?, + None => load_default_config().unwrap_or_else(Config::default), + }; + + apply_env_overrides(&mut config); + + Ok(config) +} + +pub fn load_default_config() -> Option { + let path = default_config_path()?; + if !path.exists() { + return None; + } + load_config_from_path(&path).ok() +} + +pub fn load_config_from_path(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&contents)?; + Ok(config) +} + +pub fn default_config_path() -> Option { + if cfg!(windows) { + std::env::var("APPDATA") + .ok() + .map(|root| PathBuf::from(root).join("wtfnet").join("config.json")) + } else { + let base = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg) + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".config") + } else { + return None; + }; + Some(base.join("wtfnet").join("config.json")) + } +} + +pub fn apply_env_overrides(config: &mut Config) { + if let Ok(value) = std::env::var("NETTOOL_GEOIP_COUNTRY_DB") { + if !value.trim().is_empty() { + config.geoip.country_db = Some(PathBuf::from(value)); + } + } + + if let Ok(value) = std::env::var("NETTOOL_GEOIP_ASN_DB") { + if !value.trim().is_empty() { + config.geoip.asn_db = Some(PathBuf::from(value)); + } + } + + if let Ok(value) = std::env::var("NETTOOL_LOG_LEVEL") { + if !value.trim().is_empty() { + config.logging.level = value; + } + } + + if let Ok(value) = std::env::var("NETTOOL_LOG_FORMAT") { + if !value.trim().is_empty() { + config.logging.format = value; + } + } + + if let Ok(value) = std::env::var("NETTOOL_LOG_FILE") { + if !value.trim().is_empty() { + config.logging.file = Some(PathBuf::from(value)); + } + } +} diff --git a/crates/wtfnet-platform-linux/Cargo.toml b/crates/wtfnet-platform-linux/Cargo.toml new file mode 100644 index 0000000..600d5e9 --- /dev/null +++ b/crates/wtfnet-platform-linux/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wtfnet-platform-linux" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +network-interface = "1" +resolv-conf = "0.7" +rustls-native-certs = "0.7" +sha1 = "0.10" +sha2 = "0.10" +time = { version = "0.3", features = ["formatting"] } +x509-parser = "0.16" +wtfnet-platform = { path = "../wtfnet-platform" } +wtfnet-core = { path = "../wtfnet-core" } diff --git a/crates/wtfnet-platform-linux/src/lib.rs b/crates/wtfnet-platform-linux/src/lib.rs new file mode 100644 index 0000000..05f1d25 --- /dev/null +++ b/crates/wtfnet-platform-linux/src/lib.rs @@ -0,0 +1,411 @@ +use async_trait::async_trait; +use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}; +use sha2::Digest; +use std::sync::Arc; +use wtfnet_core::ErrorCode; +use wtfnet_platform::{ + CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface, + Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider, +}; +use x509_parser::oid_registry::{ + OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256, + OID_KEY_TYPE_GOST_R3410_2012_512, OID_PKCS1_RSAENCRYPTION, +}; + +pub fn platform() -> Platform { + Platform { + sys: Arc::new(LinuxSysProvider), + ports: Arc::new(LinuxPortsProvider), + cert: Arc::new(LinuxCertProvider), + neigh: Arc::new(LinuxNeighProvider), + } +} + +struct LinuxSysProvider; +struct LinuxPortsProvider; +struct LinuxCertProvider; +struct LinuxNeighProvider; + +#[async_trait] +impl SysProvider for LinuxSysProvider { + async fn interfaces(&self) -> Result, PlatformError> { + let interfaces = NetworkInterface::show() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + Ok(interfaces.into_iter().map(map_interface).collect()) + } + + async fn routes(&self) -> Result, PlatformError> { + let mut routes = Vec::new(); + routes.extend(parse_ipv4_routes()?); + routes.extend(parse_ipv6_routes()?); + Ok(routes) + } + + async fn dns_config(&self) -> Result { + let contents = std::fs::read_to_string("/etc/resolv.conf") + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let cfg = resolv_conf::Config::parse(&contents) + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let servers = cfg + .nameservers + .iter() + .map(|ns| ns.to_string()) + .collect(); + let search_domains = cfg + .get_last_search_or_domain() + .map(|domain| domain.to_string()) + .collect(); + Ok(DnsConfigSnapshot { + servers, + search_domains, + }) + } +} + +fn map_interface(iface: NetworkInterface) -> NetInterface { + let addresses = iface + .addr + .into_iter() + .map(|addr| match addr { + Addr::V4(v4) => wtfnet_platform::NetAddress { + ip: v4.ip.to_string(), + prefix_len: prefix_from_v4_netmask(v4.netmask), + scope: None, + }, + Addr::V6(v6) => wtfnet_platform::NetAddress { + ip: v6.ip.to_string(), + prefix_len: prefix_from_v6_netmask(v6.netmask), + scope: None, + }, + }) + .collect(); + + NetInterface { + name: iface.name, + index: Some(iface.index), + is_up: None, + mtu: None, + mac: iface.mac_addr, + addresses, + } +} + +fn prefix_from_v4_netmask(netmask: Option) -> Option { + netmask.map(|mask| u32::from_be_bytes(mask.octets()).count_ones() as u8) +} + +fn prefix_from_v6_netmask(netmask: Option) -> Option { + netmask.map(|mask| u128::from_be_bytes(mask.octets()).count_ones() as u8) +} + +fn parse_ipv4_routes() -> Result, PlatformError> { + let contents = std::fs::read_to_string("/proc/net/route") + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut routes = Vec::new(); + for (idx, line) in contents.lines().enumerate() { + if idx == 0 { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 8 { + continue; + } + let iface = parts[0].to_string(); + let dest = parse_ipv4_hex(parts[1]); + let gateway = parse_ipv4_hex(parts[2]); + let mask = parse_ipv4_hex(parts[7]); + let metric = parts[6].parse::().ok(); + + let destination = match (dest, mask) { + (Some(dest), Some(mask)) => { + let prefix = u32::from(mask).count_ones(); + format!("{}/{}", dest, prefix) + } + (Some(dest), None) => dest.to_string(), + _ => continue, + }; + + routes.push(RouteEntry { + destination, + gateway: gateway.map(|ip| ip.to_string()).filter(|ip| ip != "0.0.0.0"), + interface: Some(iface), + metric, + }); + } + Ok(routes) +} + +fn parse_ipv6_routes() -> Result, PlatformError> { + let contents = std::fs::read_to_string("/proc/net/ipv6_route") + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut routes = Vec::new(); + for line in contents.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 10 { + continue; + } + + let dest = parse_ipv6_hex(parts[0]); + let dest_prefix = u32::from_str_radix(parts[1], 16).ok(); + let gateway = parse_ipv6_hex(parts[4]); + let metric = u32::from_str_radix(parts[5], 16).ok(); + let iface = parts[9].to_string(); + + let destination = match (dest, dest_prefix) { + (Some(dest), Some(prefix)) => format!("{}/{}", dest, prefix), + (Some(dest), None) => dest.to_string(), + _ => continue, + }; + + routes.push(RouteEntry { + destination, + gateway: gateway.map(|ip| ip.to_string()).filter(|ip| ip != "::"), + interface: Some(iface), + metric, + }); + } + Ok(routes) +} + +fn parse_ipv4_hex(value: &str) -> Option { + if value.len() != 8 { + return None; + } + let raw = u32::from_str_radix(value, 16).ok()?; + let bytes = raw.to_le_bytes(); + Some(std::net::Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])) +} + +fn parse_ipv6_hex(value: &str) -> Option { + if value.len() != 32 { + return None; + } + let mut bytes = [0u8; 16]; + for i in 0..16 { + let start = i * 2; + let chunk = &value[start..start + 2]; + bytes[i] = u8::from_str_radix(chunk, 16).ok()?; + } + Some(std::net::Ipv6Addr::from(bytes)) +} + +fn parse_linux_tcp(path: &str, is_v6: bool) -> Result, PlatformError> { + let contents = std::fs::read_to_string(path) + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut sockets = Vec::new(); + for (idx, line) in contents.lines().enumerate() { + if idx == 0 { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + continue; + } + let local = parts[1]; + let state = parts[3]; + if state != "0A" { + continue; + } + if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) { + sockets.push(ListenSocket { + proto: "tcp".to_string(), + local_addr, + state: Some("LISTEN".to_string()), + pid: None, + ppid: None, + process_name: None, + process_path: None, + owner: None, + }); + } + } + Ok(sockets) +} + +fn parse_linux_udp(path: &str, is_v6: bool) -> Result, PlatformError> { + let contents = std::fs::read_to_string(path) + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut sockets = Vec::new(); + for (idx, line) in contents.lines().enumerate() { + if idx == 0 { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let local = parts[1]; + if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) { + sockets.push(ListenSocket { + proto: "udp".to_string(), + local_addr, + state: None, + pid: None, + ppid: None, + process_name: None, + process_path: None, + owner: None, + }); + } + } + Ok(sockets) +} + +fn parse_proc_socket_addr(value: &str, is_v6: bool) -> Option { + let mut parts = value.split(':'); + let addr_hex = parts.next()?; + let port_hex = parts.next()?; + let port = u16::from_str_radix(port_hex, 16).ok()?; + if is_v6 { + let addr = parse_ipv6_hex(addr_hex)?; + Some(format!("[{}]:{}", addr, port)) + } else { + let addr = parse_ipv4_hex(addr_hex)?; + Some(format!("{}:{}", addr, port)) + } +} + +fn parse_linux_arp(contents: &str) -> Vec { + let mut neighbors = Vec::new(); + for (idx, line) in contents.lines().enumerate() { + if idx == 0 { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 6 { + continue; + } + let flags = parts[2]; + let state = match flags { + "0x2" => Some("reachable".to_string()), + _ => Some("stale".to_string()), + }; + + neighbors.push(NeighborEntry { + ip: parts[0].to_string(), + mac: Some(parts[3].to_string()).filter(|mac| mac != "00:00:00:00:00:00"), + interface: Some(parts[5].to_string()), + state, + }); + } + neighbors +} + +fn extract_port(value: &str) -> Option { + if let Some(pos) = value.rfind(':') { + return value[pos + 1..].parse::().ok(); + } + None +} + +fn load_native_roots(store: &str) -> Result, PlatformError> { + let certs = rustls_native_certs::load_native_certs() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut roots = Vec::new(); + + for cert in certs { + let der = cert.as_ref(); + let parsed = match x509_parser::parse_x509_certificate(der) { + Ok((_, cert)) => cert, + Err(_) => continue, + }; + + let subject = parsed.subject().to_string(); + let issuer = parsed.issuer().to_string(); + let not_before = parsed.validity().not_before.to_string(); + let not_after = parsed.validity().not_after.to_string(); + let serial = parsed.tbs_certificate.raw_serial_as_string(); + let sha1 = format_fingerprint(sha1::Sha1::digest(der).as_slice()); + let sha256 = format_fingerprint(sha2::Sha256::digest(der).as_slice()); + let (key_algorithm, key_size) = key_info(&parsed); + + roots.push(RootCert { + subject, + issuer, + not_before, + not_after, + serial_number: serial, + sha1, + sha256, + key_algorithm, + key_size, + store: Some(store.to_string()), + }); + } + + Ok(roots) +} + +fn key_info(cert: &x509_parser::certificate::X509Certificate<'_>) -> (String, Option) { + let algorithm = &cert.subject_pki.algorithm.algorithm; + let name = if algorithm == &OID_PKCS1_RSAENCRYPTION { + "RSA" + } else if algorithm == &OID_KEY_TYPE_EC_PUBLIC_KEY { + "EC" + } else if algorithm == &OID_KEY_TYPE_DSA { + "DSA" + } else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_256 { + "GOST2012-256" + } else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_512 { + "GOST2012-512" + } else { + "Unknown" + }; + + let key_size = cert + .subject_pki + .parsed() + .ok() + .map(|key| key.key_size() as u32) + .filter(|size| *size > 0); + + (name.to_string(), key_size) +} + +fn format_fingerprint(bytes: &[u8]) -> String { + let mut out = String::new(); + for (idx, byte) in bytes.iter().enumerate() { + if idx > 0 { + out.push(':'); + } + use std::fmt::Write; + let _ = write!(out, "{:02x}", byte); + } + out +} + +#[async_trait] +impl PortsProvider for LinuxPortsProvider { + async fn listening(&self) -> Result, PlatformError> { + let mut sockets = Vec::new(); + sockets.extend(parse_linux_tcp("/proc/net/tcp", false)?); + sockets.extend(parse_linux_tcp("/proc/net/tcp6", true)?); + sockets.extend(parse_linux_udp("/proc/net/udp", false)?); + sockets.extend(parse_linux_udp("/proc/net/udp6", true)?); + Ok(sockets) + } + + async fn who_owns(&self, port: u16) -> Result, PlatformError> { + let sockets = self.listening().await?; + Ok(sockets + .into_iter() + .filter(|socket| extract_port(&socket.local_addr) == Some(port)) + .collect()) + } +} + +#[async_trait] +impl CertProvider for LinuxCertProvider { + async fn trusted_roots(&self) -> Result, PlatformError> { + load_native_roots("linux") + } +} + +#[async_trait] +impl NeighProvider for LinuxNeighProvider { + async fn neighbors(&self) -> Result, PlatformError> { + let contents = std::fs::read_to_string("/proc/net/arp") + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + Ok(parse_linux_arp(&contents)) + } +} diff --git a/crates/wtfnet-platform-windows/Cargo.toml b/crates/wtfnet-platform-windows/Cargo.toml new file mode 100644 index 0000000..0a6c6a4 --- /dev/null +++ b/crates/wtfnet-platform-windows/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wtfnet-platform-windows" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +network-interface = "1" +regex = "1" +rustls-native-certs = "0.7" +sha1 = "0.10" +sha2 = "0.10" +time = { version = "0.3", features = ["formatting"] } +x509-parser = "0.16" +wtfnet-platform = { path = "../wtfnet-platform" } +wtfnet-core = { path = "../wtfnet-core" } diff --git a/crates/wtfnet-platform-windows/src/lib.rs b/crates/wtfnet-platform-windows/src/lib.rs new file mode 100644 index 0000000..8f1b058 --- /dev/null +++ b/crates/wtfnet-platform-windows/src/lib.rs @@ -0,0 +1,529 @@ +use async_trait::async_trait; +use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}; +use regex::Regex; +use sha2::Digest; +use x509_parser::oid_registry::{ + OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256, + OID_KEY_TYPE_GOST_R3410_2012_512, OID_PKCS1_RSAENCRYPTION, +}; +use std::sync::Arc; +use wtfnet_core::ErrorCode; +use wtfnet_platform::{ + CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface, + Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider, +}; + +pub fn platform() -> Platform { + Platform { + sys: Arc::new(WindowsSysProvider), + ports: Arc::new(WindowsPortsProvider), + cert: Arc::new(WindowsCertProvider), + neigh: Arc::new(WindowsNeighProvider), + } +} + +struct WindowsSysProvider; +struct WindowsPortsProvider; +struct WindowsCertProvider; +struct WindowsNeighProvider; + +#[async_trait] +impl SysProvider for WindowsSysProvider { + async fn interfaces(&self) -> Result, PlatformError> { + let interfaces = NetworkInterface::show() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + Ok(interfaces.into_iter().map(map_interface).collect()) + } + + async fn routes(&self) -> Result, PlatformError> { + let interfaces = NetworkInterface::show() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + parse_windows_routes(&interfaces) + } + + async fn dns_config(&self) -> Result { + let output = std::process::Command::new("ipconfig") + .arg("/all") + .output() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + if !output.status.success() { + return Err(PlatformError::new( + ErrorCode::IoError, + "ipconfig /all failed", + )); + } + + let text = String::from_utf8_lossy(&output.stdout); + Ok(parse_ipconfig_dns(&text)) + } +} + +fn map_interface(iface: NetworkInterface) -> NetInterface { + let addresses = iface + .addr + .into_iter() + .map(|addr| match addr { + Addr::V4(v4) => wtfnet_platform::NetAddress { + ip: v4.ip.to_string(), + prefix_len: prefix_from_v4_netmask(v4.netmask), + scope: None, + }, + Addr::V6(v6) => wtfnet_platform::NetAddress { + ip: v6.ip.to_string(), + prefix_len: prefix_from_v6_netmask(v6.netmask), + scope: None, + }, + }) + .collect(); + + NetInterface { + name: iface.name, + index: Some(iface.index), + is_up: None, + mtu: None, + mac: iface.mac_addr, + addresses, + } +} + +fn prefix_from_v4_netmask(netmask: Option) -> Option { + netmask.map(|mask| u32::from_be_bytes(mask.octets()).count_ones() as u8) +} + +fn prefix_from_v6_netmask(netmask: Option) -> Option { + netmask.map(|mask| u128::from_be_bytes(mask.octets()).count_ones() as u8) +} + +fn parse_windows_routes( + interfaces: &[NetworkInterface], +) -> Result, PlatformError> { + let output = std::process::Command::new("route") + .arg("print") + .output() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + if !output.status.success() { + return Err(PlatformError::new( + ErrorCode::IoError, + "route print failed", + )); + } + + let text = String::from_utf8_lossy(&output.stdout); + let mut routes = Vec::new(); + routes.extend(parse_windows_ipv4_routes(&text, interfaces)); + routes.extend(parse_windows_ipv6_routes(&text, interfaces)); + Ok(routes) +} + +fn parse_windows_ipv4_routes( + text: &str, + interfaces: &[NetworkInterface], +) -> Vec { + let mut routes = Vec::new(); + let mut in_ipv4 = false; + let mut in_active = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("IPv4 Route Table") { + in_ipv4 = true; + in_active = false; + continue; + } + if trimmed.starts_with("IPv6 Route Table") { + in_ipv4 = false; + in_active = false; + continue; + } + if !in_ipv4 { + continue; + } + if trimmed.starts_with("Active Routes:") { + in_active = true; + continue; + } + if !in_active { + continue; + } + if trimmed.starts_with("====") || trimmed.is_empty() || trimmed.starts_with("Network") { + continue; + } + + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 5 { + continue; + } + let destination = parts[0]; + let netmask = parts[1]; + let gateway = parts[2]; + let interface_addr = parts[3]; + let metric = parts[4].parse::().ok(); + + let prefix = parse_ipv4_prefix(netmask); + let destination = if let Some(prefix) = prefix { + format!("{}/{}", destination, prefix) + } else { + destination.to_string() + }; + let iface = interface_name_from_ip(interfaces, interface_addr); + + routes.push(RouteEntry { + destination, + gateway: Some(gateway.to_string()).filter(|g| g != "0.0.0.0"), + interface: iface, + metric, + }); + } + routes +} + +fn parse_windows_ipv6_routes( + text: &str, + interfaces: &[NetworkInterface], +) -> Vec { + let mut routes = Vec::new(); + let mut in_ipv6 = false; + let mut in_active = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("IPv6 Route Table") { + in_ipv6 = true; + in_active = false; + continue; + } + if trimmed.starts_with("====") && in_ipv6 && in_active { + break; + } + if !in_ipv6 { + continue; + } + if trimmed.starts_with("Active Routes:") { + in_active = true; + continue; + } + if !in_active { + continue; + } + if trimmed.is_empty() || trimmed.starts_with("If") { + continue; + } + + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 4 { + continue; + } + let iface_index = parts[0]; + let metric = parts[1].parse::().ok(); + let destination = parts[2].to_string(); + let gateway = parts[3].to_string(); + let iface = interface_name_from_index(interfaces, iface_index); + + routes.push(RouteEntry { + destination, + gateway: Some(gateway).filter(|g| g != "On-link"), + interface: iface, + metric, + }); + } + routes +} + +fn parse_ipv4_prefix(netmask: &str) -> Option { + let mask: std::net::Ipv4Addr = netmask.parse().ok()?; + Some(u32::from_be_bytes(mask.octets()).count_ones()) +} + +fn interface_name_from_ip( + interfaces: &[NetworkInterface], + addr: &str, +) -> Option { + let parsed: std::net::IpAddr = addr.parse().ok()?; + for iface in interfaces { + if iface.addr.iter().any(|entry| entry.ip() == parsed) { + return Some(iface.name.clone()); + } + } + None +} + +fn interface_name_from_index( + interfaces: &[NetworkInterface], + index: &str, +) -> Option { + let index = index.parse::().ok()?; + interfaces + .iter() + .find(|iface| iface.index == index) + .map(|iface| iface.name.clone()) +} + +fn parse_ipconfig_dns(text: &str) -> DnsConfigSnapshot { + let mut servers = Vec::new(); + let mut search_domains = Vec::new(); + let dns_server_re = Regex::new(r"^DNS Servers?\s*[:.]\s*(.+)$").unwrap(); + let dns_suffix_re = Regex::new(r"^DNS Suffix Search List\.?\s*[:.]\s*(.+)$").unwrap(); + + let mut in_dns_servers = false; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + in_dns_servers = false; + continue; + } + + if let Some(caps) = dns_server_re.captures(trimmed) { + if let Some(value) = caps.get(1) { + servers.push(value.as_str().to_string()); + } + in_dns_servers = true; + continue; + } + + if in_dns_servers && !trimmed.contains(':') { + servers.push(trimmed.to_string()); + continue; + } + + if let Some(caps) = dns_suffix_re.captures(trimmed) { + if let Some(value) = caps.get(1) { + let list = value.as_str(); + for entry in list.split_whitespace() { + search_domains.push(entry.to_string()); + } + } + continue; + } + } + + DnsConfigSnapshot { + servers, + search_domains, + } +} + +fn parse_windows_listeners() -> Result, PlatformError> { + let output = std::process::Command::new("netstat") + .arg("-ano") + .output() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + if !output.status.success() { + return Err(PlatformError::new(ErrorCode::IoError, "netstat -ano failed")); + } + + let text = String::from_utf8_lossy(&output.stdout); + let mut sockets = Vec::new(); + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("TCP") { + if let Some(socket) = parse_netstat_tcp_line(trimmed) { + sockets.push(socket); + } + } else if trimmed.starts_with("UDP") { + if let Some(socket) = parse_netstat_udp_line(trimmed) { + sockets.push(socket); + } + } + } + + Ok(sockets) +} + +fn parse_netstat_tcp_line(line: &str) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 5 { + return None; + } + let local = parts[1]; + let state = parts[3]; + let pid = parts[4].parse::().ok(); + + if state != "LISTENING" { + return None; + } + + Some(ListenSocket { + proto: "tcp".to_string(), + local_addr: local.to_string(), + state: Some(state.to_string()), + pid, + ppid: None, + process_name: None, + process_path: None, + owner: None, + }) +} + +fn parse_netstat_udp_line(line: &str) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + return None; + } + let local = parts[1]; + let pid = parts[3].parse::().ok(); + + Some(ListenSocket { + proto: "udp".to_string(), + local_addr: local.to_string(), + state: None, + pid, + ppid: None, + process_name: None, + process_path: None, + owner: None, + }) +} + +fn parse_arp_output(text: &str) -> Vec { + let mut neighbors = Vec::new(); + let mut current_iface = None; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("Interface:") { + current_iface = trimmed + .split_whitespace() + .nth(1) + .map(|value| value.to_string()); + continue; + } + if trimmed.starts_with("Internet Address") || trimmed.is_empty() { + continue; + } + + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + + neighbors.push(NeighborEntry { + ip: parts[0].to_string(), + mac: Some(parts[1].to_string()), + interface: current_iface.clone(), + state: Some(parts[2].to_string()), + }); + } + + neighbors +} + +fn extract_port(value: &str) -> Option { + if let Some(pos) = value.rfind(':') { + return value[pos + 1..].parse::().ok(); + } + None +} + +fn load_native_roots(store: &str) -> Result, PlatformError> { + let certs = rustls_native_certs::load_native_certs() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + let mut roots = Vec::new(); + + for cert in certs { + let der = cert.as_ref(); + let parsed = match x509_parser::parse_x509_certificate(der) { + Ok((_, cert)) => cert, + Err(_) => continue, + }; + + let subject = parsed.subject().to_string(); + let issuer = parsed.issuer().to_string(); + let not_before = parsed.validity().not_before.to_string(); + let not_after = parsed.validity().not_after.to_string(); + let serial = parsed.tbs_certificate.raw_serial_as_string(); + let sha1 = format_fingerprint(sha1::Sha1::digest(der).as_slice()); + let sha256 = format_fingerprint(sha2::Sha256::digest(der).as_slice()); + let (key_algorithm, key_size) = key_info(&parsed); + + roots.push(RootCert { + subject, + issuer, + not_before, + not_after, + serial_number: serial, + sha1, + sha256, + key_algorithm, + key_size, + store: Some(store.to_string()), + }); + } + + Ok(roots) +} + +fn key_info(cert: &x509_parser::certificate::X509Certificate<'_>) -> (String, Option) { + let algorithm = &cert.subject_pki.algorithm.algorithm; + let name = if algorithm == &OID_PKCS1_RSAENCRYPTION { + "RSA" + } else if algorithm == &OID_KEY_TYPE_EC_PUBLIC_KEY { + "EC" + } else if algorithm == &OID_KEY_TYPE_DSA { + "DSA" + } else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_256 { + "GOST2012-256" + } else if algorithm == &OID_KEY_TYPE_GOST_R3410_2012_512 { + "GOST2012-512" + } else { + "Unknown" + }; + + let key_size = cert + .subject_pki + .parsed() + .ok() + .map(|key| key.key_size() as u32) + .filter(|size| *size > 0); + + (name.to_string(), key_size) +} + +fn format_fingerprint(bytes: &[u8]) -> String { + let mut out = String::new(); + for (idx, byte) in bytes.iter().enumerate() { + if idx > 0 { + out.push(':'); + } + use std::fmt::Write; + let _ = write!(out, "{:02x}", byte); + } + out +} + +#[async_trait] +impl PortsProvider for WindowsPortsProvider { + async fn listening(&self) -> Result, PlatformError> { + let sockets = parse_windows_listeners()?; + Ok(sockets) + } + + async fn who_owns(&self, port: u16) -> Result, PlatformError> { + let sockets = parse_windows_listeners()?; + Ok(sockets + .into_iter() + .filter(|socket| extract_port(&socket.local_addr) == Some(port)) + .collect()) + } +} + +#[async_trait] +impl CertProvider for WindowsCertProvider { + async fn trusted_roots(&self) -> Result, PlatformError> { + load_native_roots("windows") + } +} + +#[async_trait] +impl NeighProvider for WindowsNeighProvider { + async fn neighbors(&self) -> Result, PlatformError> { + let output = std::process::Command::new("arp") + .arg("-a") + .output() + .map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?; + if !output.status.success() { + return Err(PlatformError::new(ErrorCode::IoError, "arp -a failed")); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(parse_arp_output(&text)) + } +} diff --git a/crates/wtfnet-platform/Cargo.toml b/crates/wtfnet-platform/Cargo.toml new file mode 100644 index 0000000..a64da94 --- /dev/null +++ b/crates/wtfnet-platform/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "wtfnet-platform" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +wtfnet-core = { path = "../wtfnet-core" } diff --git a/crates/wtfnet-platform/src/lib.rs b/crates/wtfnet-platform/src/lib.rs new file mode 100644 index 0000000..0c5241e --- /dev/null +++ b/crates/wtfnet-platform/src/lib.rs @@ -0,0 +1,118 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use wtfnet_core::ErrorCode; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetInterface { + pub name: String, + pub index: Option, + pub is_up: Option, + pub mtu: Option, + pub mac: Option, + pub addresses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetAddress { + pub ip: String, + pub prefix_len: Option, + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsConfigSnapshot { + pub servers: Vec, + pub search_domains: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteEntry { + pub destination: String, + pub gateway: Option, + pub interface: Option, + pub metric: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListenSocket { + pub proto: String, + pub local_addr: String, + pub state: Option, + pub pid: Option, + pub ppid: Option, + pub process_name: Option, + pub process_path: Option, + pub owner: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RootCert { + pub subject: String, + pub issuer: String, + pub not_before: String, + pub not_after: String, + pub serial_number: String, + pub sha1: String, + pub sha256: String, + pub key_algorithm: String, + pub key_size: Option, + pub store: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NeighborEntry { + pub ip: String, + pub mac: Option, + pub interface: Option, + pub state: Option, +} + +#[derive(Debug, Clone)] +pub struct PlatformError { + pub code: ErrorCode, + pub message: String, +} + +impl PlatformError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn not_supported(message: impl Into) -> Self { + Self::new(ErrorCode::NotSupported, message) + } +} + +#[async_trait] +pub trait SysProvider: Send + Sync { + async fn interfaces(&self) -> Result, PlatformError>; + async fn routes(&self) -> Result, PlatformError>; + async fn dns_config(&self) -> Result; +} + +#[async_trait] +pub trait PortsProvider: Send + Sync { + async fn listening(&self) -> Result, PlatformError>; + async fn who_owns(&self, port: u16) -> Result, PlatformError>; +} + +#[async_trait] +pub trait CertProvider: Send + Sync { + async fn trusted_roots(&self) -> Result, PlatformError>; +} + +#[async_trait] +pub trait NeighProvider: Send + Sync { + async fn neighbors(&self) -> Result, PlatformError>; +} + +pub struct Platform { + pub sys: Arc, + pub ports: Arc, + pub cert: Arc, + pub neigh: Arc, +} diff --git a/docs/implementation_notes.md b/docs/implementation_notes.md new file mode 100644 index 0000000..f06c918 --- /dev/null +++ b/docs/implementation_notes.md @@ -0,0 +1,660 @@ +# IMPLEMENTATION.md + +## 0) Project identity + +* **Project name:** WTFnet +* **Binary name:** `wtfn` +* **Tagline:** *“What the f*ck is my networking doing?”* + +Target OS (first-class): + +* ✅ Linux (Debian/Ubuntu) +* ✅ Windows 10/11 + Windows Server + +--- + +## 1) Workspace layout (Cargo) + +Recommended workspace structure: keep the CLI thin, keep logic reusable, and isolate OS-specific code. + +``` +wtfnet/ +├─ Cargo.toml +├─ crates/ +│ ├─ wtfnet-cli/ # bin: clap parsing + output formatting +│ ├─ wtfnet-core/ # shared types: errors, output schema, config, logging init +│ ├─ wtfnet-platform/ # platform traits + OS dispatch +│ ├─ wtfnet-platform-linux/ # Linux implementations (netlink/procfs) +│ ├─ wtfnet-platform-windows/ # Windows implementations (Win32 APIs) +│ ├─ wtfnet-geoip/ # GeoLite2 Country+ASN mmdb +│ ├─ wtfnet-probe/ # ping/tcping/trace + geoip enrichment +│ ├─ wtfnet-dns/ # query/detect/watch +│ ├─ wtfnet-http/ # HTTP/1.1, HTTP/2, optional HTTP/3 +│ ├─ wtfnet-tls/ # TLS handshake/cert/verify/alpn +│ ├─ wtfnet-discover/ # mdns/ssdp discovery +│ └─ wtfnet-diag/ # diag bundle orchestrator +└─ docs/ # design/usage docs (optional) +``` + +### Root `Cargo.toml` (workspace) + +```toml +[workspace] +resolver = "2" +members = [ + "crates/wtfnet-cli", + "crates/wtfnet-core", + "crates/wtfnet-platform", + "crates/wtfnet-platform-linux", + "crates/wtfnet-platform-windows", + "crates/wtfnet-geoip", + "crates/wtfnet-probe", + "crates/wtfnet-dns", + "crates/wtfnet-http", + "crates/wtfnet-tls", + "crates/wtfnet-discover", + "crates/wtfnet-diag", +] + +[workspace.package] +edition = "2021" +license = "MIT" +``` + +--- + +## 2) Subcrate responsibilities + +### 2.1 `wtfnet-cli` (binary) + +**Purpose** + +* Defines `clap` subcommands & global flags (`--json`, `--pretty`, logging flags, timeouts) +* Calls into library crates +* Converts results → human tables OR JSON + +**Strict rule** + +* No OS-specific code here. +* No heavy logic here. + +--- + +### 2.2 `wtfnet-core` (shared kernel) + +**Owns** + +* Output schema wrapper (`meta`, `command`, `data`, `warnings`, `errors`) +* Error taxonomy + exit code mapping +* Config loading (flags/env/config.json) +* Logging initialization (`tracing`) +* Formatting helpers (durations, bytes, IP formatting) +* “human table renderer” utilities (optional) + +--- + +### 2.3 `wtfnet-platform` (traits + dispatch) + +**Purpose** +Expose a stable interface for sysadmin-ish data: + +* sys: interfaces/IP/route/DNS config snapshot +* ports: listening sockets + PID/process info +* cert roots: enumerate trusted roots +* neigh: ARP/NDP cache + +**Pattern** + +* Define Rust traits like `SysProvider`, `PortsProvider`, `CertProvider`, `NeighProvider` +* Provide a `Platform` object that selects implementation by target OS + +--- + +### 2.4 `wtfnet-platform-linux` + +Linux implementations: + +* netlink-based route/IP/neigh: use `rtnetlink` ([Docs.rs][1]) +* ports/process mapping: + + * either use `listeners` (cross-platform) or Linux procfs parsing +* DNS snapshot: parse `/etc/resolv.conf` + detect `systemd-resolved` best-effort + +--- + +### 2.5 `wtfnet-platform-windows` + +Windows implementations: + +* use `windows` crate / Win32 APIs + + * interfaces/routes/neigh via IP Helper APIs + * ports/process via Windows socket/process APIs + * trusted roots via Windows cert store APIs + +--- + +### 2.6 `wtfnet-geoip` + +* Loads **local** GeoLite2 Country + ASN mmdb +* Provides one `GeoIpService` with `lookup(ip)` returning: + + * country + ISO + * ASN + org + +--- + +### 2.7 `wtfnet-probe` + +Implements: + +* ping (v4/v6) +* tcping (connect latency) +* trace (traceroute-like) +* optional `--geoip` enrichment + +For ping, you can start with `surge-ping` ([Crates][2]) or `ping-async` ([Docs.rs][3]) (both async-oriented). + +--- + +### 2.8 `wtfnet-dns` + +Implements: + +* `dns query` (dig-like) +* `dns detect` (poisoning compare) +* `dns watch` (passive, best-effort) + +Use **Hickory DNS** (Trust-DNS rebrand) ([Docs.rs][4]) for a solid resolver/client base. + +--- + +### 2.9 `wtfnet-http` + +Implements: + +* `http head|get` +* HTTP/2 support is required (via `reqwest`/`hyper`) +* HTTP/3 optional behind feature flag: + + * `h3` + `h3-quinn` + `quinn` ([GitHub][5]) + +--- + +### 2.10 `wtfnet-tls` + +Implements: + +* `tls handshake|cert|verify|alpn` +* Use `rustls` for handshake parsing +* For system trust verification: + + * `rustls-native-certs` for loading OS roots ([Crates][6]) + * optionally `rustls-platform-verifier` for “verify like the OS” behavior ([GitHub][7]) + +--- + +### 2.11 `wtfnet-discover` + +Implements bounded local discovery: + +* mDNS service discovery: `mdns-sd` ([Crates][8]) +* SSDP discovery: `ssdp-client` ([Crates][9]) + +--- + +### 2.12 `wtfnet-diag` + +Orchestrates: + +* sys snapshot +* routes +* ports listen +* neigh list +* optional quick checks: DNS detect, TLS handshake, HTTP head +* bundle export to zip + +--- + +## 3) Dependency map (crate graph) + +**High-level dependency graph:** + +``` +wtfnet-cli + ├─ wtfnet-core + ├─ wtfnet-platform + │ ├─ wtfnet-platform-linux (cfg linux) + │ └─ wtfnet-platform-windows (cfg windows) + ├─ wtfnet-geoip + ├─ wtfnet-probe + ├─ wtfnet-dns + ├─ wtfnet-http + ├─ wtfnet-tls + ├─ wtfnet-discover + └─ wtfnet-diag +``` + +**Rule of thumb** + +* `wtfnet-core` should not depend on OS crates. +* feature crates should depend on `wtfnet-core` + minimal extras. + +--- + +## 4) Shared libraries / crate dependencies (recommended) + +### 4.1 Core stack (almost everywhere) + +* **CLI**: `clap` (+ `clap_complete` optional) +* **Async runtime**: `tokio` +* **Serialization**: `serde`, `serde_json` +* **Errors**: `thiserror` (libs), `anyhow` (CLI glue) +* **URLs**: `url` +* **Time**: `time` (or `chrono`), `humantime` +* **Tables (human output)**: `tabled` or `comfy-table` +* **Zip bundles**: `zip` (diag bundle) + +### 4.2 Logging / tracing + +Use `tracing` + `tracing-subscriber`: + +* `tracing` +* `tracing-subscriber` (fmt + env filter) +* optional: `tracing-appender` (log file) + +**Why this choice** + +* structured logs +* spans for timing each probe stage +* easy JSON logs to stderr + +--- + +### 4.3 Sys/platform related crates + +* Interfaces: `network-interface` ([Crates][10]) (good for standardized interface listing) +* Linux netlink: `rtnetlink` ([Docs.rs][1]) +* Ports: + + * `listeners` (cross-platform “listening process mapping”) ([GitHub][11]) + * fallback: `netstat2` (cross-platform sockets info) ([Docs.rs][12]) +* Windows APIs: `windows` crate + +> Suggestion: start with `listeners` for `ports listen/who` (it directly targets your use-case). ([GitHub][11]) + +--- + +### 4.4 GeoIP + +* `maxminddb` (read GeoLite2 mmdb) + +--- + +### 4.5 DNS + +* `hickory-resolver` / Hickory ecosystem ([Docs.rs][4]) + +Passive `dns watch` (optional): + +* `pcap` or `pnet` (feature-gated) + +--- + +### 4.6 HTTP / TLS + +HTTP: + +* `reqwest` (easy HTTP/1.1 + HTTP/2) +* or `hyper` if you want lower-level control + +HTTP/3 (optional feature): + +* `h3` + `h3-quinn` + `quinn` ([GitHub][5]) + +TLS: + +* `rustls` +* `rustls-native-certs` ([Crates][6]) +* `rustls-platform-verifier` (optional) ([GitHub][7]) + +--- + +### 4.7 Probing + +* ping: + + * `surge-ping` ([Crates][2]) + * or `ping-async` ([Docs.rs][3]) +* tcping: plain `tokio::net::TcpStream::connect` + `timeout` +* trace: start simple (UDP/ICMP-based approaches are trickier & permission-sensitive) + +--- + +### 4.8 Discovery + +* mDNS: `mdns-sd` ([Crates][8]) +* SSDP: `ssdp-client` ([Crates][9]) + +--- + +## 5) Feature flags & compile-time options + +In root design, define optional features to avoid heavy deps by default. + +Suggested features: + +* `http3` → enables `h3`, `h3-quinn`, `quinn` +* `pcap` → enables passive DNS watch with packet capture +* `discover` → enable mdns/ssdp features (if you want a “minimal build”) + +Example snippet (in `wtfnet-http/Cargo.toml`): + +```toml +[features] +default = ["http2"] +http2 = [] +http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn"] + +[dependencies] +reqwest = { version = "*", features = ["rustls-tls", "http2"] } +h3 = { version = "*", optional = true } +h3-quinn = { version = "*", optional = true } +quinn = { version = "*", optional = true } +``` + +--- + +## 6) Core data model + output schema (do this early) + +### 6.1 Unified JSON wrapper (recommended) + +Every command returns: + +```rust +pub struct CommandEnvelope { + pub meta: Meta, + pub command: CommandInfo, + pub data: T, + pub warnings: Vec, + pub errors: Vec, +} +``` + +Key principles: + +* stable keys +* additive schema evolution +* logs never pollute stdout JSON + +### 6.2 Exit code mapping + +Put this in `wtfnet-core` and make CLI enforce it: + +```rust +pub enum ExitKind { + Ok, + Failed, + Usage, + Permission, + Timeout, + Partial, +} +``` + +--- + +## 7) Logging design (`tracing`) + +### 7.1 Init once in `main()` + +`wtfnet-core::logging::init(...)` should: + +* respect CLI flags + env vars +* print to stderr +* support `text|json` formats + +### 7.2 Use spans for “timing breakdown” + +Example: HTTP diagnostics + +```rust +#[tracing::instrument(skip(client))] +async fn http_head(client: &Client, url: &Url) -> Result { + let _span = tracing::info_span!("http_head", %url).entered(); + // measure dns/connect/tls/ttfb where possible + Ok(report) +} +``` + +--- + +## 8) Platform abstraction pattern (Rust traits) + +In `wtfnet-platform`: + +```rust +pub trait SysProvider { + async fn interfaces(&self) -> Result, PlatformError>; + async fn routes(&self) -> Result, PlatformError>; + async fn dns_config(&self) -> Result; +} + +pub trait PortsProvider { + async fn listening(&self) -> Result, PlatformError>; + async fn who_owns(&self, port: u16) -> Result, PlatformError>; +} + +pub trait CertProvider { + async fn trusted_roots(&self) -> Result, PlatformError>; +} + +pub trait NeighProvider { + async fn neighbors(&self) -> Result, PlatformError>; +} +``` + +Then provide: + +```rust +pub struct Platform { + pub sys: Arc, + pub ports: Arc, + pub cert: Arc, + pub neigh: Arc, +} +``` + +OS dispatch: + +* `cfg(target_os = "linux")` → `wtfnet-platform-linux` +* `cfg(target_os = "windows")` → `wtfnet-platform-windows` + +--- + +## 9) Implementation notes per command area + +### 9.1 sys (interfaces/routes/dns) + +**Interfaces** + +* start with `network-interface` ([Crates][10]) for a normalized list +* if you need MTU / gateway details not exposed, add platform-native calls + +**Linux routes/neigh** + +* `rtnetlink` can manage links/addresses/ARP/route tables ([Docs.rs][1]) + +**Windows routes/neigh** + +* use Win32 IP helper APIs via `windows` crate + +--- + +### 9.2 ports (listen/who) + +Best path: + +* use `listeners` crate for cross-platform “listening sockets → process” mapping ([GitHub][11]) + +Fallback: + +* `netstat2` provides cross-platform socket information ([Docs.rs][12]) + +--- + +### 9.3 cert roots + +Use: + +* `rustls-native-certs` to load roots from OS trust store ([Crates][6]) + +Filtering: + +* by subject substring +* by fingerprint sha256 +* expired/not-yet-valid + +Diff: + +* export stable JSON +* compare by fingerprint (sha256) + +--- + +### 9.4 geoip (local mmdb) + +Use: + +* `maxminddb` + Expose: +* `GeoIpService::new(country_path, asn_path)` +* `lookup(IpAddr) -> GeoInfo` + +--- + +### 9.5 dns (query/detect/watch) + +Resolver: + +* Hickory DNS ecosystem ([Docs.rs][4]) + +Detect logic (keep deterministic): + +* Query multiple resolvers +* Normalize answers (A/AAAA/CNAME) +* “suspicious” if major divergence, NXDOMAIN mismatch, private IP injection patterns + +Watch: + +* feature-gate packet capture +* document privilege needs clearly + +--- + +### 9.6 http (head/get) + +HTTP/2: + +* `reqwest` makes this trivial + +HTTP/3 (optional): + +* use `h3` + `h3-quinn` + `quinn` ([GitHub][5]) + Keep it behind `--http3` and fallback to HTTP/2 when UDP is blocked. + +Timing breakdown: + +* you’ll get total time easily +* fine-grained DNS/connect/TLS timing may need deeper client hooks (ok to be best-effort) + +--- + +### 9.7 tls (handshake/cert/verify) + +Handshake: + +* use `rustls` to connect and extract: + + * version, cipher suite, ALPN + * peer cert chain + +Verify: + +* use `rustls-platform-verifier` if you want OS-like verification ([GitHub][7]) +* otherwise load roots via `rustls-native-certs` ([Crates][6]) and verify with webpki + +--- + +### 9.8 neigh (ARP/NDP) + +Linux: + +* `rtnetlink` includes ARP/neighbor operations ([Docs.rs][1]) + +Windows: + +* IP Helper API provides neighbor cache info (implementation detail) + +--- + +### 9.9 discover (mDNS + SSDP) + +mDNS: + +* `mdns-sd` ([Crates][8]) + Bounded `--duration`, no spam. + +SSDP: + +* `ssdp-client` ([Crates][9]) + Send M-SEARCH, collect responses, parse location/server/usn. + +--- + +## 10) Testing strategy + +### 10.1 Unit tests (fast, pure) + +* subnet math (`calc`) +* parsing/formatting +* DNS comparison heuristics (test vectors) + +### 10.2 Snapshot tests (JSON stability) + +Use `insta`: + +* ensure `--json` schema doesn’t drift accidentally + +### 10.3 Integration tests (CI) + +* run non-privileged commands only: + + * `sys ifaces` + * `calc subnet` + * `dns query example.com A` + * `http head https://example.com` + +--- + +## 11) Coding conventions + +* Every command handler returns a structured `CommandEnvelope` +* Never `println!` from libs; return data → CLI prints it +* `--json` must be clean stdout (no logs mixed in) +* Use timeouts everywhere in probe/dns/http/tls +* Prefer “best-effort + warnings” over hard failure + +--- + +## 12) Minimal “first coding milestone” plan + +1. `wtfnet-core`: envelope + logging init + exit mapping +2. `wtfnet-cli`: clap skeleton + `sys ifaces` +3. `wtfnet-geoip`: load mmdb + `geoip ` +4. `ports listen/who` using `listeners` ([GitHub][11]) +5. `dns query` via Hickory ([Docs.rs][4]) +6. `http head` and `tls handshake` basic success path +7. `diag` orchestration + zip bundle + +--- diff --git a/docs/requirement_docs.md b/docs/requirement_docs.md new file mode 100644 index 0000000..2ce67fc --- /dev/null +++ b/docs/requirement_docs.md @@ -0,0 +1,938 @@ + +--- + +# README.md + +````markdown +# WTFnet + +**WTFnet** is a pure CLI toolbox for diagnosing network problems on **Linux (Debian/Ubuntu)** and **Windows**. + +> _"What the f\*ck is my networking doing?"_ + +It combines system network inspection, port/process visibility, DNS poisoning checks, HTTP/TLS diagnostics, GeoIP enrichment, ARP/NDP neighbor tables, and lightweight discovery tools — all in one consistent CLI. + +## Goals + +- **Pure CLI** (no REPL / no TUI) +- **Fast + scriptable** output (`--json` supported everywhere) +- **First-class support:** Linux (Debian/Ubuntu), Windows +- **Rust implementation** +- **Graceful degradation** when OS APIs differ or privileges are missing + +## Quickstart + +### Show interfaces & IPs +```bash +wtfnet sys ifaces +wtfnet sys ip --all +wtfnet sys route +```` + +### Find which process owns port 443 + +```bash +wtfnet ports who 443 +wtfnet ports listen --tcp +``` + +### DNS poisoning detection (multi-resolver compare) + +```bash +wtfnet dns detect example.com +wtfnet dns detect example.com --servers 1.1.1.1,8.8.8.8,9.9.9.9 --repeat 3 +``` + +### HTTP + TLS diagnostics + +```bash +wtfnet http head https://example.com --show-headers --http2-only +wtfnet tls handshake example.com:443 --show-chain +wtfnet tls verify example.com:443 +``` + +### GeoIP lookup (local GeoLite2 DBs) + +```bash +wtfnet geoip 8.8.8.8 +wtfnet probe tcping example.com:443 --geoip +``` + +### Neighbor table (ARP / NDP) + +```bash +wtfnet neigh list +wtfnet neigh list --ipv4 +wtfnet neigh list --ipv6 +``` + +### Generate a diagnostic report bundle + +```bash +wtfnet diag --bundle out.zip +``` + +## Output modes + +* Default: readable tables / summaries +* Machine-readable: `--json` +* Pretty JSON: `--json --pretty` +* Logs go to **stderr** (JSON output stays clean) + +Example: + +```bash +wtfnet sys ip --json --pretty > ip.json +``` + +## Logging + +```bash +wtfnet --log-level debug sys route +wtfnet --log-format json sys dns +wtfnet --log-file wtfnet.log diag +``` + +Environment variables: + +* `NETTOOL_LOG_LEVEL` +* `NETTOOL_LOG_FORMAT` +* `NETTOOL_LOG_FILE` + +## License + +TBD + +```` + +--- + +# REQUIREMENTS.md + +```markdown +# WTFnet — Requirements (v0.2) + +## 1. Product overview + +### 1.1 Purpose +WTFnet is a **single-executable CLI toolbox** for diagnosing network problems: +- inspect IP/interface/route/DNS +- probe connectivity (ICMP/TCP/path) +- detect **DNS poisoning** +- inspect **trusted root certificates** +- map **listening ports → processes** +- run **HTTP/TLS diagnostics** +- view **ARP/NDP neighbor cache** +- perform lightweight **service discovery** +- export a consistent report bundle for incident response + +### 1.2 Design goals +- Pure CLI: no REPL / no TUI +- Script-friendly: stable output & exit codes +- First-class support: **Linux (Debian/Ubuntu)** + **Windows** +- Rust implementation +- Works without admin where possible; warns clearly when privileges required + +--- + +## 2. Platform support + +### 2.1 First-class OS targets +- Linux: Debian / Ubuntu +- Windows 10/11 + Windows Server + +### 2.2 Best-effort targets +- macOS (not required for v0.x) + +--- + +## 3. CLI UX requirements + +### 3.1 Global flags +All commands MUST support: +- `--json` (machine output) +- `--pretty` (pretty JSON) +- `--no-color` +- `--quiet` +- `-v`, `-vv` (verbosity) +- logging flags (see §4) + +### 3.2 Exit codes +- `0`: success +- `1`: generic failure +- `2`: invalid args +- `3`: insufficient permissions +- `4`: timeout/unreachable category +- `5`: partial success (some checks failed; still produced results) + +--- + +## 4. Logging requirements + +### 4.1 Goals +- Debug WTFnet itself in production environments +- Preserve clean stdout for piping / JSON mode + +### 4.2 Behavior +- Logs MUST go to **stderr** +- Command output MUST go to **stdout** +- JSON output must remain valid even with debug logs enabled + +### 4.3 Controls +Flags: +- `--log-level ` (default `info`) +- `--log-format ` (default `text`) +- `--log-file ` (optional; write logs there; may also tee stderr) + +Env vars: +- `NETTOOL_LOG_LEVEL` +- `NETTOOL_LOG_FORMAT` +- `NETTOOL_LOG_FILE` + +### 4.4 Sensitive logging policy +- Do NOT log secrets by default (tokens, cookies, passwords) +- HTTP response bodies are hidden by default +- Provide explicit `--show-secrets` / `--show-body` gates where relevant + +--- + +## 5. Functional requirements + +### 5.1 System inspection (`sys`) +#### 5.1.1 Interfaces & addresses +Must provide: +- interface name/index, state, MTU, MAC +- IPv4/IPv6 addresses with prefix + scope +- DNS servers + search domains (best-effort) +- default gateway mapping (best-effort) + +Commands: +- `wtfnet sys ifaces` +- `wtfnet sys ip --all` +- `wtfnet sys route` +- `wtfnet sys dns` + +#### 5.1.2 Routing table +Outputs: +- destination/prefix, gateway/next hop, interface, metric + +--- + +### 5.2 Certificate inspection (`cert`) +Must list system-wide trusted roots: +- subject, issuer +- validity range +- serial number +- SHA1 + SHA256 fingerprint +- key algorithm + size +- OS store origin (Windows store / Linux path) + +Commands: +- `wtfnet cert roots` +- `wtfnet cert roots --filter subject="DigiCert"` +- `wtfnet cert roots --export baseline.json` +- `wtfnet cert roots --diff baseline.json` + +--- + +### 5.3 Active probing (`probe`) +#### 5.3.1 ping +- IPv4/IPv6 +- count/timeout/interval +- summary: min/avg/max latency, packet loss + +#### 5.3.2 tcping +- hostname or IP:port +- resolution result +- connect latency +- failure classification + +#### 5.3.3 trace +- hop list with RTT and IP +- IPv4/IPv6 best-effort + +#### 5.3.4 GeoIP enrichment integration +All probe commands must support: +- `--geoip` to attach GeoIP info to targets/resolved IPs/hops + +--- + +### 5.4 GeoIP (`geoip`) +#### 5.4.1 Local DB support (GeoLite2 Country + ASN) +- Country DB + ASN DB used offline +- degrade gracefully if missing + +DB configuration: +- flags: + - `--country-db ` + - `--asn-db ` +- env vars: + - `NETTOOL_GEOIP_COUNTRY_DB` + - `NETTOOL_GEOIP_ASN_DB` + +Commands: +- `wtfnet geoip ` +- `wtfnet geoip status` + +Outputs: +- country + ISO code if available +- ASN number + org name +- DB source/version timestamp if detectable + +--- + +### 5.5 DNS diagnostics (`dns`) +#### 5.5.1 Query +Commands: +- `wtfnet dns query [--server ] [--tcp]` + +Outputs: +- rcode, answer set + TTL, timing, server used + +#### 5.5.2 Active poisoning detection +Commands: +- `wtfnet dns detect example.com` +- `wtfnet dns detect example.com --servers 1.1.1.1,8.8.8.8 --repeat 5` + +Heuristics to flag suspicious: +- major answer divergence across resolvers +- abnormal TTL patterns +- unexpected private/reserved results +- NXDOMAIN injection patterns + +Output verdict: +- `clean | suspicious | inconclusive` +with evidence list + +#### 5.5.3 Passive watch (best-effort) +Commands: +- `wtfnet dns watch --duration 30s [--iface eth0] [--filter example.com]` + +Must: +- be time-bounded +- clearly document privilege requirements (pcap) + +--- + +### 5.6 Ports & processes (`ports`) +Must show listening sockets and owners: +- proto, local addr:port, state +- PID, PPID (best-effort), process name/path +- user/owner (best-effort) + +Commands: +- `wtfnet ports listen --tcp|--udp` +- `wtfnet ports who ` + +(Optional) +- `wtfnet ports conns` + +--- + +### 5.7 Subnet calculator (`calc`) +Commands: +- `wtfnet calc subnet ` +- `wtfnet calc contains ` +- `wtfnet calc overlap ` +- `wtfnet calc summarize ...` + +--- + +### 5.8 HTTP diagnostics (`http`) +Goals: +- verify endpoint health and protocol negotiation +- help debug redirect loops, TLS errors, HTTP version issues + +Commands: +- `wtfnet http head ` +- `wtfnet http get ` + +Flags: +- `--http1-only` +- `--http2-only` +- `--http3` (best-effort optional) +- `--timeout 3s` +- `--follow-redirects [N]` +- `--header "K: V"` (repeatable) +- `--show-headers` +- `--show-body` (off by default) +- `--max-body ` +- `--geoip` + +Required outputs: +- resolved IP(s) +- negotiated HTTP version (1.1/2/3) +- status code +- optional headers/body +- timing breakdown (best-effort): + - DNS + - connect / QUIC handshake + - TLS handshake + - TTFB + - total + +--- + +### 5.9 TLS diagnostics (`tls`) +Commands: +- `wtfnet tls handshake ` +- `wtfnet tls cert ` +- `wtfnet tls verify ` +- `wtfnet tls alpn ` + +Flags: +- `--sni ` +- `--alpn h2,http/1.1` +- `--insecure` +- `--show-chain` +- `--geoip` + +Outputs: +- TLS version + cipher +- ALPN negotiated +- chain summary (subject/issuer/validity/SAN best-effort) +- verification verdict + error category + +--- + +### 5.10 Neighbor table (`neigh`) +Commands: +- `wtfnet neigh list [--ipv4|--ipv6] [--iface eth0]` + +Outputs: +- IP → MAC/LLADDR mapping +- interface +- state (reachable/stale/failed if available) + +--- + +### 5.11 Discovery services (`discover`) +Purpose: lightweight local discovery, bounded and safe. + +Commands: +- `wtfnet discover mdns --duration 3s` +- `wtfnet discover ssdp --duration 3s` +(optional) +- `wtfnet discover llmnr --duration 3s` +- `wtfnet discover nbns --duration 3s` + +Outputs: +- service/device name +- IP/port if present +- protocol and service type + +--- + +### 5.12 Diagnostic bundle (`diag`) +Commands: +- `wtfnet diag` +- `wtfnet diag --out report.json --json` +- `wtfnet diag --bundle out.zip` + +Bundle must include: +- sys snapshot +- routes +- dns config + optional detect check +- ports listen +- neighbor snapshot +- meta.json (OS, version, timestamp, privilege hints) + +--- + +## 6. Non-functional requirements + +- robust error handling (no panics) +- partial results allowed (exit code `5`) +- no indefinite hangs (timeouts everywhere) +- privacy: never exfiltrate data; don’t log secrets by default + +--- + +## 7. Acceptance criteria (v0.2) +On Linux (Debian/Ubuntu) and Windows: +- sys inspection works +- cert roots listing/filter works +- ping + tcping works (IPv4/IPv6 best-effort) +- dns query + detect works with verdict+evidence +- ports listen/who works (best-effort PID mapping) +- http head/get works with HTTP/2 support +- tls handshake/verify works with clear output +- neigh list works (ARP/NDP snapshot) +- logging behaves correctly without breaking JSON output +```` + +--- + +# COMMANDS.md + +````markdown +# WTFnet — Command Reference + +This file documents WTFnet CLI commands and flags. + +## Global flags + +Applies to all commands: + +- `--json` : machine-readable output +- `--pretty` : pretty JSON (requires `--json`) +- `--no-color` : disable ANSI color +- `--quiet` : minimal output +- `-v`, `-vv` : verbose output +- `--log-level ` +- `--log-format ` +- `--log-file ` + +Exit codes: see `REQUIREMENTS.md` + +--- + +## sys + +### `wtfnet sys ifaces` +Show interface inventory. + +### `wtfnet sys ip [--all] [--iface ]` +Show IP addresses. + +### `wtfnet sys route [--ipv4|--ipv6] [--to ]` +Show routing table; optionally “route-to target”. + +### `wtfnet sys dns` +Show resolver configuration snapshot. + +--- + +## cert + +### `wtfnet cert roots` +List trusted root certificates. + +Common filters: +- `--filter subject="..."` +- `--expired` +- `--fingerprint ` + +Baseline tools: +- `--export ` +- `--diff ` + +--- + +## probe + +### `wtfnet probe ping [--count N] [--timeout 800ms] [--interval 200ms] [--geoip]` +ICMP echo with stats. + +### `wtfnet probe tcping [--count N] [--timeout 800ms] [--geoip]` +TCP connect timing. + +### `wtfnet probe trace [--max-hops N] [--timeout 800ms] [--geoip]` +Traceroute-like path discovery. + +--- + +## geoip + +### `wtfnet geoip [--resolve]` +Geo lookup (offline DB). + +### `wtfnet geoip status` +Show DB presence and detected paths. + +DB flags: +- `--country-db ` +- `--asn-db ` + +Env vars: +- `NETTOOL_GEOIP_COUNTRY_DB` +- `NETTOOL_GEOIP_ASN_DB` + +--- + +## dns + +### `wtfnet dns query [--server ] [--tcp] [--timeout 2s]` +Dig-like query. + +Examples: +```bash +wtfnet dns query example.com A +wtfnet dns query example.com AAAA --server 1.1.1.1 +wtfnet dns query example.com A --tcp +```` + +### `wtfnet dns detect [--servers ] [--repeat N] [--timeout 2s]` + +Compare across resolvers and detect anomalies. + +### `wtfnet dns watch [--iface ] [--duration 30s] [--filter ]` + +Passive watch (best-effort; may require privileges). + +--- + +## ports + +### `wtfnet ports listen [--tcp|--udp] [--port N]` + +Show listening sockets. + +### `wtfnet ports who ` + +Find owning process. + +(Optional) + +### `wtfnet ports conns [--top N]` + +Show active connections. + +--- + +## calc + +### `wtfnet calc subnet ` + +Subnet information. + +### `wtfnet calc contains ` + +Containment check. + +### `wtfnet calc overlap ` + +Overlap check. + +### `wtfnet calc summarize ` + +Summarize multiple networks. + +--- + +## http + +### `wtfnet http head ` + +### `wtfnet http get ` + +Core flags: + +* `--http1-only` +* `--http2-only` +* `--http3` (best-effort) +* `--timeout 3s` +* `--follow-redirects [N]` +* `--header "K: V"` (repeatable) +* `--show-headers` +* `--show-body` +* `--max-body ` +* `--geoip` + +Examples: + +```bash +wtfnet http head https://example.com --http2-only --show-headers +wtfnet http get https://example.com --follow-redirects 5 +``` + +--- + +## tls + +### `wtfnet tls handshake ` + +### `wtfnet tls cert ` + +### `wtfnet tls verify ` + +### `wtfnet tls alpn ` + +Flags: + +* `--sni ` +* `--alpn h2,http/1.1` +* `--insecure` +* `--show-chain` +* `--geoip` + +Examples: + +```bash +wtfnet tls handshake example.com:443 --show-chain +wtfnet tls verify example.com:443 +``` + +--- + +## neigh + +### `wtfnet neigh list [--ipv4|--ipv6] [--iface ]` + +Show neighbor table (ARP/NDP). + +--- + +## discover + +### `wtfnet discover mdns --duration 3s` + +### `wtfnet discover ssdp --duration 3s` + +(Optional) + +### `wtfnet discover llmnr --duration 3s` + +### `wtfnet discover nbns --duration 3s` + +--- + +## diag + +### `wtfnet diag [--json] [--out ]` + +Generate report. + +### `wtfnet diag --bundle ` + +Export support bundle. + +Examples: + +```bash +wtfnet diag --json --pretty --out report.json +wtfnet diag --bundle out.zip +``` + +```` + +--- + +# CONFIG.md + +```markdown +# WTFnet — Configuration + +WTFnet supports configuration via: + +Priority order: +1) CLI flags +2) Environment variables +3) Config file (optional) +4) Built-in defaults + +## Config file location (proposed) + +Linux: +- `$XDG_CONFIG_HOME/wtfnet/config.json` +- fallback: `~/.config/wtfnet/config.json` + +Windows: +- `%APPDATA%\wtfnet\config.json` + +## Example config.json + +```json +{ + "geoip": { + "country_db": "/opt/geoip/GeoLite2-Country.mmdb", + "asn_db": "/opt/geoip/GeoLite2-ASN.mmdb" + }, + "dns": { + "detect_servers": ["1.1.1.1", "8.8.8.8", "9.9.9.9"], + "timeout_ms": 2000, + "repeat": 3 + }, + "probe": { + "timeout_ms": 800, + "count": 4 + }, + "http": { + "timeout_ms": 3000, + "follow_redirects": 3, + "max_body_bytes": 8192 + }, + "logging": { + "level": "info", + "format": "text", + "file": null + } +} +```` + +## Environment variables + +GeoIP: + +* `NETTOOL_GEOIP_COUNTRY_DB` +* `NETTOOL_GEOIP_ASN_DB` + +Logging: + +* `NETTOOL_LOG_LEVEL` +* `NETTOOL_LOG_FORMAT` +* `NETTOOL_LOG_FILE` + +```` + +--- + +# OUTPUT_SCHEMA.md + +```markdown +# WTFnet — JSON Output Conventions + +All commands support `--json`. + +## General rules + +- Output must be valid JSON to stdout +- Logs always go to stderr +- Prefer stable keys; changes should be additive +- Include metadata about tool version + timestamp + +## Common wrapper schema (recommended) + +```json +{ + "meta": { + "tool": "wtfnet", + "version": "0.2.0", + "timestamp": "2026-01-15T22:01:00-05:00", + "os": "linux|windows", + "arch": "x86_64", + "privileges": { + "is_admin": false, + "notes": ["pcap capture requires elevated privileges"] + } + }, + "command": { + "name": "sys ip", + "args": ["--all"] + }, + "data": {}, + "warnings": [], + "errors": [] +} +```` + +## Error representation + +* `errors[]` should contain structured objects: + +```json +{ + "code": "PERMISSION_DENIED|TIMEOUT|NOT_SUPPORTED|IO_ERROR", + "message": "Human readable explanation", + "details": { "hint": "Try running as admin" } +} +``` + +## Timing fields (for probe/http/tls) + +Use milliseconds: + +```json +{ + "timing_ms": { + "dns_resolve": 12, + "connect": 40, + "tls_handshake": 55, + "ttfb": 70, + "total": 120 + } +} +``` + +```` + +--- + +# ROADMAP.md + +```markdown +# WTFnet — Roadmap + +## v0.1 (MVP) +Focus: core sysadmin essentials +- sys: ifaces/ip/route/dns +- ports: listen/who +- probe: ping + tcping +- calc: subnet/contains/overlap +- basic logging + --json everywhere + +## v0.2 (this requirements set) +- dns: query + detect + watch (best-effort) +- geoip: local Country+ASN mmdb integration +- http: head/get (HTTP/2 required; HTTP/3 best-effort optional) +- tls: handshake/verify/cert/alpn +- neigh: ARP/NDP snapshot +- discover: mdns + ssdp (bounded) +- diag: bundle export (zip) + +## v0.3 (future upgrades) +- richer trace output (reverse lookup, per-hop loss) +- TLS extras: OCSP stapling indicator, more chain parsing +- ports conns improvements (top talkers / summary) +- better baseline/diff for system roots +- smarter “diagnose ” workflow mode +```` + +--- + +# SECURITY.md + +```markdown +# WTFnet — Security & Privacy Notes + +## Data handling +- WTFnet performs local inspections and probes. +- It does not upload anything automatically. + +## Sensitive output defaults +- HTTP bodies are not printed by default. +- Secrets (Authorization/Cookies) are never logged by default. + +## Capture-based features +Some features (e.g. passive DNS watch) may require elevated privileges. +WTFnet must clearly indicate when a feature is: +- unavailable +- permission-limited +- OS-limited +``` + +--- + +# docs/platform-notes.md + +```markdown +# Platform Notes + +## Linux (Debian/Ubuntu) +- sys: netlink (/proc, /sys) sources +- neigh: `ip neigh` equivalent via netlink +- ports: `/proc/net/*` + process mapping + +## Windows +- sys: Win32 APIs (IP Helper API etc.) +- ports/process mapping: Windows APIs (best-effort) +- cert roots: Windows certificate store APIs +``` + +--- + +# docs/troubleshooting.md + +```markdown +# Troubleshooting + +## `dns watch` shows permission errors +Passive capture may require elevated privileges or pcap capabilities. +Run as admin/root or configure capture permissions appropriately. + +## `ping` not working without admin +Some OS configurations restrict ICMP sockets. Use: +- `wtfnet probe tcping ` as an alternative reachability test. +``` + +--- diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 0000000..d522916 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,53 @@ +# WTFnet Roadmap and Status + +This document tracks the planned roadmap alongside the current implementation status. + +## Roadmap (from docs/requirement_docs.md) + +### v0.1 (MVP) +- sys: ifaces/ip/route/dns +- ports: listen/who +- probe: ping + tcping +- calc: subnet/contains/overlap +- basic logging + --json everywhere + +### v0.2 (current requirements) +- dns: query + detect + watch (best-effort) +- geoip: local Country+ASN mmdb integration +- http: head/get (HTTP/2 required; HTTP/3 best-effort optional) +- tls: handshake/verify/cert/alpn +- neigh: ARP/NDP snapshot +- discover: mdns + ssdp (bounded) +- diag: bundle export (zip) + +### v0.3 (future upgrades) +- richer trace output (reverse lookup, per-hop loss) +- TLS extras: OCSP stapling indicator, more chain parsing +- ports conns improvements (top talkers / summary) +- better baseline/diff for system roots +- smarter "diagnose " workflow mode + +## Current stage + +### Implemented +- Workspace and core crate scaffold. +- Core data model: command envelope, meta, warnings/errors. +- Exit code mapping. +- Logging initialization with text/json formats and optional file output. +- CLI crate scaffold with global flags and logging config wiring (placeholder `sys ifaces`). +- Config/env parsing helpers in core (config file + env overrides). +- Platform trait crate and OS-specific stub crates for Windows/Linux. +- `sys ifaces` implemented in Windows/Linux providers (via `network-interface`) and wired to CLI. +- CLI support for `sys ip` and `sys route` (platform routes still OS-specific work). +- Platform `sys route` implementations (Linux via `/proc/net`, Windows via `route print` parsing). +- Platform `sys dns` implementations (Linux `/etc/resolv.conf`, Windows `ipconfig /all`) and CLI command. +- Platform `ports listen/who` best-effort parsing (Linux `/proc/net`, Windows `netstat -ano`). +- Platform `neigh list` best-effort parsing (Linux `/proc/net/arp`, Windows `arp -a`). +- Platform `cert roots` implementation via native trust store parsing. +- CLI commands for `ports listen/who`, `neigh list`, and `cert roots`. + +### In progress +- None. + +### Next +- Start additional platform/feature crates per dependency map.