From cb022127c0b5610ca10207f4b4c5f84cf26309e6 Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Fri, 16 Jan 2026 23:16:58 +0800 Subject: [PATCH] Add multiple features --- Cargo.lock | 359 +++++++++++++++++++++ Cargo.toml | 4 + README.md | 28 +- crates/wtfnet-calc/src/lib.rs | 29 ++ crates/wtfnet-cli/Cargo.toml | 4 + crates/wtfnet-cli/src/main.rs | 506 ++++++++++++++++++++++++++++++ crates/wtfnet-diag/Cargo.toml | 12 + crates/wtfnet-diag/src/lib.rs | 142 +++++++++ crates/wtfnet-discover/Cargo.toml | 10 + crates/wtfnet-discover/src/lib.rs | 209 ++++++++++++ crates/wtfnet-http/Cargo.toml | 11 + crates/wtfnet-http/src/lib.rs | 182 +++++++++++ crates/wtfnet-probe/src/lib.rs | 1 + crates/wtfnet-tls/Cargo.toml | 13 + crates/wtfnet-tls/src/lib.rs | 335 ++++++++++++++++++++ docs/debian_command_outcome.txt | 2 + docs/implementation_status.md | 29 ++ docs/status.md | 11 +- 18 files changed, 1883 insertions(+), 4 deletions(-) create mode 100644 crates/wtfnet-diag/Cargo.toml create mode 100644 crates/wtfnet-diag/src/lib.rs create mode 100644 crates/wtfnet-discover/Cargo.toml create mode 100644 crates/wtfnet-discover/src/lib.rs create mode 100644 crates/wtfnet-http/Cargo.toml create mode 100644 crates/wtfnet-http/src/lib.rs create mode 100644 crates/wtfnet-tls/Cargo.toml create mode 100644 crates/wtfnet-tls/src/lib.rs create mode 100644 docs/debian_command_outcome.txt create mode 100644 docs/implementation_status.md diff --git a/Cargo.lock b/Cargo.lock index f2b203f..b52cea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -123,6 +140,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -150,12 +173,38 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.52" @@ -163,6 +212,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -172,6 +223,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.54" @@ -218,6 +279,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "core-foundation" version = "0.9.4" @@ -243,6 +319,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -305,6 +390,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -373,6 +459,28 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.10.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" +dependencies = [ + "futures-core", + "futures-sink", + "pin-project", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -580,6 +688,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -767,6 +884,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabb0019d51a643781ff15c9c8a3e5dedc365c47211270f4e8f82812fedd8f0a" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -777,6 +904,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -816,6 +952,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -901,6 +1047,19 @@ dependencies = [ "serde", ] +[[package]] +name = "mdns-sd" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b783ca6946f01ca586127c28110ed397001ae30d0fb229bfae427479fb251e3" +dependencies = [ + "flume", + "if-addrs", + "log", + "polling", + "socket2 0.4.10", +] + [[package]] name = "memchr" version = "2.7.6" @@ -919,6 +1078,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1106,12 +1275,55 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1221,6 +1433,22 @@ dependencies = [ "pnet_sys", ] +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1679,6 +1907,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" @@ -1691,6 +1925,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.5.10" @@ -1711,6 +1955,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1723,6 +1976,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "surge-ping" version = "0.8.4" @@ -2531,12 +2790,16 @@ dependencies = [ "tokio", "wtfnet-calc", "wtfnet-core", + "wtfnet-diag", + "wtfnet-discover", "wtfnet-dns", "wtfnet-geoip", + "wtfnet-http", "wtfnet-platform", "wtfnet-platform-linux", "wtfnet-platform-windows", "wtfnet-probe", + "wtfnet-tls", ] [[package]] @@ -2551,6 +2814,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "wtfnet-diag" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.17", + "wtfnet-dns", + "wtfnet-platform", + "zip", +] + +[[package]] +name = "wtfnet-discover" +version = "0.1.0" +dependencies = [ + "mdns-sd", + "serde", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "wtfnet-dns" version = "0.1.0" @@ -2573,6 +2858,17 @@ dependencies = [ "wtfnet-core", ] +[[package]] +name = "wtfnet-http" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "thiserror 2.0.17", + "tokio", + "url", +] + [[package]] name = "wtfnet-platform" version = "0.1.0" @@ -2618,6 +2914,7 @@ dependencies = [ name = "wtfnet-probe" version = "0.1.0" dependencies = [ + "libc", "pnet", "serde", "socket2 0.6.1", @@ -2627,6 +2924,19 @@ dependencies = [ "wtfnet-geoip", ] +[[package]] +name = "wtfnet-tls" +version = "0.1.0" +dependencies = [ + "rustls", + "rustls-native-certs 0.6.3", + "serde", + "thiserror 2.0.17", + "tokio", + "tokio-rustls", + "x509-parser", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -2747,8 +3057,57 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + [[package]] name = "zmij" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 06de99b..70223c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,8 @@ members = [ "crates/wtfnet-geoip", "crates/wtfnet-probe", "crates/wtfnet-dns", + "crates/wtfnet-http", + "crates/wtfnet-tls", + "crates/wtfnet-discover", + "crates/wtfnet-diag", ] diff --git a/README.md b/README.md index 645e600..cdf11ff 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,20 @@ wtfn dns query example.com A --transport doh --server 1.1.1.1 --tls-name cloudfl wtfn dns detect example.com --transport doh --servers 1.1.1.1 --tls-name cloudflare-dns.com wtfn dns watch --duration 10s --filter example.com +# TLS +wtfn tls handshake example.com:443 +wtfn tls cert example.com:443 +wtfn tls verify example.com:443 +wtfn tls alpn example.com:443 --alpn h2,http/1.1 + +# Discover +wtfn discover mdns --duration 3s +wtfn discover ssdp --duration 3s + +# Diag +wtfn diag --out report.json --json +wtfn diag --bundle report.zip + # Calc wtfn calc contains 192.168.0.0/16 192.168.1.0/24 wtfn calc overlap 10.0.0.0/24 10.0.1.0/24 @@ -69,6 +83,11 @@ Command flags (implemented): - `dns query`: `--server `, `--transport `, `--tls-name `, `--socks5 `, `--timeout-ms ` - `dns detect`: `--servers `, `--transport `, `--tls-name `, `--socks5 `, `--repeat `, `--timeout-ms ` - `dns watch`: `--duration `, `--iface `, `--filter ` +- `http head|get`: `--timeout-ms `, `--follow-redirects `, `--show-headers`, `--show-body`, `--max-body-bytes `, `--http1-only`, `--http2-only`, `--geoip` +- `tls handshake|cert|verify|alpn`: `--sni `, `--alpn `, `--timeout-ms `, `--insecure` +- `discover mdns`: `--duration `, `--service ` +- `discover ssdp`: `--duration ` +- `diag`: `--out `, `--bundle `, `--dns-detect `, `--dns-timeout-ms `, `--dns-repeat ` ## GeoIP data files GeoLite2 mmdb files should live in `data/`. @@ -118,12 +137,19 @@ Implemented: - Core CLI with JSON output and logging. - sys, ports, neigh, cert roots. - geoip, probe, dns query/detect/watch. +- http head/get with timing and GeoIP. +- tls handshake/verify/cert/alpn. - DoT/DoH + SOCKS5 proxy for DoH. +- discover mdns/ssdp. +- diag report + bundle. - calc subcrate with subnet/contains/overlap/summarize. - CMake/Makefile build + package + install targets. +- Basic unit tests for calc and TLS parsing. In progress: -- http, tls, discover, diag. +- none. + +See `docs/implementation_status.md` for a design-vs-implementation view. ## License MIT (see `LICENSE`). diff --git a/crates/wtfnet-calc/src/lib.rs b/crates/wtfnet-calc/src/lib.rs index 5c5387f..36fc130 100644 --- a/crates/wtfnet-calc/src/lib.rs +++ b/crates/wtfnet-calc/src/lib.rs @@ -200,3 +200,32 @@ fn overlap_v6(a: Ipv6Net, b: Ipv6Net) -> bool { let b_end = u128::from(b.broadcast()); a_start <= b_end && b_start <= a_end } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subnet_v4_from_mask() { + let info = subnet_info("192.168.1.10 255.255.255.0").expect("subnet"); + assert_eq!(info.cidr, "192.168.1.10/24"); + assert_eq!(info.network, "192.168.1.0"); + assert_eq!(info.broadcast.as_deref(), Some("192.168.1.255")); + assert_eq!(info.usable_addresses, "254"); + } + + #[test] + fn contains_and_overlap() { + assert!(contains("192.168.0.0/16", "192.168.1.0/24").unwrap()); + assert!(overlap("10.0.0.0/24", "10.0.0.128/25").unwrap()); + assert!(!overlap("10.0.0.0/24", "10.0.1.0/24").unwrap()); + } + + #[test] + fn summarize_ipv4() { + let inputs = vec!["10.0.0.0/24".to_string(), "10.0.1.0/24".to_string()]; + let result = summarize(&inputs).expect("summarize"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].to_string(), "10.0.0.0/23"); + } +} diff --git a/crates/wtfnet-cli/Cargo.toml b/crates/wtfnet-cli/Cargo.toml index 1d2bde0..6cff6e7 100644 --- a/crates/wtfnet-cli/Cargo.toml +++ b/crates/wtfnet-cli/Cargo.toml @@ -18,6 +18,10 @@ wtfnet-geoip = { path = "../wtfnet-geoip" } wtfnet-platform = { path = "../wtfnet-platform" } wtfnet-probe = { path = "../wtfnet-probe" } wtfnet-dns = { path = "../wtfnet-dns", features = ["pcap"] } +wtfnet-http = { path = "../wtfnet-http" } +wtfnet-tls = { path = "../wtfnet-tls" } +wtfnet-discover = { path = "../wtfnet-discover" } +wtfnet-diag = { path = "../wtfnet-diag" } [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 a0f9846..d0ac63f 100644 --- a/crates/wtfnet-cli/src/main.rs +++ b/crates/wtfnet-cli/src/main.rs @@ -70,6 +70,19 @@ enum Commands { #[command(subcommand)] command: CalcCommand, }, + Http { + #[command(subcommand)] + command: HttpCommand, + }, + Tls { + #[command(subcommand)] + command: TlsCommand, + }, + Discover { + #[command(subcommand)] + command: DiscoverCommand, + }, + Diag(DiagArgs), } #[derive(Subcommand, Debug)] @@ -124,6 +137,26 @@ enum CalcCommand { Summarize(CalcSummarizeArgs), } +#[derive(Subcommand, Debug)] +enum HttpCommand { + Head(HttpRequestArgs), + Get(HttpRequestArgs), +} + +#[derive(Subcommand, Debug)] +enum TlsCommand { + Handshake(TlsArgs), + Cert(TlsArgs), + Verify(TlsArgs), + Alpn(TlsArgs), +} + +#[derive(Subcommand, Debug)] +enum DiscoverCommand { + Mdns(DiscoverMdnsArgs), + Ssdp(DiscoverSsdpArgs), +} + #[derive(Parser, Debug, Clone)] struct SysIpArgs { #[arg(long)] @@ -276,6 +309,68 @@ struct CalcSummarizeArgs { cidrs: Vec, } +#[derive(Parser, Debug, Clone)] +struct HttpRequestArgs { + url: String, + #[arg(long, default_value_t = 3000)] + timeout_ms: u64, + #[arg(long)] + follow_redirects: Option, + #[arg(long)] + show_headers: bool, + #[arg(long)] + show_body: bool, + #[arg(long, default_value_t = 8192)] + max_body_bytes: usize, + #[arg(long)] + http1_only: bool, + #[arg(long)] + http2_only: bool, + #[arg(long)] + geoip: bool, +} + +#[derive(Parser, Debug, Clone)] +struct TlsArgs { + target: String, + #[arg(long)] + sni: Option, + #[arg(long)] + alpn: Option, + #[arg(long, default_value_t = 3000)] + timeout_ms: u64, + #[arg(long)] + insecure: bool, +} + +#[derive(Parser, Debug, Clone)] +struct DiscoverMdnsArgs { + #[arg(long, default_value = "3s")] + duration: String, + #[arg(long)] + service: Option, +} + +#[derive(Parser, Debug, Clone)] +struct DiscoverSsdpArgs { + #[arg(long, default_value = "3s")] + duration: String, +} + +#[derive(Parser, Debug, Clone)] +struct DiagArgs { + #[arg(long)] + out: Option, + #[arg(long)] + bundle: Option, + #[arg(long)] + dns_detect: Option, + #[arg(long, default_value_t = 2000)] + dns_timeout_ms: u64, + #[arg(long, default_value_t = 3)] + dns_repeat: u32, +} + #[derive(Debug, Clone, Serialize)] struct DnsAnswerGeoIp { pub name: String, @@ -326,6 +421,20 @@ struct CalcSummarizeReport { pub result: Vec, } +#[derive(Debug, Serialize)] +struct HttpReportGeoIp { + pub url: String, + pub final_url: Option, + pub method: String, + pub status: Option, + pub http_version: Option, + pub resolved_ips: Vec, + pub geoip: Vec, + pub headers: Vec<(String, String)>, + pub body: Option, + pub timing: wtfnet_http::HttpTiming, +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -396,6 +505,31 @@ async fn main() { Commands::Calc { command: CalcCommand::Summarize(args), } => handle_calc_summarize(&cli, args.clone()).await, + Commands::Http { + command: HttpCommand::Head(args), + } => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Head).await, + Commands::Http { + command: HttpCommand::Get(args), + } => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Get).await, + Commands::Tls { + command: TlsCommand::Handshake(args), + } => handle_tls_handshake(&cli, args.clone()).await, + Commands::Tls { + command: TlsCommand::Cert(args), + } => handle_tls_cert(&cli, args.clone()).await, + Commands::Tls { + command: TlsCommand::Verify(args), + } => handle_tls_verify(&cli, args.clone()).await, + Commands::Tls { + command: TlsCommand::Alpn(args), + } => handle_tls_alpn(&cli, args.clone()).await, + Commands::Discover { + command: DiscoverCommand::Mdns(args), + } => handle_discover_mdns(&cli, args.clone()).await, + Commands::Discover { + command: DiscoverCommand::Ssdp(args), + } => handle_discover_ssdp(&cli, args.clone()).await, + Commands::Diag(args) => handle_diag(&cli, args.clone()).await, }; std::process::exit(exit_code); @@ -1553,6 +1687,378 @@ fn split_host_port_with_default(value: &str, default_port: u16) -> Result<(Strin Ok((value.to_string(), default_port)) } +async fn handle_http_request( + cli: &Cli, + args: HttpRequestArgs, + method: wtfnet_http::HttpMethod, +) -> i32 { + let opts = wtfnet_http::HttpRequestOptions { + method, + timeout_ms: args.timeout_ms, + follow_redirects: args.follow_redirects, + max_body_bytes: args.max_body_bytes, + show_headers: args.show_headers, + show_body: args.show_body, + http1_only: args.http1_only, + http2_only: args.http2_only, + }; + + match wtfnet_http::request(&args.url, opts).await { + Ok(report) => { + let enriched = if args.geoip { + let service = geoip_service(); + let geoip = report + .resolved_ips + .iter() + .filter_map(|value| value.parse::().ok()) + .map(|ip| service.lookup(ip)) + .collect::>(); + HttpReportGeoIp { + url: report.url.clone(), + final_url: report.final_url.clone(), + method: report.method.clone(), + status: report.status, + http_version: report.http_version.clone(), + resolved_ips: report.resolved_ips.clone(), + geoip, + headers: report.headers.clone(), + body: report.body.clone(), + timing: report.timing.clone(), + } + } else { + HttpReportGeoIp { + url: report.url.clone(), + final_url: report.final_url.clone(), + method: report.method.clone(), + status: report.status, + http_version: report.http_version.clone(), + resolved_ips: report.resolved_ips.clone(), + geoip: Vec::new(), + headers: report.headers.clone(), + body: report.body.clone(), + timing: report.timing.clone(), + } + }; + + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("http request", vec![args.url]); + let envelope = CommandEnvelope::new(meta, command, enriched); + emit_json(cli, &envelope) + } else { + println!( + "status: {}", + report + .status + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) + ); + if let Some(url) = report.final_url.as_ref() { + println!("final_url: {url}"); + } + if let Some(version) = report.http_version.as_ref() { + println!("version: {version}"); + } + if !report.resolved_ips.is_empty() { + println!("resolved: {}", report.resolved_ips.join(", ")); + } + println!("total_ms: {}", report.timing.total_ms); + if let Some(ms) = report.timing.dns_ms { + println!("dns_ms: {ms}"); + } + if let Some(ms) = report.timing.connect_ms { + println!("connect_ms: {ms}"); + } + if let Some(ms) = report.timing.tls_ms { + println!("tls_ms: {ms}"); + } + if let Some(ms) = report.timing.ttfb_ms { + println!("ttfb_ms: {ms}"); + } + if args.geoip && !enriched.geoip.is_empty() { + for entry in &enriched.geoip { + println!("geoip {}: {}", entry.ip, format_geoip(entry)); + } + } + if !report.headers.is_empty() { + println!("headers:"); + for (name, value) in report.headers { + println!(" {name}: {value}"); + } + } + if let Some(body) = report.body.as_ref() { + println!("body:"); + println!("{body}"); + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("http request failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_tls_handshake(cli: &Cli, args: TlsArgs) -> i32 { + let options = build_tls_options(&args); + match wtfnet_tls::handshake(&args.target, options).await { + Ok(report) => emit_tls_report(cli, "tls handshake", report), + Err(err) => { + eprintln!("tls handshake failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_tls_cert(cli: &Cli, args: TlsArgs) -> i32 { + let options = build_tls_options(&args); + match wtfnet_tls::certs(&args.target, options).await { + Ok(report) => emit_tls_report(cli, "tls cert", report), + Err(err) => { + eprintln!("tls cert failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_tls_verify(cli: &Cli, args: TlsArgs) -> i32 { + let options = build_tls_options(&args); + match wtfnet_tls::verify(&args.target, options).await { + Ok(report) => emit_tls_report(cli, "tls verify", report), + Err(err) => { + eprintln!("tls verify failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_tls_alpn(cli: &Cli, args: TlsArgs) -> i32 { + let options = build_tls_options(&args); + match wtfnet_tls::alpn(&args.target, options).await { + Ok(report) => emit_tls_report(cli, "tls alpn", report), + Err(err) => { + eprintln!("tls alpn failed: {err}"); + ExitKind::Failed.code() + } + } +} + +fn build_tls_options(args: &TlsArgs) -> wtfnet_tls::TlsOptions { + wtfnet_tls::TlsOptions { + sni: args.sni.clone(), + alpn: parse_alpn(args.alpn.as_deref()), + timeout_ms: args.timeout_ms, + insecure: args.insecure, + } +} + +fn parse_alpn(value: Option<&str>) -> Vec { + let Some(value) = value else { return Vec::new() }; + value + .split(',') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .map(|part| part.to_string()) + .collect() +} + +fn emit_tls_report(cli: &Cli, name: &str, report: T) -> i32 { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new(name, Vec::new()); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + let value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null); + println!("{}", serde_json::to_string_pretty(&value).unwrap_or_default()); + ExitKind::Ok.code() + } +} + +async fn handle_discover_mdns(cli: &Cli, args: DiscoverMdnsArgs) -> i32 { + let duration_ms = match parse_duration_ms(&args.duration) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + }; + let options = wtfnet_discover::MdnsOptions { + duration_ms, + service_type: args.service.clone(), + }; + match wtfnet_discover::mdns_discover(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(service) = args.service { + command_args.push("--service".to_string()); + command_args.push(service); + } + let command = CommandInfo::new("discover mdns", command_args); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + for service in report.services { + println!("{}", service.fullname); + println!(" type: {}", service.service_type); + if let Some(hostname) = service.hostname { + println!(" host: {hostname}"); + } + if !service.addresses.is_empty() { + println!(" addrs: {}", service.addresses.join(", ")); + } + if let Some(port) = service.port { + println!(" port: {port}"); + } + if !service.properties.is_empty() { + println!(" props:"); + for (key, value) in service.properties { + println!(" {key}={value}"); + } + } + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("mdns discover failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_discover_ssdp(cli: &Cli, args: DiscoverSsdpArgs) -> i32 { + let duration_ms = match parse_duration_ms(&args.duration) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return ExitKind::Usage.code(); + } + }; + let options = wtfnet_discover::SsdpOptions { duration_ms }; + match wtfnet_discover::ssdp_discover(options).await { + Ok(report) => { + if cli.json { + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new( + "discover ssdp", + vec!["--duration".to_string(), args.duration], + ); + let envelope = CommandEnvelope::new(meta, command, report); + emit_json(cli, &envelope) + } else { + for service in report.services { + println!("from: {}", service.from); + if let Some(st) = service.st { + println!(" st: {st}"); + } + if let Some(usn) = service.usn { + println!(" usn: {usn}"); + } + if let Some(location) = service.location { + println!(" location: {location}"); + } + if let Some(server) = service.server { + println!(" server: {server}"); + } + } + ExitKind::Ok.code() + } + } + Err(err) => { + eprintln!("ssdp discover failed: {err}"); + ExitKind::Failed.code() + } + } +} + +async fn handle_diag(cli: &Cli, args: DiagArgs) -> i32 { + let options = wtfnet_diag::DiagOptions { + dns_detect_domain: args.dns_detect.clone(), + dns_detect_timeout_ms: args.dns_timeout_ms, + dns_detect_repeat: args.dns_repeat, + }; + let platform = platform(); + let report = match wtfnet_diag::run(&platform, options).await { + Ok(value) => value, + Err(err) => { + eprintln!("diag failed: {err}"); + return ExitKind::Failed.code(); + } + }; + + let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false); + let command = CommandInfo::new("diag", Vec::new()); + let envelope = CommandEnvelope::new(meta.clone(), command, report.clone()); + let json = if cli.pretty { + serde_json::to_string_pretty(&envelope) + } else { + serde_json::to_string(&envelope) + }; + + if let Some(out) = args.out.as_ref() { + if let Ok(payload) = json.as_ref() { + if let Err(err) = std::fs::write(out, payload) { + eprintln!("failed to write diag output: {err}"); + return ExitKind::Failed.code(); + } + } + } + + if let Some(bundle) = args.bundle.as_ref() { + let meta_json = + serde_json::to_value(&meta).unwrap_or_else(|_| serde_json::Value::Null); + let report_json = + serde_json::to_value(&report).unwrap_or_else(|_| serde_json::Value::Null); + if let Err(err) = wtfnet_diag::write_bundle(bundle, &meta_json, &report_json) { + eprintln!("failed to write bundle: {err}"); + return ExitKind::Failed.code(); + } + } + + if cli.json { + match json { + Ok(payload) => { + println!("{payload}"); + ExitKind::Ok.code() + } + Err(err) => { + eprintln!("failed to serialize json: {err}"); + ExitKind::Failed.code() + } + } + } else { + println!( + "ifaces: {} routes: {} ports: {} neigh: {} warnings: {}", + report + .interfaces + .as_ref() + .map(|value| value.len().to_string()) + .unwrap_or_else(|| "-".to_string()), + report + .routes + .as_ref() + .map(|value| value.len().to_string()) + .unwrap_or_else(|| "-".to_string()), + report + .ports_listen + .as_ref() + .map(|value| value.len().to_string()) + .unwrap_or_else(|| "-".to_string()), + report + .neighbors + .as_ref() + .map(|value| value.len().to_string()) + .unwrap_or_else(|| "-".to_string()), + report.warnings.len() + ); + ExitKind::Ok.code() + } +} + fn derive_tls_name(value: &str) -> Option { if let Ok(_addr) = value.parse::() { return None; diff --git a/crates/wtfnet-diag/Cargo.toml b/crates/wtfnet-diag/Cargo.toml new file mode 100644 index 0000000..4ca5463 --- /dev/null +++ b/crates/wtfnet-diag/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wtfnet-diag" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +wtfnet-platform = { path = "../wtfnet-platform" } +wtfnet-dns = { path = "../wtfnet-dns" } +zip = "0.6" diff --git a/crates/wtfnet-diag/src/lib.rs b/crates/wtfnet-diag/src/lib.rs new file mode 100644 index 0000000..c59fc21 --- /dev/null +++ b/crates/wtfnet-diag/src/lib.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use thiserror::Error; +use wtfnet_dns::{DnsDetectResult, DnsTransport}; +use wtfnet_platform::{DnsConfigSnapshot, ListenSocket, NetInterface, NeighborEntry, RouteEntry}; +use wtfnet_platform::{Platform, PlatformError}; +use zip::write::FileOptions; + +#[derive(Debug, Error)] +pub enum DiagError { + #[error("platform error: {0}")] + Platform(String), + #[error("dns error: {0}")] + Dns(String), + #[error("io error: {0}")] + Io(String), + #[error("zip error: {0}")] + Zip(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagOptions { + pub dns_detect_domain: Option, + pub dns_detect_timeout_ms: u64, + pub dns_detect_repeat: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagReport { + pub interfaces: Option>, + pub routes: Option>, + pub dns_config: Option, + pub ports_listen: Option>, + pub neighbors: Option>, + pub dns_detect: Option, + pub warnings: Vec, +} + +pub async fn run(platform: &Platform, options: DiagOptions) -> Result { + let mut warnings = Vec::new(); + let interfaces = match platform.sys.interfaces().await { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format_platform_error("interfaces", err)); + None + } + }; + let routes = match platform.sys.routes().await { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format_platform_error("routes", err)); + None + } + }; + let dns_config = match platform.sys.dns_config().await { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format_platform_error("dns_config", err)); + None + } + }; + let ports_listen = match platform.ports.listening().await { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format_platform_error("ports_listen", err)); + None + } + }; + let neighbors = match platform.neigh.neighbors().await { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format_platform_error("neighbors", err)); + None + } + }; + + let dns_detect = if let Some(domain) = options.dns_detect_domain.as_ref() { + match wtfnet_dns::detect( + domain, + &wtfnet_dns::default_detect_servers(DnsTransport::Udp), + DnsTransport::Udp, + None, + options.dns_detect_repeat, + options.dns_detect_timeout_ms, + ) + .await + { + Ok(value) => Some(value), + Err(err) => { + warnings.push(format!("dns_detect: {err}")); + None + } + } + } else { + None + }; + + Ok(DiagReport { + interfaces, + routes, + dns_config, + ports_listen, + neighbors, + dns_detect, + warnings, + }) +} + +pub fn write_bundle( + path: &Path, + meta_json: &Value, + report_json: &Value, +) -> Result<(), DiagError> { + let file = File::create(path).map_err(|err| DiagError::Io(err.to_string()))?; + let mut zip = zip::ZipWriter::new(file); + let options = FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + zip.start_file("meta.json", options) + .map_err(|err| DiagError::Zip(err.to_string()))?; + let meta_bytes = serde_json::to_vec_pretty(meta_json) + .map_err(|err| DiagError::Io(err.to_string()))?; + zip.write_all(&meta_bytes) + .map_err(|err| DiagError::Io(err.to_string()))?; + + zip.start_file("report.json", options) + .map_err(|err| DiagError::Zip(err.to_string()))?; + let report_bytes = serde_json::to_vec_pretty(report_json) + .map_err(|err| DiagError::Io(err.to_string()))?; + zip.write_all(&report_bytes) + .map_err(|err| DiagError::Io(err.to_string()))?; + + zip.finish() + .map_err(|err| DiagError::Zip(err.to_string()))?; + Ok(()) +} + +fn format_platform_error(section: &str, err: PlatformError) -> String { + format!("{section}: {} ({:?})", err.message, err.code) +} diff --git a/crates/wtfnet-discover/Cargo.toml b/crates/wtfnet-discover/Cargo.toml new file mode 100644 index 0000000..4eef7e1 --- /dev/null +++ b/crates/wtfnet-discover/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "wtfnet-discover" +version = "0.1.0" +edition = "2024" + +[dependencies] +mdns-sd = "0.8" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["rt"] } diff --git a/crates/wtfnet-discover/src/lib.rs b/crates/wtfnet-discover/src/lib.rs new file mode 100644 index 0000000..cc72a54 --- /dev/null +++ b/crates/wtfnet-discover/src/lib.rs @@ -0,0 +1,209 @@ +use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DiscoverError { + #[error("mdns error: {0}")] + Mdns(String), + #[error("io error: {0}")] + Io(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdnsOptions { + pub duration_ms: u64, + pub service_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsdpOptions { + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdnsService { + pub service_type: String, + pub fullname: String, + pub hostname: Option, + pub addresses: Vec, + pub port: Option, + pub properties: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdnsReport { + pub duration_ms: u64, + pub service_type: Option, + pub services: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsdpService { + pub from: String, + pub st: Option, + pub usn: Option, + pub location: Option, + pub server: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsdpReport { + pub duration_ms: u64, + pub services: Vec, +} + +pub async fn mdns_discover(options: MdnsOptions) -> Result { + tokio::task::spawn_blocking(move || mdns_discover_blocking(options)) + .await + .map_err(|err| DiscoverError::Mdns(err.to_string()))? +} + +pub async fn ssdp_discover(options: SsdpOptions) -> Result { + tokio::task::spawn_blocking(move || ssdp_discover_blocking(options)) + .await + .map_err(|err| DiscoverError::Io(err.to_string()))? +} + +fn mdns_discover_blocking(options: MdnsOptions) -> Result { + let daemon = ServiceDaemon::new().map_err(|err| DiscoverError::Mdns(err.to_string()))?; + let mut service_types = BTreeSet::new(); + if let Some(service_type) = options.service_type.as_ref() { + service_types.insert(service_type.clone()); + } else { + let receiver = daemon + .browse("_services._dns-sd._udp.local.") + .map_err(|err| DiscoverError::Mdns(err.to_string()))?; + let deadline = Instant::now() + Duration::from_millis(options.duration_ms / 2); + while Instant::now() < deadline { + match receiver.recv_timeout(Duration::from_millis(200)) { + Ok(ServiceEvent::ServiceFound(service_type, _)) => { + service_types.insert(service_type); + } + Ok(_) => {} + Err(_) => {} + } + } + } + + let mut services = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(options.duration_ms); + for service_type in service_types.iter() { + let receiver = daemon + .browse(service_type) + .map_err(|err| DiscoverError::Mdns(err.to_string()))?; + while Instant::now() < deadline { + match receiver.recv_timeout(Duration::from_millis(200)) { + Ok(ServiceEvent::ServiceResolved(info)) => { + services.push(format_service_info(service_type, &info)); + } + Ok(_) => {} + Err(_) => break, + } + } + } + + Ok(MdnsReport { + duration_ms: options.duration_ms, + service_type: options.service_type, + services, + }) +} + +fn format_service_info(service_type: &str, info: &ServiceInfo) -> MdnsService { + let mut addresses = Vec::new(); + for addr in info.get_addresses().iter() { + addresses.push(addr.to_string()); + } + let mut properties = BTreeMap::new(); + for prop in info.get_properties().iter() { + properties.insert(prop.key().to_string(), prop.val_str().to_string()); + } + MdnsService { + service_type: service_type.to_string(), + fullname: info.get_fullname().to_string(), + hostname: Some(info.get_hostname().to_string()), + addresses, + port: Some(info.get_port()), + properties, + } +} + +fn ssdp_discover_blocking(options: SsdpOptions) -> Result { + let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?; + socket + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|err| DiscoverError::Io(err.to_string()))?; + + let request = [ + "M-SEARCH * HTTP/1.1", + "HOST: 239.255.255.250:1900", + "MAN: \"ssdp:discover\"", + "MX: 1", + "ST: ssdp:all", + "", + "", + ] + .join("\r\n"); + let target = "239.255.255.250:1900"; + let _ = socket.send_to(request.as_bytes(), target); + + let mut services = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(options.duration_ms); + let mut buf = [0u8; 2048]; + + while Instant::now() < deadline { + match socket.recv_from(&mut buf) { + Ok((len, from)) => { + if let Ok(payload) = std::str::from_utf8(&buf[..len]) { + if let Some(entry) = parse_ssdp_response(payload, from) { + services.push(entry); + } + } + } + Err(_) => continue, + } + } + + Ok(SsdpReport { + duration_ms: options.duration_ms, + services, + }) +} + +fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option { + let mut st = None; + let mut usn = None; + let mut location = None; + let mut server = None; + + for line in payload.lines() { + let line = line.trim(); + if let Some((key, value)) = line.split_once(':') { + let key = key.trim().to_ascii_lowercase(); + let value = value.trim().to_string(); + match key.as_str() { + "st" => st = Some(value), + "usn" => usn = Some(value), + "location" => location = Some(value), + "server" => server = Some(value), + _ => {} + } + } + } + + if st.is_none() && usn.is_none() && location.is_none() && server.is_none() { + return None; + } + + Some(SsdpService { + from: from.to_string(), + st, + usn, + location, + server, + }) +} diff --git a/crates/wtfnet-http/Cargo.toml b/crates/wtfnet-http/Cargo.toml new file mode 100644 index 0000000..147de14 --- /dev/null +++ b/crates/wtfnet-http/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wtfnet-http" +version = "0.1.0" +edition = "2024" + +[dependencies] +reqwest = { version = "0.11", features = ["rustls-tls"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["net", "time"] } +url = "2" diff --git a/crates/wtfnet-http/src/lib.rs b/crates/wtfnet-http/src/lib.rs new file mode 100644 index 0000000..40fb9e7 --- /dev/null +++ b/crates/wtfnet-http/src/lib.rs @@ -0,0 +1,182 @@ +use reqwest::{Client, Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; +use std::time::{Duration, Instant}; +use tokio::net::lookup_host; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum HttpError { + #[error("invalid url: {0}")] + Url(String), + #[error("request error: {0}")] + Request(String), + #[error("response error: {0}")] + Response(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpTiming { + pub total_ms: u128, + pub dns_ms: Option, + pub connect_ms: Option, + pub tls_ms: Option, + pub ttfb_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpReport { + pub url: String, + pub final_url: Option, + pub method: String, + pub status: Option, + pub http_version: Option, + pub resolved_ips: Vec, + pub headers: Vec<(String, String)>, + pub body: Option, + pub timing: HttpTiming, +} + +#[derive(Debug, Clone, Copy)] +pub enum HttpMethod { + Head, + Get, +} + +impl HttpMethod { + fn to_reqwest(self) -> Method { + match self { + HttpMethod::Head => Method::HEAD, + HttpMethod::Get => Method::GET, + } + } +} + +#[derive(Debug, Clone)] +pub struct HttpRequestOptions { + pub method: HttpMethod, + pub timeout_ms: u64, + pub follow_redirects: Option, + pub max_body_bytes: usize, + pub show_headers: bool, + pub show_body: bool, + pub http1_only: bool, + pub http2_only: bool, +} + +pub async fn request(url: &str, opts: HttpRequestOptions) -> Result { + let parsed = Url::parse(url).map_err(|err| HttpError::Url(err.to_string()))?; + let host = parsed + .host_str() + .ok_or_else(|| HttpError::Url("missing host".to_string()))?; + let port = parsed + .port_or_known_default() + .ok_or_else(|| HttpError::Url("missing port".to_string()))?; + + let mut resolved_ips = Vec::new(); + let dns_start = Instant::now(); + if let Ok(ip) = host.parse::() { + resolved_ips.push(ip.to_string()); + } else { + let addrs = lookup_host((host, port)) + .await + .map_err(|err| HttpError::Request(err.to_string()))?; + for addr in addrs { + resolved_ips.push(addr.ip().to_string()); + } + resolved_ips.sort(); + resolved_ips.dedup(); + if resolved_ips.is_empty() { + return Err(HttpError::Request("no addresses resolved".to_string())); + } + } + let dns_ms = dns_start.elapsed().as_millis(); + + let mut builder = Client::builder().timeout(Duration::from_millis(opts.timeout_ms)); + builder = if let Some(max) = opts.follow_redirects { + builder.redirect(reqwest::redirect::Policy::limited(max as usize)) + } else { + builder.redirect(reqwest::redirect::Policy::none()) + }; + + if opts.http1_only { + builder = builder.http1_only(); + } + if opts.http2_only { + builder = builder.http2_prior_knowledge(); + } + + if let Some(first) = resolved_ips.first() { + if let Ok(ip) = first.parse::() { + let addr = SocketAddr::new(ip, port); + builder = builder.resolve(host, addr); + } + } + + let client = builder.build().map_err(|err| HttpError::Request(err.to_string()))?; + let start = Instant::now(); + let response = client + .request(opts.method.to_reqwest(), parsed.clone()) + .send() + .await + .map_err(|err| HttpError::Request(err.to_string()))?; + let ttfb_ms = start.elapsed().as_millis(); + + let status = response.status(); + let final_url = response.url().to_string(); + let version = response.version(); + let headers = if opts.show_headers { + response + .headers() + .iter() + .map(|(name, value)| { + let value = value.to_str().unwrap_or("-").to_string(); + (name.to_string(), value) + }) + .collect::>() + } else { + Vec::new() + }; + + let body = if opts.show_body { + let bytes = response + .bytes() + .await + .map_err(|err| HttpError::Response(err.to_string()))?; + let sliced = if bytes.len() > opts.max_body_bytes { + &bytes[..opts.max_body_bytes] + } else { + &bytes + }; + Some(String::from_utf8_lossy(sliced).to_string()) + } else { + None + }; + let total_ms = start.elapsed().as_millis(); + + Ok(HttpReport { + url: url.to_string(), + final_url: Some(final_url), + method: match opts.method { + HttpMethod::Head => "HEAD".to_string(), + HttpMethod::Get => "GET".to_string(), + }, + status: status_code(status), + http_version: Some(format!("{version:?}")), + resolved_ips, + headers, + body, + timing: HttpTiming { + total_ms, + dns_ms: Some(dns_ms), + connect_ms: None, + tls_ms: None, + ttfb_ms: Some(ttfb_ms), + }, + }) +} + +fn status_code(status: StatusCode) -> Option { + Some(status.as_u16()) +} diff --git a/crates/wtfnet-probe/src/lib.rs b/crates/wtfnet-probe/src/lib.rs index 101fd85..f8fc8e5 100644 --- a/crates/wtfnet-probe/src/lib.rs +++ b/crates/wtfnet-probe/src/lib.rs @@ -14,6 +14,7 @@ use std::os::unix::io::AsRawFd; use serde::{Deserialize, Serialize}; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{IpAddr, SocketAddr}; +#[cfg(unix)] use std::mem::size_of_val; use std::time::{Duration, Instant}; use thiserror::Error; diff --git a/crates/wtfnet-tls/Cargo.toml b/crates/wtfnet-tls/Cargo.toml new file mode 100644 index 0000000..e7a4879 --- /dev/null +++ b/crates/wtfnet-tls/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wtfnet-tls" +version = "0.1.0" +edition = "2024" + +[dependencies] +rustls = { version = "0.21", features = ["dangerous_configuration"] } +rustls-native-certs = "0.6" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["net", "time"] } +tokio-rustls = "0.24" +x509-parser = "0.16" diff --git a/crates/wtfnet-tls/src/lib.rs b/crates/wtfnet-tls/src/lib.rs new file mode 100644 index 0000000..0f98f97 --- /dev/null +++ b/crates/wtfnet-tls/src/lib.rs @@ -0,0 +1,335 @@ +use rustls::{Certificate, ClientConfig, RootCertStore, ServerName}; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_rustls::TlsConnector; +use x509_parser::prelude::{FromDer, X509Certificate}; + +#[derive(Debug, Error)] +pub enum TlsError { + #[error("invalid target: {0}")] + InvalidTarget(String), + #[error("invalid sni: {0}")] + InvalidSni(String), + #[error("io error: {0}")] + Io(String), + #[error("tls error: {0}")] + Tls(String), + #[error("parse error: {0}")] + Parse(String), + #[error("timeout")] + Timeout, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsCertSummary { + pub subject: String, + pub issuer: String, + pub not_before: String, + pub not_after: String, + pub san: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsHandshakeReport { + pub target: String, + pub sni: Option, + pub alpn_offered: Vec, + pub alpn_negotiated: Option, + pub tls_version: Option, + pub cipher: Option, + pub cert_chain: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsVerifyReport { + pub target: String, + pub sni: Option, + pub alpn_offered: Vec, + pub alpn_negotiated: Option, + pub tls_version: Option, + pub cipher: Option, + pub verified: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsCertReport { + pub target: String, + pub sni: Option, + pub cert_chain: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsAlpnReport { + pub target: String, + pub sni: Option, + pub alpn_offered: Vec, + pub alpn_negotiated: Option, +} + +#[derive(Debug, Clone)] +pub struct TlsOptions { + pub sni: Option, + pub alpn: Vec, + pub timeout_ms: u64, + pub insecure: bool, +} + +pub async fn handshake(target: &str, options: TlsOptions) -> Result { + let (addr, server_name) = parse_target(target, options.sni.as_deref())?; + let connector = build_connector(options.insecure, &options.alpn)?; + let stream = connect(addr, connector, server_name, options.timeout_ms).await?; + let (_, session) = stream.get_ref(); + + Ok(TlsHandshakeReport { + target: target.to_string(), + sni: options.sni, + alpn_offered: options.alpn.clone(), + alpn_negotiated: session + .alpn_protocol() + .map(|value| String::from_utf8_lossy(value).to_string()), + tls_version: session.protocol_version().map(|v| format!("{v:?}")), + cipher: session + .negotiated_cipher_suite() + .map(|suite| format!("{suite:?}")), + cert_chain: extract_cert_chain(session.peer_certificates())?, + }) +} + +pub async fn verify(target: &str, options: TlsOptions) -> Result { + let (addr, server_name) = parse_target(target, options.sni.as_deref())?; + let connector = build_connector(false, &options.alpn)?; + match connect(addr, connector, server_name, options.timeout_ms).await { + Ok(stream) => { + let (_, session) = stream.get_ref(); + Ok(TlsVerifyReport { + target: target.to_string(), + sni: options.sni, + alpn_offered: options.alpn.clone(), + alpn_negotiated: session + .alpn_protocol() + .map(|value| String::from_utf8_lossy(value).to_string()), + tls_version: session.protocol_version().map(|v| format!("{v:?}")), + cipher: session + .negotiated_cipher_suite() + .map(|suite| format!("{suite:?}")), + verified: true, + error: None, + }) + } + Err(err) => Ok(TlsVerifyReport { + target: target.to_string(), + sni: options.sni, + alpn_offered: options.alpn.clone(), + alpn_negotiated: None, + tls_version: None, + cipher: None, + verified: false, + error: Some(err.to_string()), + }), + } +} + +pub async fn certs(target: &str, options: TlsOptions) -> Result { + let (addr, server_name) = parse_target(target, options.sni.as_deref())?; + let connector = build_connector(options.insecure, &options.alpn)?; + let stream = connect(addr, connector, server_name, options.timeout_ms).await?; + let (_, session) = stream.get_ref(); + Ok(TlsCertReport { + target: target.to_string(), + sni: options.sni, + cert_chain: extract_cert_chain(session.peer_certificates())?, + }) +} + +pub async fn alpn(target: &str, options: TlsOptions) -> Result { + let (addr, server_name) = parse_target(target, options.sni.as_deref())?; + let connector = build_connector(options.insecure, &options.alpn)?; + let stream = connect(addr, connector, server_name, options.timeout_ms).await?; + let (_, session) = stream.get_ref(); + Ok(TlsAlpnReport { + target: target.to_string(), + sni: options.sni, + alpn_offered: options.alpn.clone(), + alpn_negotiated: session + .alpn_protocol() + .map(|value| String::from_utf8_lossy(value).to_string()), + }) +} + +fn parse_target(target: &str, sni: Option<&str>) -> Result<(SocketAddr, ServerName), TlsError> { + let (host, port) = split_host_port(target)?; + let addr = resolve_addr(&host, port)?; + let server_name = if let Some(sni) = sni { + ServerName::try_from(sni).map_err(|_| TlsError::InvalidSni(sni.to_string()))? + } else if let Ok(ip) = host.parse::() { + ServerName::IpAddress(ip) + } else { + ServerName::try_from(host.as_str()) + .map_err(|_| TlsError::InvalidSni(host.to_string()))? + }; + Ok((addr, server_name)) +} + +fn split_host_port(value: &str) -> Result<(String, u16), TlsError> { + if let Some(stripped) = value.strip_prefix('[') { + if let Some(end) = stripped.find(']') { + let host = &stripped[..end]; + let rest = &stripped[end + 1..]; + let port = rest + .strip_prefix(':') + .ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?; + let port = port + .parse::() + .map_err(|_| TlsError::InvalidTarget(value.to_string()))?; + return Ok((host.to_string(), port)); + } + } + + let mut parts = value.rsplitn(2, ':'); + let port = parts + .next() + .ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?; + let host = parts + .next() + .ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?; + if host.contains(':') { + return Err(TlsError::InvalidTarget(value.to_string())); + } + let port = port + .parse::() + .map_err(|_| TlsError::InvalidTarget(value.to_string()))?; + Ok((host.to_string(), port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_host_port_ipv4() { + let (host, port) = split_host_port("example.com:443").unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } + + #[test] + fn split_host_port_ipv6() { + let (host, port) = split_host_port("[2001:db8::1]:443").unwrap(); + assert_eq!(host, "2001:db8::1"); + assert_eq!(port, 443); + } +} + +fn resolve_addr(host: &str, port: u16) -> Result { + if let Ok(ip) = host.parse::() { + return Ok(SocketAddr::new(ip, port)); + } + let addr = std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) + .map_err(|err| TlsError::Io(err.to_string()))? + .next() + .ok_or_else(|| TlsError::InvalidTarget(host.to_string()))?; + Ok(addr) +} + +fn build_connector(insecure: bool, alpn: &[String]) -> Result { + let mut config = if insecure { + ClientConfig::builder() + .with_safe_defaults() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + .with_no_client_auth() + } else { + let mut roots = RootCertStore::empty(); + let store = rustls_native_certs::load_native_certs() + .map_err(|err| TlsError::Io(err.to_string()))?; + for cert in store { + roots + .add(&Certificate(cert.0)) + .map_err(|err| TlsError::Tls(err.to_string()))?; + } + ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(roots) + .with_no_client_auth() + }; + + if !alpn.is_empty() { + config.alpn_protocols = alpn.iter().map(|p| p.as_bytes().to_vec()).collect(); + } + + Ok(TlsConnector::from(Arc::new(config))) +} + +async fn connect( + addr: SocketAddr, + connector: TlsConnector, + server_name: ServerName, + timeout_ms: u64, +) -> Result, TlsError> { + let tcp = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)) + .await + .map_err(|_| TlsError::Timeout)? + .map_err(|err| TlsError::Io(err.to_string()))?; + let stream = timeout( + Duration::from_millis(timeout_ms), + connector.connect(server_name, tcp), + ) + .await + .map_err(|_| TlsError::Timeout)? + .map_err(|err| TlsError::Tls(err.to_string()))?; + Ok(stream) +} + +fn extract_cert_chain(certs: Option<&[Certificate]>) -> Result, TlsError> { + let mut results = Vec::new(); + if let Some(certs) = certs { + for cert in certs { + let summary = parse_cert(&cert.0)?; + results.push(summary); + } + } + Ok(results) +} + +fn parse_cert(der: &[u8]) -> Result { + let (_, cert) = + X509Certificate::from_der(der).map_err(|err| TlsError::Parse(err.to_string()))?; + Ok(TlsCertSummary { + subject: cert.subject().to_string(), + issuer: cert.issuer().to_string(), + not_before: cert.validity().not_before.to_string(), + not_after: cert.validity().not_after.to_string(), + san: extract_san(&cert), + }) +} + +fn extract_san(cert: &X509Certificate<'_>) -> Vec { + let mut result = Vec::new(); + if let Ok(Some(ext)) = cert.subject_alternative_name() { + for name in ext.value.general_names.iter() { + result.push(name.to_string()); + } + } + result +} + +struct NoVerifier; + +impl rustls::client::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &Certificate, + _intermediates: &[Certificate], + _server_name: &ServerName, + _scts: &mut dyn Iterator, + _ocsp: &[u8], + _now: SystemTime, + ) -> Result { + Ok(rustls::client::ServerCertVerified::assertion()) + } +} diff --git a/docs/debian_command_outcome.txt b/docs/debian_command_outcome.txt new file mode 100644 index 0000000..41851dc --- /dev/null +++ b/docs/debian_command_outcome.txt @@ -0,0 +1,2 @@ +sudo ./target/release/wtfn dns watch --duration 5s +iface: eno1 duration_ms: 5000 filter: - \ No newline at end of file diff --git a/docs/implementation_status.md b/docs/implementation_status.md new file mode 100644 index 0000000..27c3e1f --- /dev/null +++ b/docs/implementation_status.md @@ -0,0 +1,29 @@ +# Implementation Status vs Design + +This document tracks current implementation status against the original design in `docs/implementation_notes.md`. + +## Matches the design +- Workspace layout with feature crates (`wtfnet-core`, `wtfnet-platform`, `wtfnet-geoip`, `wtfnet-probe`, `wtfnet-dns`, `wtfnet-http`, `wtfnet-tls`, `wtfnet-discover`, `wtfnet-diag`). +- CLI remains a thin wrapper around library crates. +- Platform abstraction uses traits with OS dispatch. +- GeoIP: local GeoLite2 Country + ASN support. +- Probe: ping/tcping/trace with GeoIP enrichment. +- DNS: Hickory-based query/detect with best-effort heuristics. +- HTTP: head/get via reqwest. +- TLS: rustls-based handshake/verify/cert/alpn. +- Discover: mDNS/SSDP bounded collection. +- Diag: bundle export in zip. + +## Deviations or refinements +- DNS adds DoT/DoH and SOCKS5 proxy support (beyond initial scope). +- HTTP timing breakdown is best-effort: `dns_ms` and `ttfb_ms` are captured; `connect_ms`/`tls_ms` remain placeholders. +- DNS watch uses `pnet` and is feature-gated as best-effort. + +## Gaps vs design (as of now) +- HTTP/3 not implemented. +- TLS verification is rustls-based (no OS-native verifier). +- Discover does not include LLMNR/NBNS. + +## Current stage summary +- v0.1 scope is complete. +- v0.2 scope mostly complete; remaining are deeper test coverage and optional enhancements. diff --git a/docs/status.md b/docs/status.md index b981206..c8f6910 100644 --- a/docs/status.md +++ b/docs/status.md @@ -58,10 +58,15 @@ This document tracks the planned roadmap alongside the current implementation st - DNS watch (passive, best-effort) implemented. - Calc subcrate with subnet/contains/overlap/summarize wired to CLI. - CMake/Makefile build, install, and package targets for release packaging. +- HTTP crate with head/get support, timing breakdown, and optional GeoIP. +- TLS crate with handshake/verify/cert/alpn support in CLI. +- Discover crate with mdns/ssdp commands. +- Diag crate with report and bundle export. +- Basic unit tests for calc and TLS parsing. ### In progress -- v0.2 features: http, tls, discover, diag. +- None. ### Next -- Complete remaining v0.2 crates/commands (http/tls/discover/diag/dns watch). -- Add v0.2 tests (dns detect, calc, basic http/tls smoke). +- Add v0.2 tests (dns detect, basic http/tls smoke). +- Validate http/tls timings and improve breakdown if possible.