From b63bcd405ba5418039b0dc12d36b4c830c7a38d0 Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Fri, 16 Jan 2026 13:27:07 +0800 Subject: [PATCH] Finish verion 0.1.0 --- Cargo.lock | 1709 ++++++++++++++++++++- Cargo.toml | 4 + crates/wtfnet-calc/Cargo.toml | 9 + crates/wtfnet-calc/src/lib.rs | 202 +++ crates/wtfnet-cli/Cargo.toml | 4 + crates/wtfnet-cli/src/main.rs | 1129 ++++++++++++++ crates/wtfnet-dns/Cargo.toml | 16 + crates/wtfnet-dns/src/lib.rs | 736 +++++++++ crates/wtfnet-geoip/Cargo.toml | 9 + crates/wtfnet-geoip/src/lib.rs | 98 ++ crates/wtfnet-platform-linux/src/lib.rs | 154 +- crates/wtfnet-platform-windows/src/lib.rs | 105 +- crates/wtfnet-probe/Cargo.toml | 13 + crates/wtfnet-probe/src/lib.rs | 520 +++++++ docs/dns_poisoning_design.md | 84 + docs/requirement_docs.md | 5 + docs/status.md | 17 +- 17 files changed, 4788 insertions(+), 26 deletions(-) create mode 100644 crates/wtfnet-calc/Cargo.toml create mode 100644 crates/wtfnet-calc/src/lib.rs create mode 100644 crates/wtfnet-dns/Cargo.toml create mode 100644 crates/wtfnet-dns/src/lib.rs create mode 100644 crates/wtfnet-geoip/Cargo.toml create mode 100644 crates/wtfnet-geoip/src/lib.rs create mode 100644 crates/wtfnet-probe/Cargo.toml create mode 100644 crates/wtfnet-probe/src/lib.rs create mode 100644 docs/dns_poisoning_design.md diff --git a/Cargo.lock b/Cargo.lock index 310a55a..f2b203f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -117,6 +117,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -132,6 +144,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cc" version = "1.2.52" @@ -294,12 +318,137 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -310,12 +459,351 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.8.5", + "rustls", + "rustls-native-certs 0.6.3", + "rustls-pemfile 1.0.4", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.5", + "resolv-conf", + "rustls", + "rustls-native-certs 0.6.3", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -328,6 +816,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -340,12 +838,48 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "matchers" version = "0.2.0" @@ -355,18 +889,64 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "network-interface" version = "1.1.4" @@ -379,6 +959,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + [[package]] name = "nom" version = "7.1.3" @@ -395,7 +981,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -453,24 +1039,212 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "pnet" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "130c5b738eeda2dc5796fe2671e49027e6935e817ab51b930a36ec9e6a206a64" +dependencies = [ + "ipnetwork", + "pnet_base", + "pnet_datalink", + "pnet_packet", + "pnet_sys", + "pnet_transport", +] + +[[package]] +name = "pnet_base" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5854abf0067ebbd3967f7d45ebc8976ff577ff0c7bd101c4973ae3c70f98fe" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_macros" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "pnet_macros_support" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "pnet_packet" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a005825396b7fe7a38a8e288dbc342d5034dac80c15212436424fef8ea90ba" +dependencies = [ + "glob", + "pnet_base", + "pnet_macros", + "pnet_macros_support", +] + +[[package]] +name = "pnet_sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417c0becd1b573f6d544f73671070b039051e5ad819cc64aa96377b536128d00" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pnet_transport" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2637e14d7de974ee2f74393afccbc8704f3e54e6eb31488715e72481d1662cc3" +dependencies = [ + "libc", + "pnet_base", + "pnet_packet", + "pnet_sys", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -489,6 +1263,80 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "regex" version = "1.12.2" @@ -518,12 +1366,71 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-socks", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "resolv-conf" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -533,6 +1440,43 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework", +] + [[package]] name = "rustls-native-certs" version = "0.7.3" @@ -540,12 +1484,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -564,13 +1517,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "schannel" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", ] [[package]] @@ -579,7 +1570,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -639,6 +1630,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -676,18 +1679,66 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "surge-ping" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30498e9c9feba213c3df6ed675bdf75519ccbee493517e7225305898c86cac05" +dependencies = [ + "hex", + "parking_lot", + "pnet_packet", + "rand 0.9.2", + "socket2 0.6.1", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "syn" version = "2.0.114" @@ -699,6 +1750,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "synstructure" version = "0.13.2" @@ -710,6 +1767,40 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -790,14 +1881,44 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2 0.6.1", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -811,6 +1932,57 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -897,6 +2069,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -909,6 +2087,30 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -921,12 +2123,123 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -955,6 +2268,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -964,6 +2304,223 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wtfnet-calc" +version = "0.1.0" +dependencies = [ + "ipnet", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "wtfnet-cli" version = "0.1.0" @@ -972,10 +2529,14 @@ dependencies = [ "serde", "serde_json", "tokio", + "wtfnet-calc", "wtfnet-core", + "wtfnet-dns", + "wtfnet-geoip", "wtfnet-platform", "wtfnet-platform-linux", "wtfnet-platform-windows", + "wtfnet-probe", ] [[package]] @@ -990,6 +2551,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "wtfnet-dns" +version = "0.1.0" +dependencies = [ + "hickory-proto", + "hickory-resolver", + "pnet", + "reqwest", + "serde", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "wtfnet-geoip" +version = "0.1.0" +dependencies = [ + "maxminddb", + "serde", + "wtfnet-core", +] + [[package]] name = "wtfnet-platform" version = "0.1.0" @@ -1006,7 +2589,7 @@ dependencies = [ "async-trait", "network-interface", "resolv-conf", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "sha1", "sha2", "time", @@ -1022,7 +2605,7 @@ dependencies = [ "async-trait", "network-interface", "regex", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "sha1", "sha2", "time", @@ -1031,6 +2614,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "wtfnet-probe" +version = "0.1.0" +dependencies = [ + "pnet", + "serde", + "socket2 0.6.1", + "surge-ping", + "thiserror 2.0.17", + "tokio", + "wtfnet-geoip", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -1048,12 +2644,109 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.14" diff --git a/Cargo.toml b/Cargo.toml index 343b28f..06de99b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,11 @@ resolver = "3" members = [ "crates/wtfnet-core", "crates/wtfnet-cli", + "crates/wtfnet-calc", "crates/wtfnet-platform", "crates/wtfnet-platform-windows", "crates/wtfnet-platform-linux", + "crates/wtfnet-geoip", + "crates/wtfnet-probe", + "crates/wtfnet-dns", ] diff --git a/crates/wtfnet-calc/Cargo.toml b/crates/wtfnet-calc/Cargo.toml new file mode 100644 index 0000000..e3f6646 --- /dev/null +++ b/crates/wtfnet-calc/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "wtfnet-calc" +version = "0.1.0" +edition = "2024" + +[dependencies] +ipnet = "2" +serde = { version = "1", features = ["derive"] } +thiserror = "2" diff --git a/crates/wtfnet-calc/src/lib.rs b/crates/wtfnet-calc/src/lib.rs new file mode 100644 index 0000000..5c5387f --- /dev/null +++ b/crates/wtfnet-calc/src/lib.rs @@ -0,0 +1,202 @@ +use ipnet::{IpNet, Ipv4Net, Ipv6Net}; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, Ipv4Addr}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CalcError { + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("parse error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubnetInfo { + pub input: String, + pub version: String, + pub cidr: String, + pub network: String, + pub broadcast: Option, + pub netmask: String, + pub hostmask: String, + pub prefix_len: u8, + pub total_addresses: String, + pub usable_addresses: String, + pub first_host: Option, + pub last_host: Option, +} + +pub fn subnet_info(input: &str) -> Result { + let net = parse_net(input)?; + match net { + IpNet::V4(v4) => Ok(subnet_info_v4(input, v4)), + IpNet::V6(v6) => Ok(subnet_info_v6(input, v6)), + } +} + +pub fn contains(a: &str, b: &str) -> Result { + let net_a = parse_net(a)?; + let net_b = parse_net(b)?; + Ok(net_a.contains(&net_b)) +} + +pub fn overlap(a: &str, b: &str) -> Result { + let net_a = parse_net(a)?; + let net_b = parse_net(b)?; + match (net_a, net_b) { + (IpNet::V4(a), IpNet::V4(b)) => Ok(overlap_v4(a, b)), + (IpNet::V6(a), IpNet::V6(b)) => Ok(overlap_v6(a, b)), + _ => Ok(false), + } +} + +pub fn summarize(inputs: &[String]) -> Result, CalcError> { + if inputs.is_empty() { + return Err(CalcError::InvalidInput( + "at least one CIDR required".to_string(), + )); + } + let mut nets = Vec::with_capacity(inputs.len()); + for value in inputs { + nets.push(parse_net(value)?); + } + Ok(IpNet::aggregate(&nets)) +} + +fn subnet_info_v4(input: &str, net: Ipv4Net) -> SubnetInfo { + let total = total_addresses_v4(net.prefix_len()); + let usable = usable_addresses_v4(net.prefix_len()); + let (first, last) = first_last_v4(net); + SubnetInfo { + input: input.to_string(), + version: "ipv4".to_string(), + cidr: net.to_string(), + network: net.network().to_string(), + broadcast: Some(net.broadcast().to_string()), + netmask: net.netmask().to_string(), + hostmask: net.hostmask().to_string(), + prefix_len: net.prefix_len(), + total_addresses: total, + usable_addresses: usable, + first_host: first, + last_host: last, + } +} + +fn subnet_info_v6(input: &str, net: Ipv6Net) -> SubnetInfo { + let total = total_addresses_v6(net.prefix_len()); + let (first, last) = first_last_v6(net); + SubnetInfo { + input: input.to_string(), + version: "ipv6".to_string(), + cidr: net.to_string(), + network: net.network().to_string(), + broadcast: None, + netmask: net.netmask().to_string(), + hostmask: net.hostmask().to_string(), + prefix_len: net.prefix_len(), + total_addresses: total.clone(), + usable_addresses: total, + first_host: first, + last_host: last, + } +} + +fn parse_net(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(CalcError::InvalidInput("empty input".to_string())); + } + + let mut parts = trimmed.split_whitespace(); + let first = parts.next().unwrap(); + if let Some(mask) = parts.next() { + if parts.next().is_some() { + return Err(CalcError::InvalidInput( + "expected: ".to_string(), + )); + } + return parse_ip_mask(first, mask); + } + + if let Some((ip, mask)) = trimmed.split_once('/') { + if mask.contains('.') || mask.contains(':') { + return parse_ip_mask(ip, mask); + } + } + + trimmed + .parse::() + .map_err(|err| CalcError::Parse(err.to_string())) +} + +fn parse_ip_mask(ip: &str, mask: &str) -> Result { + let ip: IpAddr = ip + .parse() + .map_err(|_| CalcError::Parse(format!("invalid ip: {ip}")))?; + let mask: IpAddr = mask + .parse() + .map_err(|_| CalcError::Parse(format!("invalid mask: {mask}")))?; + IpNet::with_netmask(ip, mask).map_err(|err| CalcError::Parse(err.to_string())) +} + +fn total_addresses_v4(prefix: u8) -> String { + let bits = 32u32.saturating_sub(prefix as u32); + (1u128 << bits).to_string() +} + +fn usable_addresses_v4(prefix: u8) -> String { + let total = 1u128 << (32u32.saturating_sub(prefix as u32)); + let usable = if prefix <= 30 { + total.saturating_sub(2) + } else { + total + }; + usable.to_string() +} + +fn total_addresses_v6(prefix: u8) -> String { + let bits = 128u32.saturating_sub(prefix as u32); + if bits == 128 { + return "340282366920938463463374607431768211456".to_string(); + } + (1u128 << bits).to_string() +} + +fn first_last_v4(net: Ipv4Net) -> (Option, Option) { + let network = net.network(); + let broadcast = net.broadcast(); + let (first, last) = if net.prefix_len() <= 30 { + ( + Some(Ipv4Addr::from(u32::from(network).saturating_add(1)).to_string()), + Some(Ipv4Addr::from(u32::from(broadcast).saturating_sub(1)).to_string()), + ) + } else { + (Some(network.to_string()), Some(broadcast.to_string())) + }; + (first, last) +} + +fn first_last_v6(net: Ipv6Net) -> (Option, Option) { + ( + Some(net.network().to_string()), + Some(net.broadcast().to_string()), + ) +} + +fn overlap_v4(a: Ipv4Net, b: Ipv4Net) -> bool { + let a_start = u32::from(a.network()); + let a_end = u32::from(a.broadcast()); + let b_start = u32::from(b.network()); + let b_end = u32::from(b.broadcast()); + a_start <= b_end && b_start <= a_end +} + +fn overlap_v6(a: Ipv6Net, b: Ipv6Net) -> bool { + let a_start = u128::from(a.network()); + let a_end = u128::from(a.broadcast()); + let b_start = u128::from(b.network()); + let b_end = u128::from(b.broadcast()); + a_start <= b_end && b_start <= a_end +} diff --git a/crates/wtfnet-cli/Cargo.toml b/crates/wtfnet-cli/Cargo.toml index f41aadb..1d2bde0 100644 --- a/crates/wtfnet-cli/Cargo.toml +++ b/crates/wtfnet-cli/Cargo.toml @@ -13,7 +13,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } wtfnet-core = { path = "../wtfnet-core" } +wtfnet-calc = { path = "../wtfnet-calc" } +wtfnet-geoip = { path = "../wtfnet-geoip" } wtfnet-platform = { path = "../wtfnet-platform" } +wtfnet-probe = { path = "../wtfnet-probe" } +wtfnet-dns = { path = "../wtfnet-dns", features = ["pcap"] } [target.'cfg(windows)'.dependencies] wtfnet-platform-windows = { path = "../wtfnet-platform-windows" } diff --git a/crates/wtfnet-cli/src/main.rs b/crates/wtfnet-cli/src/main.rs index 99980aa..a0f9846 100644 --- a/crates/wtfnet-cli/src/main.rs +++ b/crates/wtfnet-cli/src/main.rs @@ -1,4 +1,6 @@ use clap::{Parser, Subcommand}; +use serde::Serialize; +use std::net::ToSocketAddrs; use std::path::PathBuf; use wtfnet_core::{ init_logging, CommandEnvelope, CommandInfo, ErrItem, ExitKind, LogFormat, LogLevel, @@ -52,6 +54,22 @@ enum Commands { #[command(subcommand)] command: CertCommand, }, + Geoip { + #[command(subcommand)] + command: GeoIpCommand, + }, + Probe { + #[command(subcommand)] + command: ProbeCommand, + }, + Dns { + #[command(subcommand)] + command: DnsCommand, + }, + Calc { + #[command(subcommand)] + command: CalcCommand, + }, } #[derive(Subcommand, Debug)] @@ -78,6 +96,34 @@ enum CertCommand { Roots, } +#[derive(Subcommand, Debug)] +enum GeoIpCommand { + Lookup(GeoIpLookupArgs), + Status, +} + +#[derive(Subcommand, Debug)] +enum ProbeCommand { + Ping(ProbePingArgs), + Tcping(ProbeTcpingArgs), + Trace(ProbeTraceArgs), +} + +#[derive(Subcommand, Debug)] +enum DnsCommand { + Query(DnsQueryArgs), + Detect(DnsDetectArgs), + Watch(DnsWatchArgs), +} + +#[derive(Subcommand, Debug)] +enum CalcCommand { + Subnet(CalcSubnetArgs), + Contains(CalcContainsArgs), + Overlap(CalcOverlapArgs), + Summarize(CalcSummarizeArgs), +} + #[derive(Parser, Debug, Clone)] struct SysIpArgs { #[arg(long)] @@ -121,6 +167,165 @@ struct NeighListArgs { iface: Option, } +#[derive(Parser, Debug, Clone)] +struct GeoIpLookupArgs { + target: String, +} + +#[derive(Parser, Debug, Clone)] +struct ProbePingArgs { + target: String, + #[arg(long, default_value_t = 4)] + count: u32, + #[arg(long, default_value_t = 800)] + timeout_ms: u64, + #[arg(long, default_value_t = 200)] + interval_ms: u64, + #[arg(long)] + no_geoip: bool, +} + +#[derive(Parser, Debug, Clone)] +struct ProbeTcpingArgs { + target: String, + #[arg(long, default_value_t = 4)] + count: u32, + #[arg(long, default_value_t = 800)] + timeout_ms: u64, + #[arg(long)] + no_geoip: bool, +} + +#[derive(Parser, Debug, Clone)] +struct ProbeTraceArgs { + target: String, + #[arg(long, default_value_t = 30)] + max_hops: u8, + #[arg(long, default_value_t = 800)] + timeout_ms: u64, + #[arg(long)] + udp: bool, + #[arg(long, default_value_t = 33434)] + port: u16, + #[arg(long)] + no_geoip: bool, +} + +#[derive(Parser, Debug, Clone)] +struct DnsQueryArgs { + domain: String, + record_type: String, + #[arg(long)] + server: Option, + #[arg(long, default_value = "udp")] + transport: String, + #[arg(long)] + tls_name: Option, + #[arg(long)] + socks5: Option, + #[arg(long, default_value_t = 2000)] + timeout_ms: u64, +} + +#[derive(Parser, Debug, Clone)] +struct DnsDetectArgs { + domain: String, + #[arg(long)] + servers: Option, + #[arg(long, default_value = "udp")] + transport: String, + #[arg(long)] + tls_name: Option, + #[arg(long)] + socks5: Option, + #[arg(long, default_value_t = 3)] + repeat: u32, + #[arg(long, default_value_t = 2000)] + timeout_ms: u64, +} + +#[derive(Parser, Debug, Clone)] +struct DnsWatchArgs { + #[arg(long, default_value = "30s")] + duration: String, + #[arg(long)] + iface: Option, + #[arg(long)] + filter: Option, +} + +#[derive(Parser, Debug, Clone)] +struct CalcSubnetArgs { + input: Vec, +} + +#[derive(Parser, Debug, Clone)] +struct CalcContainsArgs { + a: String, + b: String, +} + +#[derive(Parser, Debug, Clone)] +struct CalcOverlapArgs { + a: String, + b: String, +} + +#[derive(Parser, Debug, Clone)] +struct CalcSummarizeArgs { + cidrs: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct DnsAnswerGeoIp { + pub name: String, + pub record_type: String, + pub ttl: u32, + pub data: String, + pub geoip: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct DnsQueryReportGeoIp { + pub domain: String, + pub record_type: String, + pub transport: String, + pub server: Option, + pub server_name: Option, + pub server_geoip: Option, + pub proxy: Option, + pub rcode: String, + pub answers: Vec, + pub duration_ms: u128, +} + +#[derive(Debug, Clone, Serialize)] +struct DnsDetectResultGeoIp { + pub verdict: String, + pub evidence: Vec, + pub results: Vec, +} + +#[derive(Debug, Serialize)] +struct CalcContainsReport { + pub a: String, + pub b: String, + pub contains: bool, +} + +#[derive(Debug, Serialize)] +struct CalcOverlapReport { + pub a: String, + pub b: String, + pub overlap: bool, +} + +#[derive(Debug, Serialize)] +struct CalcSummarizeReport { + pub inputs: Vec, + pub result: Vec, +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -155,6 +360,42 @@ async fn main() { Commands::Cert { command: CertCommand::Roots, } => handle_cert_roots(&cli).await, + Commands::Geoip { + command: GeoIpCommand::Lookup(args), + } => handle_geoip_lookup(&cli, args.clone()).await, + Commands::Geoip { + command: GeoIpCommand::Status, + } => handle_geoip_status(&cli).await, + Commands::Probe { + command: ProbeCommand::Ping(args), + } => handle_probe_ping(&cli, args.clone()).await, + Commands::Probe { + command: ProbeCommand::Tcping(args), + } => handle_probe_tcping(&cli, args.clone()).await, + Commands::Probe { + command: ProbeCommand::Trace(args), + } => handle_probe_trace(&cli, args.clone()).await, + Commands::Dns { + command: DnsCommand::Query(args), + } => handle_dns_query(&cli, args.clone()).await, + Commands::Dns { + command: DnsCommand::Detect(args), + } => handle_dns_detect(&cli, args.clone()).await, + Commands::Dns { + command: DnsCommand::Watch(args), + } => handle_dns_watch(&cli, args.clone()).await, + Commands::Calc { + command: CalcCommand::Subnet(args), + } => handle_calc_subnet(&cli, args.clone()).await, + Commands::Calc { + command: CalcCommand::Contains(args), + } => handle_calc_contains(&cli, args.clone()).await, + Commands::Calc { + command: CalcCommand::Overlap(args), + } => handle_calc_overlap(&cli, args.clone()).await, + Commands::Calc { + command: CalcCommand::Summarize(args), + } => handle_calc_summarize(&cli, args.clone()).await, }; std::process::exit(exit_code); @@ -489,6 +730,236 @@ async fn handle_cert_roots(cli: &Cli) -> i32 { } } +async fn handle_geoip_lookup(cli: &Cli, args: GeoIpLookupArgs) -> i32 { + let ip = match args.target.parse::() { + Ok(ip) => ip, + Err(_) => { + eprintln!("invalid ip: {}", args.target); + return ExitKind::Usage.code(); + } + }; + let service = geoip_service(); + let record = service.lookup(ip); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("geoip", vec![args.target]); + let envelope = CommandEnvelope::new(meta, command, record); + emit_json(cli, &envelope) + } else { + println!("ip: {}", record.ip); + if let Some(country) = record.country { + println!( + "country: {} {}", + country.name.unwrap_or_else(|| "-".to_string()), + country.iso_code.unwrap_or_else(|| "-".to_string()) + ); + } else { + println!("country: -"); + } + if let Some(asn) = record.asn { + println!( + "asn: {} {}", + asn.number + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + asn.organization.unwrap_or_else(|| "-".to_string()) + ); + } else { + println!("asn: -"); + } + ExitKind::Ok.code() + } +} + +async fn handle_geoip_status(cli: &Cli) -> i32 { + let service = geoip_service(); + let status = service.status(); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("geoip status", Vec::new()); + let envelope = CommandEnvelope::new(meta, command, status); + emit_json(cli, &envelope) + } else { + println!( + "country db: {}", + status.country_db.unwrap_or_else(|| "-".to_string()) + ); + println!( + "asn db: {}", + status.asn_db.unwrap_or_else(|| "-".to_string()) + ); + println!( + "loaded: country={} asn={}", + status.country_loaded, status.asn_loaded + ); + ExitKind::Ok.code() + } +} + +async fn handle_probe_ping(cli: &Cli, args: ProbePingArgs) -> i32 { + match wtfnet_probe::ping( + &args.target, + args.count, + args.timeout_ms, + args.interval_ms, + ) + .await + { + Ok(mut report) => { + if !args.no_geoip { + enrich_ping_geoip(&mut report); + } + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("probe ping", vec![args.target]); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!( + "target: {} ({})", + report.target, + report.ip.unwrap_or_else(|| "-".to_string()) + ); + if let Some(geoip) = report.geoip.as_ref() { + println!("geoip: {}", format_geoip(geoip)); + } + for result in report.results { + if let Some(rtt) = result.rtt_ms { + println!("seq={} rtt={}ms", result.seq, rtt); + } else { + println!( + "seq={} error={}", + result.seq, + result.error.unwrap_or_else(|| "-".to_string()) + ); + } + } + print_summary(&report.summary); + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("ping failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_probe_tcping(cli: &Cli, args: ProbeTcpingArgs) -> i32 { + let (host, port) = match split_host_port(&args.target) { + Some(value) => value, + None => { + eprintln!("invalid target: {}", args.target); + return ExitKind::Usage.code(); + } + }; + + match wtfnet_probe::tcp_ping(&host, port, args.count, args.timeout_ms).await { + Ok(mut report) => { + if !args.no_geoip { + enrich_tcp_geoip(&mut report); + } + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("probe tcping", vec![args.target]); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!( + "target: {} ({})", + report.target, + report.ip.unwrap_or_else(|| "-".to_string()) + ); + if let Some(geoip) = report.geoip.as_ref() { + println!("geoip: {}", format_geoip(geoip)); + } + for result in report.results { + if let Some(rtt) = result.rtt_ms { + println!("seq={} rtt={}ms", result.seq, rtt); + } else { + println!( + "seq={} error={}", + result.seq, + result.error.unwrap_or_else(|| "-".to_string()) + ); + } + } + print_summary(&report.summary); + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("tcping failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_probe_trace(cli: &Cli, args: ProbeTraceArgs) -> i32 { + let (host, port) = match split_host_port(&args.target).or_else(|| { + if args.udp { + Some((args.target.clone(), args.port)) + } else { + None + } + }) { + Some(value) => value, + None => { + eprintln!("invalid target: {}", args.target); + return ExitKind::Usage.code(); + } + }; + + let result = if args.udp { + wtfnet_probe::udp_trace(&host, port, args.max_hops, args.timeout_ms).await + } else { + wtfnet_probe::tcp_trace(&host, port, args.max_hops, args.timeout_ms).await + }; + + match result { + Ok(mut report) => { + if !args.no_geoip { + enrich_trace_geoip(&mut report); + } + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("probe trace", vec![args.target]); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!( + "target: {} ({})", + report.target, + report.ip.unwrap_or_else(|| "-".to_string()) + ); + if let Some(geoip) = report.geoip.as_ref() { + println!("geoip: {}", format_geoip(geoip)); + } + for hop in report.hops { + let geoip = hop.geoip.as_ref().map(format_geoip); + println!( + "ttl={} addr={} rtt={}ms {}{}", + hop.ttl, + hop.addr.unwrap_or_else(|| "*".to_string()), + hop.rtt_ms + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()), + hop.note.unwrap_or_default(), + geoip + .map(|value| format!(" geoip={value}")) + .unwrap_or_default() + ); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("trace failed: {err}"); + ExitKind::Failed.code() + } + } +} + fn filter_interfaces_for_ip( interfaces: Vec, args: &SysIpArgs, @@ -617,6 +1088,664 @@ fn parse_port_arg(value: &str) -> Option { extract_port(value) } +fn geoip_service() -> wtfnet_geoip::GeoIpService { + let country = geoip_db_path("NETTOOL_GEOIP_COUNTRY_DB", "GeoLite2-Country.mmdb"); + let asn = geoip_db_path("NETTOOL_GEOIP_ASN_DB", "GeoLite2-ASN.mmdb"); + wtfnet_geoip::GeoIpService::new(country, asn) +} + +fn geoip_db_path(env_key: &str, filename: &str) -> Option { + if let Ok(value) = std::env::var(env_key) { + let value = value.trim(); + if !value.is_empty() { + return Some(std::path::PathBuf::from(value)); + } + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("data").join(filename); + if candidate.exists() { + return Some(candidate); + } + } + } + + let candidate = std::path::PathBuf::from("data").join(filename); + if candidate.exists() { + return Some(candidate); + } + + None +} + +fn split_host_port(value: &str) -> Option<(String, u16)> { + if let Some((host, port)) = value.rsplit_once(':') { + let port = port.parse::().ok()?; + return Some((host.to_string(), port)); + } + None +} + +fn print_summary(summary: &wtfnet_probe::PingSummary) { + println!( + "sent={} received={} loss={:.1}%", + summary.sent, summary.received, summary.loss_pct + ); + if let (Some(min), Some(avg), Some(max)) = (summary.min_ms, summary.avg_ms, summary.max_ms) { + println!("min/avg/max={}ms/{:.1}ms/{}ms", min, avg, max); + } +} + +async fn handle_dns_query(cli: &Cli, args: DnsQueryArgs) -> i32 { + let transport = match args.transport.parse::() { + Ok(value) => value, + Err(err) => { + eprintln!("invalid transport: {err}"); + return ExitKind::Usage.code(); + } + }; + let server = match args.server.as_deref() { + Some(value) => match parse_dns_server_target(value, transport, args.tls_name.as_deref()) { + Ok(addr) => Some(addr), + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + }, + None => None, + }; + + match wtfnet_dns::query( + &args.domain, + &args.record_type, + server, + transport, + args.socks5.clone(), + args.timeout_ms, + ) + .await + { + Ok(report) => { + let service = geoip_service(); + let enriched = enrich_dns_query_geoip(&report, &service); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new( + "dns query", + vec![args.domain.clone(), args.record_type.clone()], + ); + let envelope = CommandEnvelope::new(meta, command, enriched); + emit_json(cli, &envelope) + } else { + println!("rcode: {}", enriched.rcode); + println!("transport: {}", enriched.transport); + if let Some(proxy) = enriched.proxy.as_ref() { + println!("proxy: {proxy}"); + } + println!( + "server: {}", + enriched.server.clone().unwrap_or_else(|| "-".to_string()) + ); + if let Some(name) = enriched.server_name.as_ref() { + println!("server name: {name}"); + } + if let Some(geoip) = enriched.server_geoip.as_ref() { + println!("server geoip: {}", format_geoip(geoip)); + } + for answer in enriched.answers { + let geoip = answer + .geoip + .as_ref() + .map(format_geoip) + .unwrap_or_else(|| "-".to_string()); + println!( + "{} {} {} {} geoip={}", + answer.name, answer.ttl, answer.record_type, answer.data, geoip + ); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("dns query failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_dns_detect(cli: &Cli, args: DnsDetectArgs) -> i32 { + let transport = match args.transport.parse::() { + Ok(value) => value, + Err(err) => { + eprintln!("invalid transport: {err}"); + return ExitKind::Usage.code(); + } + }; + let servers = if let Some(raw) = args.servers.as_deref() { + let parsed = raw + .split(',') + .filter(|value| !value.trim().is_empty()) + .map(|value| parse_dns_server_target(value.trim(), transport, args.tls_name.as_deref())) + .collect::, _>>(); + match parsed { + Ok(values) => values, + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + } + } else { + wtfnet_dns::default_detect_servers(transport) + }; + + match wtfnet_dns::detect( + &args.domain, + &servers, + transport, + args.socks5.clone(), + args.repeat, + args.timeout_ms, + ) + .await + { + Ok(report) => { + let service = geoip_service(); + let enriched = enrich_dns_detect_geoip(&report, &service); + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("dns detect", vec![args.domain.clone()]); + let envelope = CommandEnvelope::new(meta, command, enriched); + emit_json(cli, &envelope) + } else { + println!("verdict: {}", enriched.verdict); + println!("transport: {}", transport); + if let Some(proxy) = args.socks5.as_ref() { + println!("proxy: {proxy}"); + } + for ev in enriched.evidence { + println!("evidence: {} {}", ev.code, ev.message); + } + for result in enriched.results { + println!( + "{} {} {} {}", + result.server.clone().unwrap_or_else(|| "-".to_string()), + result.rcode, + result.duration_ms, + format_answers_geoip(&result.answers) + ); + if let Some(geoip) = result.server_geoip.as_ref() { + println!(" server geoip: {}", format_geoip(geoip)); + } + if let Some(name) = result.server_name.as_ref() { + println!(" server name: {name}"); + } + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("dns detect failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 { + let duration_ms = match parse_duration_ms(&args.duration) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + }; + let options = wtfnet_dns::DnsWatchOptions { + iface: args.iface.clone(), + duration_ms, + filter: args.filter.clone(), + }; + + match wtfnet_dns::watch(options).await { + Ok(report) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let mut command_args = vec!["--duration".to_string(), args.duration]; + if let Some(iface) = args.iface { + command_args.push("--iface".to_string()); + command_args.push(iface); + } + if let Some(filter) = args.filter { + command_args.push("--filter".to_string()); + command_args.push(filter); + } + let command = CommandInfo::new("dns watch", command_args); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!( + "iface: {} duration_ms: {} filter: {}", + report.iface.unwrap_or_else(|| "-".to_string()), + report.duration_ms, + report.filter.unwrap_or_else(|| "-".to_string()) + ); + for event in report.events { + let answers = if event.answers.is_empty() { + "-".to_string() + } else { + event.answers.join(",") + }; + println!( + "t={}ms {} -> {} {} {} rcode={} answers={}", + event.timestamp_ms, + event.src, + event.dst, + event.query_type, + event.query_name, + event.rcode, + answers + ); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("dns watch failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_calc_subnet(cli: &Cli, args: CalcSubnetArgs) -> i32 { + let input = match normalize_subnet_input(&args.input) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + }; + + match wtfnet_calc::subnet_info(&input) { + Ok(info) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("calc subnet", vec![input]); + let envelope = CommandEnvelope::new(meta, command, info); + emit_json(cli, &envelope) + } else { + println!("cidr: {}", info.cidr); + println!("network: {}", info.network); + if let Some(broadcast) = info.broadcast.as_ref() { + println!("broadcast: {broadcast}"); + } + println!("netmask: {}", info.netmask); + println!("hostmask: {}", info.hostmask); + println!("prefix: {}", info.prefix_len); + println!("total: {}", info.total_addresses); + println!("usable: {}", info.usable_addresses); + if let Some(first) = info.first_host.as_ref() { + println!("first: {first}"); + } + if let Some(last) = info.last_host.as_ref() { + println!("last: {last}"); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("calc subnet failed: {err}"); + ExitKind::Usage.code() + } + } +} + +async fn handle_calc_contains(cli: &Cli, args: CalcContainsArgs) -> i32 { + match wtfnet_calc::contains(&args.a, &args.b) { + Ok(result) => { + let report = CalcContainsReport { + a: args.a.clone(), + b: args.b.clone(), + contains: result, + }; + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("calc contains", vec![args.a, args.b]); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!("{}", if result { "yes" } else { "no" }); + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("calc contains failed: {err}"); + ExitKind::Usage.code() + } + } +} + +async fn handle_calc_overlap(cli: &Cli, args: CalcOverlapArgs) -> i32 { + match wtfnet_calc::overlap(&args.a, &args.b) { + Ok(result) => { + let report = CalcOverlapReport { + a: args.a.clone(), + b: args.b.clone(), + overlap: result, + }; + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("calc overlap", vec![args.a, args.b]); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + println!("{}", if result { "yes" } else { "no" }); + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("calc overlap failed: {err}"); + ExitKind::Usage.code() + } + } +} + +async fn handle_calc_summarize(cli: &Cli, args: CalcSummarizeArgs) -> i32 { + if args.cidrs.is_empty() { + eprintln!("calc summarize requires at least one CIDR"); + return ExitKind::Usage.code(); + } + match wtfnet_calc::summarize(&args.cidrs) { + Ok(result) => { + let result = result.iter().map(|net| net.to_string()).collect::>(); + let report = CalcSummarizeReport { + inputs: args.cidrs.clone(), + result, + }; + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("calc summarize", args.cidrs); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + for entry in report.result { + println!("{entry}"); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("calc summarize failed: {err}"); + ExitKind::Usage.code() + } + } +} + +fn parse_dns_server_target( + value: &str, + transport: wtfnet_dns::DnsTransport, + tls_name: Option<&str>, +) -> Result { + let default_port = match transport { + wtfnet_dns::DnsTransport::Udp | wtfnet_dns::DnsTransport::Tcp => 53, + wtfnet_dns::DnsTransport::Dot => 853, + wtfnet_dns::DnsTransport::Doh => 443, + }; + + if let Ok(addr) = value.parse::() { + let name = tls_name + .map(|value| value.to_string()) + .or_else(|| derive_tls_name(value)); + return Ok(wtfnet_dns::DnsServerTarget { addr, name }); + } + + if let Ok(ip) = value.parse::() { + let addr = std::net::SocketAddr::new(ip, default_port); + let name = tls_name.map(|value| value.to_string()); + return Ok(wtfnet_dns::DnsServerTarget { addr, name }); + } + + let (host, port) = split_host_port_with_default(value, default_port)?; + let addr = format!("{host}:{port}") + .to_socket_addrs() + .map_err(|_| format!("invalid server address: {value}"))? + .next() + .ok_or_else(|| format!("unable to resolve server: {value}"))?; + + let name = tls_name + .map(|value| value.to_string()) + .or_else(|| derive_tls_name(&host)); + + if matches!( + transport, + wtfnet_dns::DnsTransport::Dot | wtfnet_dns::DnsTransport::Doh + ) && name.is_none() + { + return Err(format!( + "tls name is required for transport {}", + transport + )); + } + + Ok(wtfnet_dns::DnsServerTarget { addr, name }) +} + +fn split_host_port_with_default(value: &str, default_port: u16) -> Result<(String, u16), String> { + if let Some(addr) = value.strip_prefix('[') { + if let Some(end) = addr.find(']') { + let host = &addr[..end]; + let port = addr[end + 1..] + .strip_prefix(':') + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_port); + return Ok((host.to_string(), port)); + } + } + + if let Some((host, port)) = value.rsplit_once(':') { + if host.contains(':') { + return Ok((value.to_string(), default_port)); + } + let port = port + .parse::() + .map_err(|_| format!("invalid port in server: {value}"))?; + return Ok((host.to_string(), port)); + } + + Ok((value.to_string(), default_port)) +} + +fn derive_tls_name(value: &str) -> Option { + if let Ok(_addr) = value.parse::() { + return None; + } + if let Ok(_ip) = value.parse::() { + return None; + } + let host = if let Some(addr) = value.strip_prefix('[') { + if let Some(end) = addr.find(']') { + &addr[..end] + } else { + value + } + } else if let Some((host, _)) = value.rsplit_once(':') { + host + } else { + value + }; + if host.parse::().is_ok() { + None + } else { + Some(host.to_string()) + } +} + +fn normalize_subnet_input(values: &[String]) -> Result { + match values.len() { + 1 => Ok(values[0].clone()), + 2 => Ok(format!("{} {}", values[0], values[1])), + _ => Err("expected: or ".to_string()), + } +} + +fn parse_duration_ms(value: &str) -> Result { + let raw = value.trim(); + if raw.is_empty() { + return Err("duration is empty".to_string()); + } + let (number, unit) = if raw.ends_with("ms") { + (&raw[..raw.len() - 2], "ms") + } else if raw.ends_with('s') { + (&raw[..raw.len() - 1], "s") + } else { + (raw, "ms") + }; + + let base: u64 = number + .parse() + .map_err(|_| format!("invalid duration: {value}"))?; + let ms = match unit { + "ms" => base, + "s" => base.saturating_mul(1000), + _ => base, + }; + Ok(ms) +} + +fn format_answers_geoip(answers: &[DnsAnswerGeoIp]) -> String { + if answers.is_empty() { + return "-".to_string(); + } + answers + .iter() + .map(|answer| { + let geoip = answer + .geoip + .as_ref() + .map(format_geoip) + .unwrap_or_else(|| "-".to_string()); + format!("{}(geoip={})", answer.data, geoip) + }) + .collect::>() + .join(",") +} + +fn enrich_dns_query_geoip( + report: &wtfnet_dns::DnsQueryReport, + service: &wtfnet_geoip::GeoIpService, +) -> DnsQueryReportGeoIp { + let server_geoip = report + .server + .as_deref() + .and_then(|value| parse_ip_from_server(value)) + .map(|ip| service.lookup(ip)); + let answers = report + .answers + .iter() + .map(|answer| DnsAnswerGeoIp { + name: answer.name.clone(), + record_type: answer.record_type.clone(), + ttl: answer.ttl, + data: answer.data.clone(), + geoip: parse_ip_from_server(&answer.data).map(|ip| service.lookup(ip)), + }) + .collect::>(); + + DnsQueryReportGeoIp { + domain: report.domain.clone(), + record_type: report.record_type.clone(), + transport: report.transport.clone(), + server: report.server.clone(), + server_name: report.server_name.clone(), + server_geoip, + proxy: report.proxy.clone(), + rcode: report.rcode.clone(), + answers, + duration_ms: report.duration_ms, + } +} + +fn enrich_dns_detect_geoip( + report: &wtfnet_dns::DnsDetectResult, + service: &wtfnet_geoip::GeoIpService, +) -> DnsDetectResultGeoIp { + let results = report + .results + .iter() + .map(|result| enrich_dns_query_geoip(result, service)) + .collect::>(); + DnsDetectResultGeoIp { + verdict: report.verdict.clone(), + evidence: report.evidence.clone(), + results, + } +} + +fn parse_ip_from_server(value: &str) -> Option { + if let Ok(addr) = value.parse::() { + return Some(addr.ip()); + } + value.parse::().ok() +} + +fn enrich_ping_geoip(report: &mut wtfnet_probe::PingReport) { + let ip = match report.ip.as_deref() { + Some(ip) => ip, + None => return, + }; + if let Ok(parsed) = ip.parse::() { + let service = geoip_service(); + report.geoip = Some(service.lookup(parsed)); + } +} + +fn enrich_tcp_geoip(report: &mut wtfnet_probe::TcpPingReport) { + let ip = match report.ip.as_deref() { + Some(ip) => ip, + None => return, + }; + if let Ok(parsed) = ip.parse::() { + let service = geoip_service(); + report.geoip = Some(service.lookup(parsed)); + } +} + +fn enrich_trace_geoip(report: &mut wtfnet_probe::TraceReport) { + let service = geoip_service(); + if let Some(ip) = report.ip.as_deref() { + if let Ok(parsed) = ip.parse::() { + report.geoip = Some(service.lookup(parsed)); + } + } + for hop in &mut report.hops { + if let Some(addr) = hop.addr.as_deref() { + if let Ok(parsed) = addr.parse::() { + hop.geoip = Some(service.lookup(parsed)); + } + } + } +} + +fn format_geoip(record: &wtfnet_geoip::GeoIpRecord) -> String { + let country = record.country.as_ref(); + let iso = country + .and_then(|value| value.iso_code.as_deref()) + .unwrap_or("-"); + let name = country + .and_then(|value| value.name.as_deref()) + .unwrap_or("-"); + let asn = record + .asn + .as_ref() + .and_then(|value| value.number) + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()); + let org = record + .asn + .as_ref() + .and_then(|value| value.organization.as_deref()) + .unwrap_or("-"); + format!("country={} name={} asn={} org={}", iso, name, asn, org) +} + fn emit_platform_error(cli: &Cli, err: PlatformError) -> i32 { let code = err.code.clone(); let message = err.message.clone(); diff --git a/crates/wtfnet-dns/Cargo.toml b/crates/wtfnet-dns/Cargo.toml new file mode 100644 index 0000000..726c406 --- /dev/null +++ b/crates/wtfnet-dns/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wtfnet-dns" +version = "0.1.0" +edition = "2024" + +[dependencies] +hickory-resolver = { version = "0.24", features = ["dns-over-tls", "dns-over-https", "dns-over-https-rustls", "dns-over-rustls", "native-certs"] } +hickory-proto = "0.24" +reqwest = { version = "0.11", features = ["rustls-tls", "socks"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["time"] } +pnet = { version = "0.34", optional = true } + +[features] +pcap = ["dep:pnet"] diff --git a/crates/wtfnet-dns/src/lib.rs b/crates/wtfnet-dns/src/lib.rs new file mode 100644 index 0000000..e88038a --- /dev/null +++ b/crates/wtfnet-dns/src/lib.rs @@ -0,0 +1,736 @@ +use hickory_resolver::config::{ + NameServerConfig, NameServerConfigGroup, Protocol, ResolverConfig, ResolverOpts, +}; +use hickory_resolver::error::ResolveErrorKind; +use hickory_resolver::proto::rr::{RData, RecordType}; +use hickory_resolver::TokioAsyncResolver; +use hickory_resolver::system_conf::read_system_conf; +use hickory_proto::op::{Message, MessageType, Query}; +use hickory_proto::rr::Name; +use reqwest::Proxy; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[cfg(feature = "pcap")] +use pnet::datalink::{self, Channel, Config as DatalinkConfig, NetworkInterface}; +#[cfg(feature = "pcap")] +use pnet::packet::ethernet::{EtherTypes, EthernetPacket}; +#[cfg(feature = "pcap")] +use pnet::packet::ip::IpNextHeaderProtocols; +#[cfg(feature = "pcap")] +use pnet::packet::ipv4::Ipv4Packet; +#[cfg(feature = "pcap")] +use pnet::packet::ipv6::Ipv6Packet; +#[cfg(feature = "pcap")] +use pnet::packet::udp::UdpPacket; +#[cfg(feature = "pcap")] +use pnet::packet::Packet; + +#[derive(Debug, Error)] +pub enum DnsError { + #[error("invalid record type: {0}")] + RecordType(String), + #[error("resolver error: {0}")] + Resolver(String), + #[error("io error: {0}")] + Io(String), + #[error("missing tls server name for {0}")] + MissingTlsName(String), + #[error("missing server for transport {0}")] + MissingServer(String), + #[error("proxy only supported for {0}")] + ProxyUnsupported(String), + #[error("proxy error: {0}")] + Proxy(String), + #[error("not supported: {0}")] + NotSupported(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DnsTransport { + Udp, + Tcp, + Dot, + Doh, +} + +impl std::fmt::Display for DnsTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + DnsTransport::Udp => "udp", + DnsTransport::Tcp => "tcp", + DnsTransport::Dot => "dot", + DnsTransport::Doh => "doh", + }; + f.write_str(value) + } +} + +impl FromStr for DnsTransport { + type Err = DnsError; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "udp" => Ok(DnsTransport::Udp), + "tcp" => Ok(DnsTransport::Tcp), + "dot" | "tls" => Ok(DnsTransport::Dot), + "doh" | "https" => Ok(DnsTransport::Doh), + _ => Err(DnsError::Resolver(format!( + "invalid transport: {value}" + ))), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsServerTarget { + pub addr: SocketAddr, + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsAnswer { + pub name: String, + pub record_type: String, + pub ttl: u32, + pub data: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsQueryReport { + pub domain: String, + pub record_type: String, + pub transport: String, + pub server: Option, + pub server_name: Option, + pub proxy: Option, + pub rcode: String, + pub answers: Vec, + pub duration_ms: u128, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsDetectEvidence { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsDetectResult { + pub verdict: String, + pub evidence: Vec, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsWatchOptions { + pub iface: Option, + pub duration_ms: u64, + pub filter: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsWatchEvent { + pub timestamp_ms: u128, + pub src: String, + pub dst: String, + pub query_name: String, + pub query_type: String, + pub rcode: String, + pub answers: Vec, + pub is_response: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsWatchReport { + pub iface: Option, + pub duration_ms: u64, + pub filter: Option, + pub events: Vec, +} + +pub async fn query( + domain: &str, + record_type: &str, + server: Option, + transport: DnsTransport, + proxy: Option, + timeout_ms: u64, +) -> Result { + let record_type = parse_record_type(record_type)?; + if let Some(proxy) = proxy { + if transport != DnsTransport::Doh { + return Err(DnsError::ProxyUnsupported(transport.to_string())); + } + let server = server.ok_or_else(|| DnsError::MissingServer(transport.to_string()))?; + return doh_query_via_proxy(domain, record_type, server, timeout_ms, proxy).await; + } + let resolver = build_resolver(server.clone(), transport, timeout_ms)?; + let start = Instant::now(); + let response = resolver.lookup(domain, record_type).await; + let duration_ms = start.elapsed().as_millis(); + + match response { + Ok(lookup) => { + let mut answers = Vec::new(); + for record in lookup.record_iter() { + let ttl = record.ttl(); + let name = record.name().to_string(); + let record_type = record.record_type().to_string(); + if let Some(data) = record.data() { + if let Some(data) = format_rdata(data) { + answers.push(DnsAnswer { + name, + record_type, + ttl, + data, + }); + } + } + } + + Ok(DnsQueryReport { + domain: domain.to_string(), + record_type: record_type.to_string(), + transport: transport.to_string(), + server: server.as_ref().map(|value| value.addr.to_string()), + server_name: server.as_ref().and_then(|value| value.name.clone()), + proxy: None, + rcode: "NOERROR".to_string(), + answers, + duration_ms, + }) + } + Err(err) => { + if let ResolveErrorKind::NoRecordsFound { response_code, .. } = err.kind() { + Ok(DnsQueryReport { + domain: domain.to_string(), + record_type: record_type.to_string(), + transport: transport.to_string(), + server: server.as_ref().map(|value| value.addr.to_string()), + server_name: server.as_ref().and_then(|value| value.name.clone()), + proxy: None, + rcode: response_code.to_string(), + answers: Vec::new(), + duration_ms, + }) + } else { + Err(DnsError::Resolver(err.to_string())) + } + } + } +} + +pub async fn detect( + domain: &str, + servers: &[DnsServerTarget], + transport: DnsTransport, + proxy: Option, + repeat: u32, + timeout_ms: u64, +) -> Result { + let mut results = Vec::new(); + for server in servers { + for _ in 0..repeat.max(1) { + let report = query( + domain, + "A", + Some(server.clone()), + transport, + proxy.clone(), + timeout_ms, + ) + .await?; + results.push(report); + } + } + + let mut evidence = Vec::new(); + let verdict = evaluate_detect(domain, &results, &mut evidence); + + Ok(DnsDetectResult { + verdict, + evidence, + results, + }) +} + +pub fn default_detect_servers(transport: DnsTransport) -> Vec { + let (port, names) = match transport { + DnsTransport::Udp | DnsTransport::Tcp => (53, [None, None, None]), + DnsTransport::Dot => ( + 853, + [ + Some("cloudflare-dns.com"), + Some("dns.google"), + Some("dns.quad9.net"), + ], + ), + DnsTransport::Doh => ( + 443, + [ + Some("cloudflare-dns.com"), + Some("dns.google"), + Some("dns.quad9.net"), + ], + ), + }; + + let ips = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]; + ips.iter() + .zip(names.iter()) + .map(|(ip, name)| DnsServerTarget { + addr: SocketAddr::new(ip.parse().unwrap(), port), + name: name.map(|value| value.to_string()), + }) + .collect() +} + +#[cfg(not(feature = "pcap"))] +pub async fn watch(_options: DnsWatchOptions) -> Result { + Err(DnsError::NotSupported( + "dns watch requires pcap feature".to_string(), + )) +} + +#[cfg(feature = "pcap")] +pub async fn watch(options: DnsWatchOptions) -> Result { + let iface = match select_interface(options.iface.as_deref()) { + Some(value) => value, + None => { + return Err(DnsError::Resolver( + "no suitable interface found".to_string(), + )) + } + }; + + let mut config = DatalinkConfig::default(); + config.read_timeout = Some(Duration::from_millis(500)); + let (_, mut rx) = match datalink::channel(&iface, config) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + Ok(_) => { + return Err(DnsError::Resolver( + "unsupported datalink channel".to_string(), + )) + } + Err(err) => return Err(DnsError::Resolver(err.to_string())), + }; + + let start = Instant::now(); + let deadline = start + Duration::from_millis(options.duration_ms); + let filter = options.filter.as_ref().map(|value| value.to_ascii_lowercase()); + let mut events = Vec::new(); + + while Instant::now() < deadline { + match rx.next() { + Ok(frame) => { + if let Some(event) = parse_dns_frame(frame, start, &filter) { + events.push(event); + } + } + Err(_) => continue, + } + } + + Ok(DnsWatchReport { + iface: Some(iface.name), + duration_ms: options.duration_ms, + filter: options.filter, + events, + }) +} + +fn build_resolver( + server: Option, + transport: DnsTransport, + timeout_ms: u64, +) -> Result { + let mut opts = ResolverOpts::default(); + opts.timeout = Duration::from_millis(timeout_ms); + if let Some(server) = server { + let protocol = match transport { + DnsTransport::Udp => Protocol::Udp, + DnsTransport::Tcp => Protocol::Tcp, + DnsTransport::Dot => Protocol::Tls, + DnsTransport::Doh => Protocol::Https, + }; + let tls_dns_name = match transport { + DnsTransport::Dot | DnsTransport::Doh => server + .name + .clone() + .ok_or_else(|| DnsError::MissingTlsName(transport.to_string()))?, + _ => String::new(), + }; + let mut group = NameServerConfigGroup::new(); + group.push(NameServerConfig { + socket_addr: server.addr, + protocol, + tls_dns_name: match transport { + DnsTransport::Dot | DnsTransport::Doh => Some(tls_dns_name), + _ => None, + }, + trust_negative_responses: true, + tls_config: None, + bind_addr: None, + }); + let config = ResolverConfig::from_parts(None, vec![], group); + Ok(TokioAsyncResolver::tokio(config, opts)) + } else { + match transport { + DnsTransport::Udp => { + let (config, mut sys_opts) = + read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?; + sys_opts.timeout = opts.timeout; + Ok(TokioAsyncResolver::tokio(config, sys_opts)) + } + DnsTransport::Tcp => { + let (config, mut sys_opts) = + read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?; + sys_opts.timeout = opts.timeout; + let mut group = NameServerConfigGroup::new(); + for entry in config.name_servers() { + group.push(NameServerConfig { + socket_addr: entry.socket_addr, + protocol: Protocol::Tcp, + tls_dns_name: None, + trust_negative_responses: entry.trust_negative_responses, + tls_config: None, + bind_addr: entry.bind_addr, + }); + } + let config = ResolverConfig::from_parts( + config.domain().cloned(), + config.search().to_vec(), + group, + ); + Ok(TokioAsyncResolver::tokio(config, sys_opts)) + } + DnsTransport::Dot | DnsTransport::Doh => { + Err(DnsError::MissingServer(transport.to_string())) + } + } + } +} + +async fn doh_query_via_proxy( + domain: &str, + record_type: RecordType, + server: DnsServerTarget, + timeout_ms: u64, + proxy: String, +) -> Result { + let tls_name = server + .name + .clone() + .ok_or_else(|| DnsError::MissingTlsName("doh".to_string()))?; + + let name = Name::from_ascii(domain) + .map_err(|err| DnsError::Resolver(format!("invalid domain: {err}")))?; + + let mut message = Message::new(); + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_recursion_desired(true) + .add_query(Query::query(name, record_type)); + + let body = message + .to_vec() + .map_err(|err| DnsError::Resolver(err.to_string()))?; + + let url = format!("https://{tls_name}/dns-query"); + let proxy_value = proxy.clone(); + let proxy = Proxy::all(&proxy).map_err(|err| DnsError::Proxy(err.to_string()))?; + + let mut builder = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .proxy(proxy); + + let server_addr = SocketAddr::new(server.addr.ip(), server.addr.port()); + builder = builder.resolve(&tls_name, server_addr); + let client = builder + .build() + .map_err(|err| DnsError::Resolver(err.to_string()))?; + + let start = Instant::now(); + let response = client + .post(url) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(body) + .send() + .await + .map_err(|err| DnsError::Resolver(err.to_string()))?; + + let status = response.status(); + if !status.is_success() { + return Err(DnsError::Resolver(format!( + "doh status: {}", + status.as_u16() + ))); + } + + let bytes = response + .bytes() + .await + .map_err(|err| DnsError::Resolver(err.to_string()))?; + let response = Message::from_vec(&bytes).map_err(|err| DnsError::Resolver(err.to_string()))?; + let duration_ms = start.elapsed().as_millis(); + + let mut answers = Vec::new(); + for record in response.answers() { + let ttl = record.ttl(); + let name = record.name().to_string(); + let record_type = record.record_type().to_string(); + if let Some(data) = record.data() { + if let Some(data) = format_rdata(data) { + answers.push(DnsAnswer { + name, + record_type, + ttl, + data, + }); + } + } + } + + Ok(DnsQueryReport { + domain: domain.to_string(), + record_type: record_type.to_string(), + transport: DnsTransport::Doh.to_string(), + server: Some(server.addr.to_string()), + server_name: Some(tls_name), + proxy: Some(proxy_value), + rcode: response.response_code().to_string(), + answers, + duration_ms, + }) +} + +#[cfg(feature = "pcap")] +fn select_interface(name: Option<&str>) -> Option { + let interfaces = datalink::interfaces(); + if let Some(name) = name { + return interfaces.into_iter().find(|iface| iface.name == name); + } + interfaces + .into_iter() + .find(|iface| iface.is_up() && !iface.is_loopback()) +} + +#[cfg(feature = "pcap")] +fn parse_dns_frame( + frame: &[u8], + start: Instant, + filter: &Option, +) -> Option { + let ethernet = EthernetPacket::new(frame)?; + match ethernet.get_ethertype() { + EtherTypes::Ipv4 => parse_ipv4(ethernet.payload(), start, filter), + EtherTypes::Ipv6 => parse_ipv6(ethernet.payload(), start, filter), + _ => None, + } +} + +#[cfg(feature = "pcap")] +fn parse_ipv4( + payload: &[u8], + start: Instant, + filter: &Option, +) -> Option { + let ipv4 = Ipv4Packet::new(payload)?; + if ipv4.get_next_level_protocol() != IpNextHeaderProtocols::Udp { + return None; + } + let udp = UdpPacket::new(ipv4.payload())?; + parse_dns_packet( + ipv4.get_source().into(), + ipv4.get_destination().into(), + &udp, + start, + filter, + ) +} + +#[cfg(feature = "pcap")] +fn parse_ipv6( + payload: &[u8], + start: Instant, + filter: &Option, +) -> Option { + let ipv6 = Ipv6Packet::new(payload)?; + if ipv6.get_next_header() != IpNextHeaderProtocols::Udp { + return None; + } + let udp = UdpPacket::new(ipv6.payload())?; + parse_dns_packet( + ipv6.get_source().into(), + ipv6.get_destination().into(), + &udp, + start, + filter, + ) +} + +#[cfg(feature = "pcap")] +fn parse_dns_packet( + src: IpAddr, + dst: IpAddr, + udp: &UdpPacket<'_>, + start: Instant, + filter: &Option, +) -> Option { + let src_port = udp.get_source(); + let dst_port = udp.get_destination(); + if src_port != 53 && dst_port != 53 { + return None; + } + + let message = Message::from_vec(udp.payload()).ok()?; + let query = message.queries().first()?; + let query_name = query.name().to_utf8(); + if let Some(filter) = filter.as_ref() { + if !query_name.to_ascii_lowercase().contains(filter) { + return None; + } + } + let query_type = query.query_type().to_string(); + let rcode = message.response_code().to_string(); + let answers = message + .answers() + .iter() + .filter_map(|record| record.data().and_then(format_rdata)) + .collect::>(); + + Some(DnsWatchEvent { + timestamp_ms: start.elapsed().as_millis(), + src: src.to_string(), + dst: dst.to_string(), + query_name, + query_type, + rcode, + answers, + is_response: message.message_type() == MessageType::Response, + }) +} + +fn parse_record_type(value: &str) -> Result { + value + .parse::() + .map_err(|_| DnsError::RecordType(value.to_string())) +} + +fn format_rdata(data: &RData) -> Option { + match data { + RData::A(addr) => Some(addr.to_string()), + RData::AAAA(addr) => Some(addr.to_string()), + RData::CNAME(name) => Some(name.to_string()), + RData::NS(name) => Some(name.to_string()), + RData::MX(mx) => Some(format!("{} {}", mx.preference(), mx.exchange())), + RData::TXT(txt) => Some( + txt.txt_data() + .iter() + .map(|part| String::from_utf8_lossy(part).to_string()) + .collect::>() + .join(" "), + ), + _ => None, + } +} + +fn evaluate_detect( + domain: &str, + results: &[DnsQueryReport], + evidence: &mut Vec, +) -> String { + if results.is_empty() { + evidence.push(DnsDetectEvidence { + code: "NO_RESULTS".to_string(), + message: "no dns results returned".to_string(), + }); + return "inconclusive".to_string(); + } + + let mut rcodes = BTreeSet::new(); + let mut answer_sets = BTreeSet::new(); + let mut ttl_values = Vec::new(); + let mut private_hits = Vec::new(); + + for report in results { + rcodes.insert(report.rcode.clone()); + let mut answers = BTreeSet::new(); + for answer in &report.answers { + answers.insert(answer.data.clone()); + if let Ok(ip) = answer.data.parse::() { + if is_private_or_reserved(ip) { + private_hits.push(ip.to_string()); + } + } + ttl_values.push(answer.ttl); + } + answer_sets.insert(answers); + } + + if rcodes.len() > 1 { + evidence.push(DnsDetectEvidence { + code: "RCODE_DIVERGENCE".to_string(), + message: format!("rcodes differed for {}", domain), + }); + } + + if answer_sets.len() > 1 { + evidence.push(DnsDetectEvidence { + code: "ANSWER_DIVERGENCE".to_string(), + message: format!("answers diverged for {}", domain), + }); + } + + if !private_hits.is_empty() { + evidence.push(DnsDetectEvidence { + code: "PRIVATE_RESULT".to_string(), + message: format!("private/reserved answers: {}", private_hits.join(", ")), + }); + } + + let ttl_span = ttl_span(&ttl_values); + if ttl_span > 3600 { + evidence.push(DnsDetectEvidence { + code: "TTL_VARIANCE".to_string(), + message: format!("ttl variance high: {ttl_span}s"), + }); + } + + if evidence.is_empty() { + "clean".to_string() + } else if evidence.len() >= 2 { + "suspicious".to_string() + } else { + "inconclusive".to_string() + } +} + +fn ttl_span(values: &[u32]) -> u32 { + let min = values.iter().min().copied().unwrap_or(0); + let max = values.iter().max().copied().unwrap_or(0); + max.saturating_sub(min) +} + +fn is_private_or_reserved(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + } + IpAddr::V6(v6) => { + v6.is_loopback() || v6.is_unique_local() || v6.is_unspecified() + } + } +} diff --git a/crates/wtfnet-geoip/Cargo.toml b/crates/wtfnet-geoip/Cargo.toml new file mode 100644 index 0000000..3cb7888 --- /dev/null +++ b/crates/wtfnet-geoip/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "wtfnet-geoip" +version = "0.1.0" +edition = "2024" + +[dependencies] +maxminddb = "0.24" +serde = { version = "1", features = ["derive"] } +wtfnet-core = { path = "../wtfnet-core" } diff --git a/crates/wtfnet-geoip/src/lib.rs b/crates/wtfnet-geoip/src/lib.rs new file mode 100644 index 0000000..83e3d7e --- /dev/null +++ b/crates/wtfnet-geoip/src/lib.rs @@ -0,0 +1,98 @@ +use maxminddb::geoip2; +use maxminddb::Reader; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeoIpRecord { + pub ip: String, + pub country: Option, + pub asn: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CountryInfo { + pub iso_code: Option, + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AsnInfo { + pub number: Option, + pub organization: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeoIpStatus { + pub country_db: Option, + pub asn_db: Option, + pub country_loaded: bool, + pub asn_loaded: bool, +} + +pub struct GeoIpService { + country_db: Option<(Reader>, PathBuf)>, + asn_db: Option<(Reader>, PathBuf)>, +} + +impl GeoIpService { + pub fn new(country_path: Option, asn_path: Option) -> Self { + let country_db = country_path + .as_ref() + .and_then(|path| load_db(path).map(|db| (db, path.clone()))); + let asn_db = asn_path + .as_ref() + .and_then(|path| load_db(path).map(|db| (db, path.clone()))); + Self { country_db, asn_db } + } + + pub fn status(&self) -> GeoIpStatus { + GeoIpStatus { + country_db: self.country_db.as_ref().map(|(_, path)| path.display().to_string()), + asn_db: self.asn_db.as_ref().map(|(_, path)| path.display().to_string()), + country_loaded: self.country_db.is_some(), + asn_loaded: self.asn_db.is_some(), + } + } + + pub fn lookup(&self, ip: IpAddr) -> GeoIpRecord { + let country = self + .country_db + .as_ref() + .and_then(|(db, _)| lookup_country(db, ip)); + let asn = self.asn_db.as_ref().and_then(|(db, _)| lookup_asn(db, ip)); + GeoIpRecord { + ip: ip.to_string(), + country, + asn, + } + } +} + +fn load_db(path: &Path) -> Option>> { + let bytes = std::fs::read(path).ok()?; + Reader::from_source(bytes).ok() +} + +fn lookup_country(db: &Reader>, ip: IpAddr) -> Option { + let data: geoip2::Country = db.lookup(ip).ok()?; + let country = data.country?; + Some(CountryInfo { + iso_code: country.iso_code.map(|value| value.to_string()), + name: country + .names + .as_ref() + .and_then(|names| names.get("en").map(|value| value.to_string())), + }) +} + +fn lookup_asn(db: &Reader>, ip: IpAddr) -> Option { + let data: geoip2::Asn = db.lookup(ip).ok()?; + Some(AsnInfo { + number: data.autonomous_system_number.map(|value| value as u32), + organization: data + .autonomous_system_organization + .map(|value| value.to_string()), + }) +} diff --git a/crates/wtfnet-platform-linux/src/lib.rs b/crates/wtfnet-platform-linux/src/lib.rs index 05f1d25..f17eb4f 100644 --- a/crates/wtfnet-platform-linux/src/lib.rs +++ b/crates/wtfnet-platform-linux/src/lib.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}; use sha2::Digest; +use std::collections::HashMap; use std::sync::Arc; use wtfnet_core::ErrorCode; use wtfnet_platform::{ @@ -189,7 +190,11 @@ fn parse_ipv6_hex(value: &str) -> Option { Some(std::net::Ipv6Addr::from(bytes)) } -fn parse_linux_tcp(path: &str, is_v6: bool) -> Result, PlatformError> { +fn parse_linux_tcp_with_inode_map( + path: &str, + is_v6: bool, + inode_map: &HashMap, +) -> 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(); @@ -206,15 +211,28 @@ fn parse_linux_tcp(path: &str, is_v6: bool) -> Result, Platfor if state != "0A" { continue; } + let inode = parts.get(9).copied(); if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) { + let (pid, ppid, process_name, process_path) = + inode.and_then(|value| inode_map.get(value)).map_or( + (None, None, None, None), + |info| { + ( + Some(info.pid), + info.ppid, + info.name.clone(), + info.path.clone(), + ) + }, + ); sockets.push(ListenSocket { proto: "tcp".to_string(), local_addr, state: Some("LISTEN".to_string()), - pid: None, - ppid: None, - process_name: None, - process_path: None, + pid, + ppid, + process_name, + process_path, owner: None, }); } @@ -222,7 +240,11 @@ fn parse_linux_tcp(path: &str, is_v6: bool) -> Result, Platfor Ok(sockets) } -fn parse_linux_udp(path: &str, is_v6: bool) -> Result, PlatformError> { +fn parse_linux_udp_with_inode_map( + path: &str, + is_v6: bool, + inode_map: &HashMap, +) -> 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(); @@ -235,15 +257,28 @@ fn parse_linux_udp(path: &str, is_v6: bool) -> Result, Platfor continue; } let local = parts[1]; + let inode = parts.get(9).copied(); if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) { + let (pid, ppid, process_name, process_path) = + inode.and_then(|value| inode_map.get(value)).map_or( + (None, None, None, None), + |info| { + ( + Some(info.pid), + info.ppid, + info.name.clone(), + info.path.clone(), + ) + }, + ); sockets.push(ListenSocket { proto: "udp".to_string(), local_addr, state: None, - pid: None, - ppid: None, - process_name: None, - process_path: None, + pid, + ppid, + process_name, + process_path, owner: None, }); } @@ -298,6 +333,80 @@ fn extract_port(value: &str) -> Option { None } +#[derive(Clone)] +struct ProcInfo { + pid: u32, + ppid: Option, + name: Option, + path: Option, +} + +fn build_inode_map() -> HashMap { + let mut map = HashMap::new(); + let entries = match std::fs::read_dir("/proc") { + Ok(entries) => entries, + Err(_) => return map, + }; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let name = match file_name.to_str() { + Some(name) => name, + None => continue, + }; + let pid = match name.parse::() { + Ok(pid) => pid, + Err(_) => continue, + }; + + let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)) + .ok() + .map(|value| value.trim().to_string()); + let path = std::fs::read_link(format!("/proc/{}/exe", pid)) + .ok() + .and_then(|value| value.to_str().map(|s| s.to_string())); + let ppid = read_ppid(pid); + + let info = ProcInfo { + pid, + ppid, + name: comm, + path, + }; + + let fd_dir = match std::fs::read_dir(format!("/proc/{}/fd", pid)) { + Ok(dir) => dir, + Err(_) => continue, + }; + + for fd in fd_dir.flatten() { + if let Ok(target) = std::fs::read_link(fd.path()) { + if let Some(target) = target.to_str() { + if let Some(inode) = parse_socket_inode(target) { + map.entry(inode).or_insert_with(|| info.clone()); + } + } + } + } + } + map +} + +fn parse_socket_inode(value: &str) -> Option { + let value = value.strip_prefix("socket:[")?; + let value = value.strip_suffix(']')?; + Some(value.to_string()) +} + +fn read_ppid(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; + let end = stat.rfind(')')?; + let rest = stat.get(end + 2..)?; + let mut parts = rest.split_whitespace(); + let _state = parts.next()?; + let ppid = parts.next()?.parse::().ok()?; + Some(ppid) +} + 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()))?; @@ -377,11 +486,28 @@ fn format_fingerprint(bytes: &[u8]) -> String { #[async_trait] impl PortsProvider for LinuxPortsProvider { async fn listening(&self) -> Result, PlatformError> { + let inode_map = build_inode_map(); 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)?); + sockets.extend(parse_linux_tcp_with_inode_map( + "/proc/net/tcp", + false, + &inode_map, + )?); + sockets.extend(parse_linux_tcp_with_inode_map( + "/proc/net/tcp6", + true, + &inode_map, + )?); + sockets.extend(parse_linux_udp_with_inode_map( + "/proc/net/udp", + false, + &inode_map, + )?); + sockets.extend(parse_linux_udp_with_inode_map( + "/proc/net/udp6", + true, + &inode_map, + )?); Ok(sockets) } diff --git a/crates/wtfnet-platform-windows/src/lib.rs b/crates/wtfnet-platform-windows/src/lib.rs index 8f1b058..e0d8b9c 100644 --- a/crates/wtfnet-platform-windows/src/lib.rs +++ b/crates/wtfnet-platform-windows/src/lib.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}; use regex::Regex; +use std::collections::HashMap; 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, @@ -302,6 +303,7 @@ fn parse_ipconfig_dns(text: &str) -> DnsConfigSnapshot { } fn parse_windows_listeners() -> Result, PlatformError> { + let proc_map = load_windows_process_map(); let output = std::process::Command::new("netstat") .arg("-ano") .output() @@ -316,11 +318,13 @@ fn parse_windows_listeners() -> Result, PlatformError> { for line in text.lines() { let trimmed = line.trim(); if trimmed.starts_with("TCP") { - if let Some(socket) = parse_netstat_tcp_line(trimmed) { + if let Some(mut socket) = parse_netstat_tcp_line(trimmed) { + enrich_socket(&mut socket, &proc_map); sockets.push(socket); } } else if trimmed.starts_with("UDP") { - if let Some(socket) = parse_netstat_udp_line(trimmed) { + if let Some(mut socket) = parse_netstat_udp_line(trimmed) { + enrich_socket(&mut socket, &proc_map); sockets.push(socket); } } @@ -414,6 +418,103 @@ fn extract_port(value: &str) -> Option { None } +fn enrich_socket(socket: &mut ListenSocket, map: &HashMap) { + let pid = match socket.pid { + Some(pid) => pid, + None => return, + }; + if let Some(info) = map.get(&pid) { + socket.process_name = info.name.clone(); + socket.process_path = info.path.clone(); + } +} + +#[derive(Clone)] +struct ProcInfo { + name: Option, + path: Option, +} + +fn load_windows_process_map() -> HashMap { + let mut map = HashMap::new(); + let mut name_map = HashMap::new(); + let tasklist = std::process::Command::new("tasklist") + .args(["/fo", "csv", "/nh"]) + .output(); + if let Ok(output) = tasklist { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let parts = parse_csv_line(line); + if parts.len() < 2 { + continue; + } + if let Ok(pid) = parts[1].parse::() { + name_map.insert(pid, parts[0].to_string()); + } + } + } + } + + let wmic = std::process::Command::new("wmic") + .args(["process", "get", "ProcessId,ExecutablePath", "/FORMAT:CSV"]) + .output(); + if let Ok(output) = wmic { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let parts = parse_csv_line(line); + if parts.len() < 3 { + continue; + } + let path = parts[1].trim(); + let pid = parts[2].trim().parse::().ok(); + if let Some(pid) = pid { + let name = name_map.get(&pid).cloned(); + let path = if path.is_empty() { + None + } else { + Some(path.to_string()) + }; + map.insert(pid, ProcInfo { name, path }); + } + } + } + } + + for (pid, name) in name_map { + map.entry(pid) + .or_insert_with(|| ProcInfo { + name: Some(name), + path: None, + }); + } + + map +} + +fn parse_csv_line(line: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + for ch in line.chars() { + match ch { + '"' => { + in_quotes = !in_quotes; + } + ',' if !in_quotes => { + out.push(current.trim_matches('"').to_string()); + current.clear(); + } + _ => current.push(ch), + } + } + if !current.is_empty() { + out.push(current.trim_matches('"').to_string()); + } + out +} + 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()))?; diff --git a/crates/wtfnet-probe/Cargo.toml b/crates/wtfnet-probe/Cargo.toml new file mode 100644 index 0000000..cffbfdf --- /dev/null +++ b/crates/wtfnet-probe/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wtfnet-probe" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +pnet = "0.34" +socket2 = "0.6" +thiserror = "2" +tokio = { version = "1", features = ["net", "time"] } +surge-ping = "0.8" +wtfnet-geoip = { path = "../wtfnet-geoip" } diff --git a/crates/wtfnet-probe/src/lib.rs b/crates/wtfnet-probe/src/lib.rs new file mode 100644 index 0000000..b268ad4 --- /dev/null +++ b/crates/wtfnet-probe/src/lib.rs @@ -0,0 +1,520 @@ +#[cfg(unix)] +use pnet::packet::icmp::{IcmpPacket, IcmpTypes}; +#[cfg(unix)] +use pnet::packet::icmpv6::{Icmpv6Packet, Icmpv6Types}; +#[cfg(unix)] +use pnet::packet::ip::IpNextHeaderProtocols; +#[cfg(unix)] +use pnet::transport::{ + icmp_packet_iter, icmpv6_packet_iter, transport_channel, TransportChannelType, + TransportProtocol, +}; +use serde::{Deserialize, Serialize}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::net::{IpAddr, SocketAddr}; +use std::time::{Duration, Instant}; +use thiserror::Error; +use tokio::net::{lookup_host, TcpStream}; +use tokio::time::timeout; +use wtfnet_geoip::GeoIpRecord; + +#[derive(Debug, Error)] +pub enum ProbeError { + #[error("resolution failed: {0}")] + Resolve(String), + #[error("io error: {0}")] + Io(String), + #[error("timeout")] + Timeout, + #[error("ping error: {0}")] + Ping(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingResult { + pub seq: u16, + pub rtt_ms: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingSummary { + pub sent: u32, + pub received: u32, + pub loss_pct: f64, + pub min_ms: Option, + pub avg_ms: Option, + pub max_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingReport { + pub target: String, + pub ip: Option, + pub geoip: Option, + pub timeout_ms: u64, + pub count: u32, + pub results: Vec, + pub summary: PingSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpPingResult { + pub seq: u16, + pub rtt_ms: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpPingReport { + pub target: String, + pub ip: Option, + pub geoip: Option, + pub port: u16, + pub timeout_ms: u64, + pub count: u32, + pub results: Vec, + pub summary: PingSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraceHop { + pub ttl: u8, + pub addr: Option, + pub rtt_ms: Option, + pub note: Option, + pub geoip: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraceReport { + pub target: String, + pub ip: Option, + pub geoip: Option, + pub port: u16, + pub max_hops: u8, + pub timeout_ms: u64, + pub protocol: String, + pub hops: Vec, +} + +pub async fn ping( + target: &str, + count: u32, + timeout_ms: u64, + interval_ms: u64, +) -> Result { + let addr = resolve_one(target).await?; + let mut results = Vec::new(); + let mut received = 0u32; + let mut min = None; + let mut max = None; + let mut sum = 0u128; + + let config = match addr { + IpAddr::V4(_) => surge_ping::Config::default(), + IpAddr::V6(_) => surge_ping::Config::builder() + .kind(surge_ping::ICMP::V6) + .build(), + }; + let client = surge_ping::Client::new(&config) + .map_err(|err| ProbeError::Ping(err.to_string()))?; + let mut pinger = client + .pinger(addr, surge_ping::PingIdentifier(0)) + .await; + let timeout_dur = Duration::from_millis(timeout_ms); + + for seq in 0..count { + let seq = seq as u16; + let start = Instant::now(); + let response = + timeout(timeout_dur, pinger.ping(surge_ping::PingSequence(seq), &[0; 8])).await; + match response { + Ok(Ok((_packet, _))) => { + let rtt = start.elapsed().as_millis(); + received += 1; + min = Some(min.map_or(rtt, |value: u128| value.min(rtt))); + max = Some(max.map_or(rtt, |value: u128| value.max(rtt))); + sum += rtt; + results.push(PingResult { + seq, + rtt_ms: Some(rtt), + error: None, + }); + } + Ok(Err(err)) => { + results.push(PingResult { + seq, + rtt_ms: None, + error: Some(err.to_string()), + }); + } + Err(_) => { + results.push(PingResult { + seq, + rtt_ms: None, + error: Some("timeout".to_string()), + }); + } + } + if interval_ms > 0 { + tokio::time::sleep(Duration::from_millis(interval_ms)).await; + } + } + + let summary = build_summary(count, received, min, max, sum); + Ok(PingReport { + target: target.to_string(), + ip: Some(addr.to_string()), + geoip: None, + timeout_ms, + count, + results, + summary, + }) +} + +pub async fn tcp_ping( + target: &str, + port: u16, + count: u32, + timeout_ms: u64, +) -> Result { + let addr = resolve_one(target).await?; + let socket_addr = SocketAddr::new(addr, port); + let timeout_dur = Duration::from_millis(timeout_ms); + let mut results = Vec::new(); + let mut received = 0u32; + let mut min = None; + let mut max = None; + let mut sum = 0u128; + + for seq in 0..count { + let seq = seq as u16; + let start = Instant::now(); + let attempt = timeout(timeout_dur, TcpStream::connect(socket_addr)).await; + match attempt { + Ok(Ok(_stream)) => { + let rtt = start.elapsed().as_millis(); + received += 1; + min = Some(min.map_or(rtt, |value: u128| value.min(rtt))); + max = Some(max.map_or(rtt, |value: u128| value.max(rtt))); + sum += rtt; + results.push(TcpPingResult { + seq, + rtt_ms: Some(rtt), + error: None, + }); + } + Ok(Err(err)) => { + results.push(TcpPingResult { + seq, + rtt_ms: None, + error: Some(err.to_string()), + }); + } + Err(_) => { + results.push(TcpPingResult { + seq, + rtt_ms: None, + error: Some("timeout".to_string()), + }); + } + } + } + + let summary = build_summary(count, received, min, max, sum); + Ok(TcpPingReport { + target: target.to_string(), + ip: Some(addr.to_string()), + geoip: None, + port, + timeout_ms, + count, + results, + summary, + }) +} + +pub async fn tcp_trace( + target: &str, + port: u16, + max_hops: u8, + timeout_ms: u64, +) -> Result { + let addr = resolve_one(target).await?; + let socket_addr = SocketAddr::new(addr, port); + let timeout_dur = Duration::from_millis(timeout_ms); + let mut hops = Vec::new(); + + for ttl in 1..=max_hops { + let addr = socket_addr; + let start = Instant::now(); + let result = tokio::task::spawn_blocking(move || { + tcp_connect_with_ttl(addr, ttl, timeout_dur) + }) + .await + .map_err(|err| ProbeError::Io(err.to_string()))?; + + match result { + Ok(()) => { + let rtt = start.elapsed().as_millis(); + hops.push(TraceHop { + ttl, + addr: Some(socket_addr.ip().to_string()), + rtt_ms: Some(rtt), + note: None, + geoip: None, + }); + break; + } + Err(err) => { + let rtt = start.elapsed().as_millis(); + hops.push(TraceHop { + ttl, + addr: None, + rtt_ms: Some(rtt), + note: Some(err.to_string()), + geoip: None, + }); + } + } + } + + Ok(TraceReport { + target: target.to_string(), + ip: Some(addr.to_string()), + geoip: None, + port, + max_hops, + timeout_ms, + protocol: "tcp".to_string(), + hops, + }) +} + +pub async fn udp_trace( + target: &str, + port: u16, + max_hops: u8, + timeout_ms: u64, +) -> Result { + let addr = resolve_one(target).await?; + + let timeout_dur = Duration::from_millis(timeout_ms); + let mut hops = Vec::new(); + + for ttl in 1..=max_hops { + let addr = SocketAddr::new(addr, port); + let start = Instant::now(); + let result = tokio::task::spawn_blocking(move || udp_trace_hop(addr, ttl, timeout_dur)) + .await + .map_err(|err| ProbeError::Io(err.to_string()))?; + + match result { + Ok((hop_addr, reached)) => { + let rtt = start.elapsed().as_millis(); + hops.push(TraceHop { + ttl, + addr: hop_addr.map(|ip| ip.to_string()), + rtt_ms: Some(rtt), + note: None, + geoip: None, + }); + if reached { + break; + } + } + Err(err) => { + hops.push(TraceHop { + ttl, + addr: None, + rtt_ms: None, + note: Some(err.to_string()), + geoip: None, + }); + } + } + } + + Ok(TraceReport { + target: target.to_string(), + ip: Some(addr.to_string()), + geoip: None, + port, + max_hops, + timeout_ms, + protocol: "udp".to_string(), + hops, + }) +} + +fn build_summary( + sent: u32, + received: u32, + min: Option, + max: Option, + sum: u128, +) -> PingSummary { + let loss_pct = if sent == 0 { + 0.0 + } else { + ((sent - received) as f64 / sent as f64) * 100.0 + }; + let avg_ms = if received == 0 { + None + } else { + Some(sum as f64 / received as f64) + }; + PingSummary { + sent, + received, + loss_pct, + min_ms: min, + avg_ms, + max_ms: max, + } +} + +async fn resolve_one(target: &str) -> Result { + let mut iter = lookup_host((target, 0)) + .await + .map_err(|err| ProbeError::Resolve(err.to_string()))?; + iter.next() + .map(|addr| addr.ip()) + .ok_or_else(|| ProbeError::Resolve("no address found".to_string())) +} + +fn tcp_connect_with_ttl( + addr: SocketAddr, + ttl: u8, + timeout: Duration, +) -> Result<(), ProbeError> { + let domain = match addr.ip() { + IpAddr::V4(_) => Domain::IPV4, + IpAddr::V6(_) => Domain::IPV6, + }; + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP)) + .map_err(|err| ProbeError::Io(err.to_string()))?; + match addr.ip() { + IpAddr::V4(_) => socket + .set_ttl_v4(u32::from(ttl)) + .map_err(|err| ProbeError::Io(err.to_string()))?, + IpAddr::V6(_) => socket + .set_unicast_hops_v6(u32::from(ttl)) + .map_err(|err| ProbeError::Io(err.to_string()))?, + } + socket + .connect_timeout(&addr.into(), timeout) + .map_err(|err| ProbeError::Io(err.to_string()))?; + Ok(()) +} + +#[cfg(unix)] +fn udp_trace_hop( + addr: SocketAddr, + ttl: u8, + timeout: Duration, +) -> Result<(Option, bool), ProbeError> { + match addr.ip() { + IpAddr::V4(_) => udp_trace_hop_v4(addr, ttl, timeout), + IpAddr::V6(_) => udp_trace_hop_v6(addr, ttl, timeout), + } +} + +#[cfg(not(unix))] +fn udp_trace_hop( + _addr: SocketAddr, + _ttl: u8, + _timeout: Duration, +) -> Result<(Option, bool), ProbeError> { + Err(ProbeError::Io( + "udp trace not supported on this platform".to_string(), + )) +} + +#[cfg(unix)] +fn udp_trace_hop_v4( + addr: SocketAddr, + ttl: u8, + timeout: Duration, +) -> Result<(Option, bool), ProbeError> { + let protocol = + TransportChannelType::Layer4(TransportProtocol::Ipv4(IpNextHeaderProtocols::Icmp)); + let (_tx, mut rx) = transport_channel(4096, protocol) + .map_err(|err| ProbeError::Io(err.to_string()))?; + + let socket = std::net::UdpSocket::bind("0.0.0.0:0") + .map_err(|err| ProbeError::Io(err.to_string()))?; + socket + .set_ttl(u32::from(ttl)) + .map_err(|err| ProbeError::Io(err.to_string()))?; + let _ = socket.send_to(&[0u8; 4], addr); + + let mut iter = icmp_packet_iter(&mut rx); + match iter.next_with_timeout(timeout) { + Ok(Some((packet, addr))) => { + if let Some(result) = interpret_icmp_v4(&packet) { + return Ok((Some(addr), result)); + } + Ok((Some(addr), false)) + } + Ok(None) => Err(ProbeError::Timeout), + Err(err) => Err(ProbeError::Io(err.to_string())), + } +} + +#[cfg(unix)] +fn udp_trace_hop_v6( + addr: SocketAddr, + ttl: u8, + timeout: Duration, +) -> Result<(Option, bool), ProbeError> { + let protocol = + TransportChannelType::Layer4(TransportProtocol::Ipv6(IpNextHeaderProtocols::Icmpv6)); + let (_tx, mut rx) = transport_channel(4096, protocol) + .map_err(|err| ProbeError::Io(err.to_string()))?; + + let socket = std::net::UdpSocket::bind("[::]:0") + .map_err(|err| ProbeError::Io(err.to_string()))?; + socket + .set_unicast_hops_v6(u32::from(ttl)) + .map_err(|err| ProbeError::Io(err.to_string()))?; + let _ = socket.send_to(&[0u8; 4], addr); + + let mut iter = icmpv6_packet_iter(&mut rx); + match iter.next_with_timeout(timeout) { + Ok(Some((packet, addr))) => { + if let Some(result) = interpret_icmp_v6(&packet) { + return Ok((Some(addr), result)); + } + Ok((Some(addr), false)) + } + Ok(None) => Err(ProbeError::Timeout), + Err(err) => Err(ProbeError::Io(err.to_string())), + } +} + +#[cfg(unix)] +fn interpret_icmp_v4(packet: &IcmpPacket) -> Option { + let icmp_type = packet.get_icmp_type(); + if icmp_type == IcmpTypes::TimeExceeded { + return Some(false); + } + if icmp_type == IcmpTypes::DestinationUnreachable { + return Some(true); + } + None +} + +#[cfg(unix)] +fn interpret_icmp_v6(packet: &Icmpv6Packet) -> Option { + let icmp_type = packet.get_icmpv6_type(); + if icmp_type == Icmpv6Types::TimeExceeded { + return Some(false); + } + if icmp_type == Icmpv6Types::DestinationUnreachable { + return Some(true); + } + None +} diff --git a/docs/dns_poisoning_design.md b/docs/dns_poisoning_design.md new file mode 100644 index 0000000..77b6e8b --- /dev/null +++ b/docs/dns_poisoning_design.md @@ -0,0 +1,84 @@ +# DNS Poisoning Detection Design + +This document summarizes the current implementation approach for detecting DNS poisoning in active probing, and the planned design for passive methods. + +## Active probing (current implementation) + +### Overview +- Active probing compares answers from multiple resolvers for the same domain and record type. +- The current CLI command is `dns detect `. +- The current implementation focuses on deterministic, best-effort heuristics and avoids OS-specific parsing. + +### Inputs +- Domain name. +- Resolver list: either user-provided via `--servers` or default public resolvers. +- Transport: UDP/TCP/DoT/DoH. +- Optional SOCKS5 proxy for DoH queries (`--socks5`). +- Repeat count: `--repeat` (>= 1). +- Timeout: `--timeout-ms`. + +### Query flow +1. For each resolver and each repeat, issue a DNS A query using `hickory-resolver`. +2. Collect a `DnsQueryReport` that includes: + - `domain`, `record_type`, `transport`, `server`, `server_name`, `rcode`, `answers`, `duration_ms`. +3. Enrich results in the CLI with GeoIP: + - `server_geoip` based on the resolver IP. + - Per-answer GeoIP when answer data is an IP (A/AAAA). + +### Current heuristics +The detect verdict is derived from the following checks across all results: +- **RCODE divergence**: mismatch in response code across resolvers. +- **Answer divergence**: different answer sets across resolvers. +- **Private/reserved answers**: any A/AAAA in private/reserved space. +- **TTL variance**: wide TTL span (currently > 3600s). + +### Verdict mapping +- `clean`: no evidence found. +- `inconclusive`: only one evidence signal or no results. +- `suspicious`: two or more evidence signals. + +### Output +- JSON output returns a list of per-resolver reports plus evidence. +- Human output shows verdict, evidence, and per-resolver summaries with GeoIP. +- Reports also include transport, server name (for DoT/DoH), and proxy (if used). + +### Rationale and limitations +- This approach is deterministic and does not rely on parsing OS tools. +- False positives may occur due to legitimate geo-load balancing or CDN behavior. +- DNSSEC validation is not currently used in detection logic. + +## Passive methods (planned design) + +### Goals +- Observe DNS responses and correlate with active results. +- Identify anomalies without injecting traffic. + +### Passive data sources (feature gated) +- Packet capture via `pcap` or `pnet` (root/admin privileges needed). +- Optional system resolver logs if available (platform-specific; best-effort). + +### Planned pipeline +1. Capture DNS responses (UDP/TCP, port 53; optionally DoH/DoT if visible). +2. Parse responses into normalized records: + - `domain`, `record_type`, `rcode`, `answers`, `ttl`, `server_ip`. +3. Maintain short-term rolling windows (time-bounded) to: + - detect sudden shifts in answers + - detect private/reserved answers for public domains + - detect TTL anomalies compared to historical baseline + +### Planned heuristics +- **Answer churn**: frequent changes in answer sets beyond normal CDN variance. +- **Resolver mismatch**: passive answers conflict with known public resolver responses. +- **Suspicious IP ranges**: private/reserved or local ISP blocks where not expected. +- **Low TTL bursts**: sudden TTL drops that persist for short windows. + +### Output (planned) +- Passive summaries include: + - top domains observed + - divergence counts + - suspicious answer summaries + - optional GeoIP enrichment for answer IPs and resolver IPs + +### Privacy and safety notes +- Passive capture should be explicit and opt-in. +- Store minimal metadata and avoid payload logging beyond DNS fields. diff --git a/docs/requirement_docs.md b/docs/requirement_docs.md index 2ce67fc..f4f1dd0 100644 --- a/docs/requirement_docs.md +++ b/docs/requirement_docs.md @@ -767,6 +767,11 @@ GeoIP: * `NETTOOL_GEOIP_COUNTRY_DB` * `NETTOOL_GEOIP_ASN_DB` +Lookup order: +1) Environment variable path +2) `data/` next to the CLI binary +3) `data/` in the current working directory + Logging: * `NETTOOL_LOG_LEVEL` diff --git a/docs/status.md b/docs/status.md index d522916..2a43906 100644 --- a/docs/status.md +++ b/docs/status.md @@ -45,9 +45,22 @@ This document tracks the planned roadmap alongside the current implementation st - 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`. +- Process name/path enrichment for `ports listen/who` (Linux procfs, Windows tasklist/wmic). +- `wtfnet-geoip` crate with local mmdb lookup and CLI commands (`geoip`, `geoip status`). +- `wtfnet-probe` crate with ping/tcping and best-effort TCP trace, plus CLI commands. +- ICMP/UDP traceroute support (IPv4) via pnet. +- Probe outputs now include GeoIP by default with `--no-geoip` disable flags. +- UDP traceroute now supports IPv6 on Unix and includes per-hop RTT. +- `wtfnet-dns` crate with query/detect support wired to CLI. +- DNS query/detect output includes GeoIP enrichment for server and answer IPs. +- DNS query/detect supports DoT and DoH transports. +- DNS query/detect supports SOCKS5 proxying for DoH. +- DNS watch (passive, best-effort) implemented. +- Calc subcrate with subnet/contains/overlap/summarize wired to CLI. ### In progress -- None. +- v0.2 features: http, tls, discover, diag. ### Next -- Start additional platform/feature crates per dependency map. +- Complete remaining v0.2 crates/commands (http/tls/discover/diag/dns watch). +- Add v0.2 tests (dns detect, calc, basic http/tls smoke).