Add multiple features
This commit is contained in:
359
Cargo.lock
generated
359
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
28
README.md
28
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 <ip[:port]>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--timeout-ms <n>`
|
||||
- `dns detect`: `--servers <csv>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--repeat <n>`, `--timeout-ms <n>`
|
||||
- `dns watch`: `--duration <Ns|Nms>`, `--iface <name>`, `--filter <pattern>`
|
||||
- `http head|get`: `--timeout-ms <n>`, `--follow-redirects <n>`, `--show-headers`, `--show-body`, `--max-body-bytes <n>`, `--http1-only`, `--http2-only`, `--geoip`
|
||||
- `tls handshake|cert|verify|alpn`: `--sni <name>`, `--alpn <csv>`, `--timeout-ms <n>`, `--insecure`
|
||||
- `discover mdns`: `--duration <Ns|Nms>`, `--service <type>`
|
||||
- `discover ssdp`: `--duration <Ns|Nms>`
|
||||
- `diag`: `--out <path>`, `--bundle <path>`, `--dns-detect <domain>`, `--dns-timeout-ms <n>`, `--dns-repeat <n>`
|
||||
|
||||
## 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`).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct HttpRequestArgs {
|
||||
url: String,
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
timeout_ms: u64,
|
||||
#[arg(long)]
|
||||
follow_redirects: Option<u32>,
|
||||
#[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<String>,
|
||||
#[arg(long)]
|
||||
alpn: Option<String>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct DiscoverSsdpArgs {
|
||||
#[arg(long, default_value = "3s")]
|
||||
duration: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct DiagArgs {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
bundle: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
dns_detect: Option<String>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HttpReportGeoIp {
|
||||
pub url: String,
|
||||
pub final_url: Option<String>,
|
||||
pub method: String,
|
||||
pub status: Option<u16>,
|
||||
pub http_version: Option<String>,
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub geoip: Vec<wtfnet_geoip::GeoIpRecord>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
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::<std::net::IpAddr>().ok())
|
||||
.map(|ip| service.lookup(ip))
|
||||
.collect::<Vec<_>>();
|
||||
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<String> {
|
||||
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<T: serde::Serialize>(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<String> {
|
||||
if let Ok(_addr) = value.parse::<std::net::SocketAddr>() {
|
||||
return None;
|
||||
|
||||
12
crates/wtfnet-diag/Cargo.toml
Normal file
12
crates/wtfnet-diag/Cargo.toml
Normal file
@@ -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"
|
||||
142
crates/wtfnet-diag/src/lib.rs
Normal file
142
crates/wtfnet-diag/src/lib.rs
Normal file
@@ -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<String>,
|
||||
pub dns_detect_timeout_ms: u64,
|
||||
pub dns_detect_repeat: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagReport {
|
||||
pub interfaces: Option<Vec<NetInterface>>,
|
||||
pub routes: Option<Vec<RouteEntry>>,
|
||||
pub dns_config: Option<DnsConfigSnapshot>,
|
||||
pub ports_listen: Option<Vec<ListenSocket>>,
|
||||
pub neighbors: Option<Vec<NeighborEntry>>,
|
||||
pub dns_detect: Option<DnsDetectResult>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn run(platform: &Platform, options: DiagOptions) -> Result<DiagReport, DiagError> {
|
||||
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)
|
||||
}
|
||||
10
crates/wtfnet-discover/Cargo.toml
Normal file
10
crates/wtfnet-discover/Cargo.toml
Normal file
@@ -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"] }
|
||||
209
crates/wtfnet-discover/src/lib.rs
Normal file
209
crates/wtfnet-discover/src/lib.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub addresses: Vec<String>,
|
||||
pub port: Option<u16>,
|
||||
pub properties: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsReport {
|
||||
pub duration_ms: u64,
|
||||
pub service_type: Option<String>,
|
||||
pub services: Vec<MdnsService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SsdpService {
|
||||
pub from: String,
|
||||
pub st: Option<String>,
|
||||
pub usn: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SsdpReport {
|
||||
pub duration_ms: u64,
|
||||
pub services: Vec<SsdpService>,
|
||||
}
|
||||
|
||||
pub async fn mdns_discover(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
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<SsdpReport, DiscoverError> {
|
||||
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<MdnsReport, DiscoverError> {
|
||||
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<SsdpReport, DiscoverError> {
|
||||
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<SsdpService> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
11
crates/wtfnet-http/Cargo.toml
Normal file
11
crates/wtfnet-http/Cargo.toml
Normal file
@@ -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"
|
||||
182
crates/wtfnet-http/src/lib.rs
Normal file
182
crates/wtfnet-http/src/lib.rs
Normal file
@@ -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<u128>,
|
||||
pub connect_ms: Option<u128>,
|
||||
pub tls_ms: Option<u128>,
|
||||
pub ttfb_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpReport {
|
||||
pub url: String,
|
||||
pub final_url: Option<String>,
|
||||
pub method: String,
|
||||
pub status: Option<u16>,
|
||||
pub http_version: Option<String>,
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
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<u32>,
|
||||
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<HttpReport, HttpError> {
|
||||
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::<IpAddr>() {
|
||||
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::<IpAddr>() {
|
||||
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::<Vec<_>>()
|
||||
} 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<u16> {
|
||||
Some(status.as_u16())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
13
crates/wtfnet-tls/Cargo.toml
Normal file
13
crates/wtfnet-tls/Cargo.toml
Normal file
@@ -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"
|
||||
335
crates/wtfnet-tls/src/lib.rs
Normal file
335
crates/wtfnet-tls/src/lib.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsHandshakeReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsVerifyReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub verified: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsCertReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsAlpnReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TlsOptions {
|
||||
pub sni: Option<String>,
|
||||
pub alpn: Vec<String>,
|
||||
pub timeout_ms: u64,
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
pub async fn handshake(target: &str, options: TlsOptions) -> Result<TlsHandshakeReport, TlsError> {
|
||||
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<TlsVerifyReport, TlsError> {
|
||||
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<TlsCertReport, TlsError> {
|
||||
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<TlsAlpnReport, TlsError> {
|
||||
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::<IpAddr>() {
|
||||
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::<u16>()
|
||||
.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::<u16>()
|
||||
.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<SocketAddr, TlsError> {
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
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<TlsConnector, TlsError> {
|
||||
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<tokio_rustls::client::TlsStream<TcpStream>, 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<Vec<TlsCertSummary>, 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<TlsCertSummary, TlsError> {
|
||||
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<String> {
|
||||
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<Item = &[u8]>,
|
||||
_ocsp: &[u8],
|
||||
_now: SystemTime,
|
||||
) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
2
docs/debian_command_outcome.txt
Normal file
2
docs/debian_command_outcome.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
sudo ./target/release/wtfn dns watch --duration 5s
|
||||
iface: eno1 duration_ms: 5000 filter: -
|
||||
29
docs/implementation_status.md
Normal file
29
docs/implementation_status.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user