Compare commits
11 Commits
cfa96bde08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a23175a83 | ||
|
|
57492ab654 | ||
|
|
7054ff77a7 | ||
|
|
9bcb7549f3 | ||
|
|
1da9b915d8 | ||
|
|
94762d139a | ||
|
|
f349d4b4fa | ||
|
|
7f6ee839b2 | ||
|
|
a82a7fe2ad | ||
|
|
d5b92ede7b | ||
|
|
144e801e13 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
/data
|
/data
|
||||||
|
/dist
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
|
||||||
project(wtfnet LANGUAGES NONE)
|
|
||||||
|
|
||||||
set(CARGO_CMD cargo)
|
|
||||||
set(CARGO_TARGET_DIR "${CMAKE_BINARY_DIR}/cargo-target")
|
|
||||||
set(BIN_NAME "wtfn${CMAKE_EXECUTABLE_SUFFIX}")
|
|
||||||
set(BIN_PATH "${CARGO_TARGET_DIR}/release/${BIN_NAME}")
|
|
||||||
|
|
||||||
file(READ "${CMAKE_SOURCE_DIR}/crates/wtfnet-cli/Cargo.toml" CLI_TOML)
|
|
||||||
string(REGEX MATCH "version = \"([0-9]+\\.[0-9]+\\.[0-9]+)\"" CLI_VERSION_MATCH "${CLI_TOML}")
|
|
||||||
if(CMAKE_MATCH_1)
|
|
||||||
set(PACKAGE_VERSION "${CMAKE_MATCH_1}")
|
|
||||||
else()
|
|
||||||
set(PACKAGE_VERSION "0.1.0")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_custom_command(
|
|
||||||
OUTPUT "${BIN_PATH}"
|
|
||||||
COMMAND "${CMAKE_COMMAND}" -E env CARGO_TARGET_DIR="${CARGO_TARGET_DIR}"
|
|
||||||
"${CARGO_CMD}" build --release --workspace --bin wtfn
|
|
||||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
|
||||||
COMMENT "Building wtfn with cargo"
|
|
||||||
VERBATIM
|
|
||||||
)
|
|
||||||
|
|
||||||
add_custom_target(wtfnet_build ALL DEPENDS "${BIN_PATH}")
|
|
||||||
|
|
||||||
install(PROGRAMS "${BIN_PATH}" DESTINATION bin)
|
|
||||||
install(DIRECTORY "${CMAKE_SOURCE_DIR}/data" DESTINATION share/wtfnet)
|
|
||||||
|
|
||||||
add_dependencies(install wtfnet_build)
|
|
||||||
|
|
||||||
set(CPACK_PACKAGE_NAME "wtfnet")
|
|
||||||
set(CPACK_PACKAGE_VERSION "${PACKAGE_VERSION}")
|
|
||||||
set(CPACK_PACKAGE_FILE_NAME "wtfnet-${PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
|
|
||||||
if(WIN32)
|
|
||||||
set(CPACK_GENERATOR "ZIP")
|
|
||||||
else()
|
|
||||||
set(CPACK_GENERATOR "TGZ")
|
|
||||||
endif()
|
|
||||||
include(CPack)
|
|
||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -2230,6 +2230,15 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -2504,6 +2513,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2 0.6.1",
|
"socket2 0.6.1",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3225,7 +3235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wtfnet-cli"
|
name = "wtfnet-cli"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -1,18 +0,0 @@
|
|||||||
BUILD_DIR ?= build
|
|
||||||
|
|
||||||
.PHONY: build configure package install clean
|
|
||||||
|
|
||||||
configure:
|
|
||||||
cmake -S . -B $(BUILD_DIR)
|
|
||||||
|
|
||||||
build: configure
|
|
||||||
cmake --build $(BUILD_DIR)
|
|
||||||
|
|
||||||
package: build
|
|
||||||
cmake --build $(BUILD_DIR) --target package
|
|
||||||
|
|
||||||
install: build
|
|
||||||
cmake --build $(BUILD_DIR) --target install
|
|
||||||
|
|
||||||
clean:
|
|
||||||
cmake -E rm -rf $(BUILD_DIR)
|
|
||||||
72
README.md
72
README.md
@@ -36,23 +36,25 @@ wtfn neigh list --ipv6
|
|||||||
wtfn geoip lookup 8.8.8.8
|
wtfn geoip lookup 8.8.8.8
|
||||||
wtfn probe ping example.com --count 4
|
wtfn probe ping example.com --count 4
|
||||||
wtfn probe tcping example.com:443 --count 4
|
wtfn probe tcping example.com:443 --count 4
|
||||||
wtfn probe tcping example.com:443 --socks5 socks5://127.0.0.1:9909
|
wtfn probe tcping example.com:443 --socks5 socks5://127.0.0.1:10808
|
||||||
wtfn probe trace example.com:443 --max-hops 20
|
wtfn probe trace example.com:443 --max-hops 20
|
||||||
|
|
||||||
# DNS
|
# DNS
|
||||||
wtfn dns query example.com A
|
wtfn dns query example.com A
|
||||||
wtfn dns query example.com AAAA --server 1.1.1.1
|
wtfn dns query example.com AAAA --server 1.1.1.1
|
||||||
wtfn dns query example.com A --transport doh --server 1.1.1.1 --tls-name cloudflare-dns.com
|
wtfn dns query example.com A --transport doh --server 1.1.1.1 --tls-name cloudflare-dns.com
|
||||||
wtfn dns query example.com A --transport dot --server 1.1.1.1 --tls-name cloudflare-dns.com --socks5 socks5://127.0.0.1:9909
|
wtfn dns query example.com A --transport dot --server 1.1.1.1 --tls-name cloudflare-dns.com --socks5 socks5://127.0.0.1:10808
|
||||||
wtfn dns detect example.com --transport doh --servers 1.1.1.1 --tls-name cloudflare-dns.com
|
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
|
wtfn dns watch --duration 10s --filter example.com
|
||||||
|
wtfn dns watch --follow
|
||||||
wtfn dns leak status
|
wtfn dns leak status
|
||||||
wtfn dns leak watch --duration 10s --profile proxy-stub
|
wtfn dns leak watch --duration 10s --profile proxy-stub
|
||||||
|
wtfn dns leak watch --follow
|
||||||
wtfn dns leak report report.json
|
wtfn dns leak report report.json
|
||||||
|
|
||||||
# TLS
|
# TLS
|
||||||
wtfn tls handshake example.com:443
|
wtfn tls handshake example.com:443
|
||||||
wtfn tls handshake example.com:443 --socks5 socks5://127.0.0.1:9909
|
wtfn tls handshake example.com:443 --socks5 socks5://127.0.0.1:10808
|
||||||
wtfn tls cert example.com:443
|
wtfn tls cert example.com:443
|
||||||
wtfn tls verify example.com:443
|
wtfn tls verify example.com:443
|
||||||
wtfn tls alpn example.com:443 --alpn h2,http/1.1
|
wtfn tls alpn example.com:443 --alpn h2,http/1.1
|
||||||
@@ -73,41 +75,8 @@ wtfn calc overlap 10.0.0.0/24 10.0.1.0/24
|
|||||||
wtfn calc summarize 10.0.0.0/24 10.0.1.0/24
|
wtfn calc summarize 10.0.0.0/24 10.0.1.0/24
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported flags
|
## Command reference
|
||||||
Global flags:
|
See `docs/COMMANDS.md` for the full list of commands and flags (with descriptions).
|
||||||
- `--json` / `--pretty`
|
|
||||||
- `--no-color` / `--quiet`
|
|
||||||
- `-v` / `-vv` / `--verbose`
|
|
||||||
- `--log-level <error|warn|info|debug|trace>`
|
|
||||||
- `--log-format <text|json>`
|
|
||||||
- `--log-file <path>`
|
|
||||||
- `NETTOOL_LOG_FILTER` or `RUST_LOG` can override log filters (ex: `maxminddb::decoder=debug`)
|
|
||||||
|
|
||||||
Command flags (implemented):
|
|
||||||
- `sys ip`: `--all`, `--iface <name>`
|
|
||||||
- `sys route`: `--ipv4`, `--ipv6`, `--to <ip>`
|
|
||||||
- `ports listen`: `--tcp`, `--udp`, `--port <n>`
|
|
||||||
- `neigh list`: `--ipv4`, `--ipv6`, `--iface <name>`
|
|
||||||
- `ports conns`: `--top <n>`, `--by-process`
|
|
||||||
- `cert baseline`: `<path>`
|
|
||||||
- `cert diff`: `<path>`
|
|
||||||
- `probe ping`: `--count <n>`, `--timeout-ms <n>`, `--interval-ms <n>`, `--no-geoip`
|
|
||||||
- `probe tcping`: `--count <n>`, `--timeout-ms <n>`, `--socks5 <url>`, `--prefer-ipv4`, `--no-geoip`
|
|
||||||
- `probe trace`: `--max-hops <n>`, `--per-hop <n>`, `--timeout-ms <n>`, `--udp`, `--port <n>`, `--rdns`, `--no-geoip`
|
|
||||||
- `dns query`: `--server <ip[:port]>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--timeout-ms <n>`
|
|
||||||
- `dns detect`: `--servers <csv>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--repeat <n>`, `--timeout-ms <n>`
|
|
||||||
- `dns watch`: `--duration <Ns|Nms>`, `--iface <name>`, `--filter <pattern>`
|
|
||||||
- `dns leak status`: `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`
|
|
||||||
- `dns leak watch`: `--duration <Ns|Nms>`, `--iface <name>`, `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`, `--privacy <full|redacted|minimal>`, `--out <path>`, `--summary-only`
|
|
||||||
- `dns leak watch`: `--iface-diag` (prints capture-capable interfaces)
|
|
||||||
- `dns leak report`: `<path>`, `--privacy <full|redacted|minimal>`
|
|
||||||
- `http head|get`: `--timeout-ms <n>`, `--follow-redirects <n>`, `--show-headers`, `--show-body`, `--max-body-bytes <n>`, `--http1-only`, `--http2-only`, `--http3` (feature `http3`), `--http3-only` (feature `http3`), `--geoip`, `--socks5 <url>`
|
|
||||||
- `tls handshake|cert|verify|alpn`: `--sni <name>`, `--alpn <csv>`, `--timeout-ms <n>`, `--insecure`, `--socks5 <url>`, `--prefer-ipv4`, `--show-extensions`, `--ocsp`
|
|
||||||
- `discover mdns`: `--duration <Ns|Nms>`, `--service <type>`
|
|
||||||
- `discover ssdp`: `--duration <Ns|Nms>`
|
|
||||||
- `discover llmnr`: `--duration <Ns|Nms>`, `--name <host>`
|
|
||||||
- `discover nbns`: `--duration <Ns|Nms>`
|
|
||||||
- `diag`: `--out <path>`, `--bundle <path>`, `--dns-detect <domain>`, `--dns-timeout-ms <n>`, `--dns-repeat <n>`
|
|
||||||
|
|
||||||
## GeoIP data files
|
## GeoIP data files
|
||||||
GeoLite2 mmdb files should live in `data/`.
|
GeoLite2 mmdb files should live in `data/`.
|
||||||
@@ -116,20 +85,33 @@ Lookup order:
|
|||||||
2) `data/` next to the CLI binary
|
2) `data/` next to the CLI binary
|
||||||
3) `data/` in the current working directory
|
3) `data/` in the current working directory
|
||||||
|
|
||||||
## Build and package
|
## Build
|
||||||
|
|
||||||
|
### Only build binary
|
||||||
```bash
|
```bash
|
||||||
cmake -S . -B build
|
cargo build --release
|
||||||
cmake --build build
|
|
||||||
cmake --build build --target package
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install:
|
### Build and package
|
||||||
|
1. Prepare GeoLite2 databases (required `GeoLite2-ASN.mmdb` and `GeoLite2-Country.mmdb` ):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmake --build build --target install
|
# Place your mmdb files under data/
|
||||||
|
mkdir data
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: This step requires `python3` and `just`.
|
||||||
|
|
||||||
|
2. Use `just` to run build and package command (Note: you don't need bash environment on windows):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# You will find package under dist/, zip file on windows, tar.gz file on linux
|
||||||
|
just release
|
||||||
```
|
```
|
||||||
|
|
||||||
## HTTP/3 (experimental)
|
## HTTP/3 (experimental)
|
||||||
HTTP/3 support is feature-gated and incomplete. Do not enable it in production builds yet.
|
HTTP/3 support is feature-gated and best-effort. Enable it only when you want to test QUIC
|
||||||
|
connectivity.
|
||||||
|
|
||||||
To enable locally for testing:
|
To enable locally for testing:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wtfnet-cli"
|
name = "wtfnet-cli"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -12,7 +12,7 @@ clap = { version = "4", features = ["derive"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||||
wtfnet-core = { path = "../wtfnet-core" }
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
wtfnet-calc = { path = "../wtfnet-calc" }
|
wtfnet-calc = { path = "../wtfnet-calc" }
|
||||||
wtfnet-geoip = { path = "../wtfnet-geoip" }
|
wtfnet-geoip = { path = "../wtfnet-geoip" }
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ use wtfnet_platform::{Platform, PlatformError};
|
|||||||
arg_required_else_help = true
|
arg_required_else_help = true
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Emit JSON output")]
|
||||||
json: bool,
|
json: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Pretty-print JSON output")]
|
||||||
pretty: bool,
|
pretty: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Disable ANSI colors")]
|
||||||
no_color: bool,
|
no_color: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Reduce stdout output")]
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
|
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, help = "Increase log verbosity (-v, -vv)")]
|
||||||
verbose: u8,
|
verbose: u8,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Set log level (error|warn|info|debug|trace)")]
|
||||||
log_level: Option<String>,
|
log_level: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Set log format (text|json)")]
|
||||||
log_format: Option<String>,
|
log_format: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Write logs to a file")]
|
||||||
log_file: Option<PathBuf>,
|
log_file: Option<PathBuf>,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
@@ -38,98 +38,130 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
|
/// System snapshot: interfaces, IPs, routes, DNS
|
||||||
Sys {
|
Sys {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: SysCommand,
|
command: SysCommand,
|
||||||
},
|
},
|
||||||
|
/// Ports and socket ownership
|
||||||
Ports {
|
Ports {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: PortsCommand,
|
command: PortsCommand,
|
||||||
},
|
},
|
||||||
|
/// Neighbor table (ARP/NDP)
|
||||||
Neigh {
|
Neigh {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: NeighCommand,
|
command: NeighCommand,
|
||||||
},
|
},
|
||||||
|
/// Certificate roots and baselines
|
||||||
Cert {
|
Cert {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: CertCommand,
|
command: CertCommand,
|
||||||
},
|
},
|
||||||
|
/// GeoIP lookup helpers
|
||||||
Geoip {
|
Geoip {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: GeoIpCommand,
|
command: GeoIpCommand,
|
||||||
},
|
},
|
||||||
|
/// Probing tools (ping/tcping/trace)
|
||||||
Probe {
|
Probe {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: ProbeCommand,
|
command: ProbeCommand,
|
||||||
},
|
},
|
||||||
|
/// DNS query, detect, watch, and leak detection
|
||||||
Dns {
|
Dns {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: DnsCommand,
|
command: DnsCommand,
|
||||||
},
|
},
|
||||||
|
/// Subnet calculator
|
||||||
Calc {
|
Calc {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: CalcCommand,
|
command: CalcCommand,
|
||||||
},
|
},
|
||||||
|
/// HTTP head/get diagnostics
|
||||||
Http {
|
Http {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: HttpCommand,
|
command: HttpCommand,
|
||||||
},
|
},
|
||||||
|
/// TLS handshake and certificate analysis
|
||||||
Tls {
|
Tls {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: TlsCommand,
|
command: TlsCommand,
|
||||||
},
|
},
|
||||||
|
/// Local network discovery (mDNS/SSDP/LLMNR/NBNS)
|
||||||
Discover {
|
Discover {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: DiscoverCommand,
|
command: DiscoverCommand,
|
||||||
},
|
},
|
||||||
|
/// Bundle a diagnostic report
|
||||||
Diag(DiagArgs),
|
Diag(DiagArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum SysCommand {
|
enum SysCommand {
|
||||||
|
/// List network interfaces
|
||||||
Ifaces,
|
Ifaces,
|
||||||
|
/// Show IP addresses
|
||||||
Ip(SysIpArgs),
|
Ip(SysIpArgs),
|
||||||
|
/// Show route table
|
||||||
Route(SysRouteArgs),
|
Route(SysRouteArgs),
|
||||||
|
/// Show DNS configuration
|
||||||
Dns,
|
Dns,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum PortsCommand {
|
enum PortsCommand {
|
||||||
|
/// List listening sockets
|
||||||
Listen(PortsListenArgs),
|
Listen(PortsListenArgs),
|
||||||
|
/// Find socket owners for a port
|
||||||
Who(PortsWhoArgs),
|
Who(PortsWhoArgs),
|
||||||
|
/// List active TCP connections
|
||||||
Conns(PortsConnsArgs),
|
Conns(PortsConnsArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum NeighCommand {
|
enum NeighCommand {
|
||||||
|
/// List ARP/NDP neighbors
|
||||||
List(NeighListArgs),
|
List(NeighListArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum CertCommand {
|
enum CertCommand {
|
||||||
|
/// List trusted root certificates
|
||||||
Roots,
|
Roots,
|
||||||
|
/// Write a baseline file of trusted roots
|
||||||
Baseline(CertBaselineArgs),
|
Baseline(CertBaselineArgs),
|
||||||
|
/// Diff trusted roots against a baseline
|
||||||
Diff(CertDiffArgs),
|
Diff(CertDiffArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum GeoIpCommand {
|
enum GeoIpCommand {
|
||||||
|
/// Lookup GeoIP for an IP address
|
||||||
Lookup(GeoIpLookupArgs),
|
Lookup(GeoIpLookupArgs),
|
||||||
|
/// Show GeoIP database status
|
||||||
Status,
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum ProbeCommand {
|
enum ProbeCommand {
|
||||||
|
/// ICMP ping
|
||||||
Ping(ProbePingArgs),
|
Ping(ProbePingArgs),
|
||||||
|
/// TCP ping (connect timing)
|
||||||
Tcping(ProbeTcpingArgs),
|
Tcping(ProbeTcpingArgs),
|
||||||
|
/// Traceroute
|
||||||
Trace(ProbeTraceArgs),
|
Trace(ProbeTraceArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum DnsCommand {
|
enum DnsCommand {
|
||||||
|
/// DNS query
|
||||||
Query(DnsQueryArgs),
|
Query(DnsQueryArgs),
|
||||||
|
/// DNS poisoning detection (active)
|
||||||
Detect(DnsDetectArgs),
|
Detect(DnsDetectArgs),
|
||||||
|
/// DNS passive watch (pcap)
|
||||||
Watch(DnsWatchArgs),
|
Watch(DnsWatchArgs),
|
||||||
|
/// DNS leak detection
|
||||||
Leak {
|
Leak {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: DnsLeakCommand,
|
command: DnsLeakCommand,
|
||||||
@@ -138,345 +170,385 @@ enum DnsCommand {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum DnsLeakCommand {
|
enum DnsLeakCommand {
|
||||||
|
/// Show current policy and interface snapshot
|
||||||
Status(DnsLeakStatusArgs),
|
Status(DnsLeakStatusArgs),
|
||||||
|
/// Passive leak detection watch
|
||||||
Watch(DnsLeakWatchArgs),
|
Watch(DnsLeakWatchArgs),
|
||||||
|
/// Summarize a saved leak report
|
||||||
Report(DnsLeakReportArgs),
|
Report(DnsLeakReportArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum CalcCommand {
|
enum CalcCommand {
|
||||||
|
/// Subnet info
|
||||||
Subnet(CalcSubnetArgs),
|
Subnet(CalcSubnetArgs),
|
||||||
|
/// Check CIDR containment
|
||||||
Contains(CalcContainsArgs),
|
Contains(CalcContainsArgs),
|
||||||
|
/// Check CIDR overlap
|
||||||
Overlap(CalcOverlapArgs),
|
Overlap(CalcOverlapArgs),
|
||||||
|
/// Summarize CIDRs
|
||||||
Summarize(CalcSummarizeArgs),
|
Summarize(CalcSummarizeArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum HttpCommand {
|
enum HttpCommand {
|
||||||
|
/// HTTP HEAD request
|
||||||
Head(HttpRequestArgs),
|
Head(HttpRequestArgs),
|
||||||
|
/// HTTP GET request
|
||||||
Get(HttpRequestArgs),
|
Get(HttpRequestArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum TlsCommand {
|
enum TlsCommand {
|
||||||
|
/// TLS handshake summary
|
||||||
Handshake(TlsArgs),
|
Handshake(TlsArgs),
|
||||||
|
/// TLS certificate details
|
||||||
Cert(TlsArgs),
|
Cert(TlsArgs),
|
||||||
|
/// TLS verification
|
||||||
Verify(TlsArgs),
|
Verify(TlsArgs),
|
||||||
|
/// TLS ALPN negotiation
|
||||||
Alpn(TlsArgs),
|
Alpn(TlsArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum DiscoverCommand {
|
enum DiscoverCommand {
|
||||||
|
/// mDNS discovery
|
||||||
Mdns(DiscoverMdnsArgs),
|
Mdns(DiscoverMdnsArgs),
|
||||||
|
/// SSDP discovery
|
||||||
Ssdp(DiscoverSsdpArgs),
|
Ssdp(DiscoverSsdpArgs),
|
||||||
|
/// LLMNR discovery
|
||||||
Llmnr(DiscoverLlmnrArgs),
|
Llmnr(DiscoverLlmnrArgs),
|
||||||
|
/// NBNS discovery
|
||||||
Nbns(DiscoverNbnsArgs),
|
Nbns(DiscoverNbnsArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct SysIpArgs {
|
struct SysIpArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show all addresses (including link-local)")]
|
||||||
all: bool,
|
all: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Filter by interface name")]
|
||||||
iface: Option<String>,
|
iface: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct SysRouteArgs {
|
struct SysRouteArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show IPv4 routes")]
|
||||||
ipv4: bool,
|
ipv4: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show IPv6 routes")]
|
||||||
ipv6: bool,
|
ipv6: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Filter routes by destination")]
|
||||||
to: Option<String>,
|
to: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct PortsListenArgs {
|
struct PortsListenArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show TCP listeners")]
|
||||||
tcp: bool,
|
tcp: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show UDP listeners")]
|
||||||
udp: bool,
|
udp: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Filter by port")]
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct PortsWhoArgs {
|
struct PortsWhoArgs {
|
||||||
|
#[arg(help = "Target port number")]
|
||||||
target: String,
|
target: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct PortsConnsArgs {
|
struct PortsConnsArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show top N remote endpoints")]
|
||||||
top: Option<usize>,
|
top: Option<usize>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Group by process")]
|
||||||
by_process: bool,
|
by_process: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct NeighListArgs {
|
struct NeighListArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show IPv4 neighbors")]
|
||||||
ipv4: bool,
|
ipv4: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show IPv6 neighbors")]
|
||||||
ipv6: bool,
|
ipv6: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Filter by interface name")]
|
||||||
iface: Option<String>,
|
iface: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct GeoIpLookupArgs {
|
struct GeoIpLookupArgs {
|
||||||
|
#[arg(help = "Target IP address")]
|
||||||
target: String,
|
target: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CertBaselineArgs {
|
struct CertBaselineArgs {
|
||||||
|
#[arg(help = "Path to write baseline JSON")]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CertDiffArgs {
|
struct CertDiffArgs {
|
||||||
|
#[arg(help = "Path to baseline JSON")]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct ProbePingArgs {
|
struct ProbePingArgs {
|
||||||
|
#[arg(help = "Target hostname or IP")]
|
||||||
target: String,
|
target: String,
|
||||||
#[arg(long, default_value_t = 4)]
|
#[arg(long, default_value_t = 4, help = "Ping count")]
|
||||||
count: u32,
|
count: u32,
|
||||||
#[arg(long, default_value_t = 800)]
|
#[arg(long, default_value_t = 800, help = "Timeout per ping (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
#[arg(long, default_value_t = 200)]
|
#[arg(long, default_value_t = 200, help = "Interval between pings (ms)")]
|
||||||
interval_ms: u64,
|
interval_ms: u64,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Disable GeoIP enrichment")]
|
||||||
no_geoip: bool,
|
no_geoip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct ProbeTcpingArgs {
|
struct ProbeTcpingArgs {
|
||||||
|
#[arg(help = "Target host:port")]
|
||||||
target: String,
|
target: String,
|
||||||
#[arg(long, default_value_t = 4)]
|
#[arg(long, default_value_t = 4, help = "Ping count")]
|
||||||
count: u32,
|
count: u32,
|
||||||
#[arg(long, default_value_t = 800)]
|
#[arg(long, default_value_t = 800, help = "Timeout per connect (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
#[arg(long)]
|
#[arg(long, help = "SOCKS5 proxy URL")]
|
||||||
socks5: Option<String>,
|
socks5: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Prefer IPv4 resolution")]
|
||||||
prefer_ipv4: bool,
|
prefer_ipv4: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Disable GeoIP enrichment")]
|
||||||
no_geoip: bool,
|
no_geoip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct ProbeTraceArgs {
|
struct ProbeTraceArgs {
|
||||||
|
#[arg(help = "Target host:port")]
|
||||||
target: String,
|
target: String,
|
||||||
#[arg(long, default_value_t = 30)]
|
#[arg(long, default_value_t = 30, help = "Maximum hops")]
|
||||||
max_hops: u8,
|
max_hops: u8,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3, help = "Probes per hop")]
|
||||||
per_hop: u32,
|
per_hop: u32,
|
||||||
#[arg(long, default_value_t = 800)]
|
#[arg(long, default_value_t = 800, help = "Timeout per hop (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Use UDP traceroute")]
|
||||||
udp: bool,
|
udp: bool,
|
||||||
#[arg(long, default_value_t = 33434)]
|
#[arg(long, default_value_t = 33434, help = "Destination port for UDP/TCP trace")]
|
||||||
port: u16,
|
port: u16,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Reverse DNS lookup per hop")]
|
||||||
rdns: bool,
|
rdns: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Disable GeoIP enrichment")]
|
||||||
no_geoip: bool,
|
no_geoip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsQueryArgs {
|
struct DnsQueryArgs {
|
||||||
|
#[arg(help = "Domain name to query")]
|
||||||
domain: String,
|
domain: String,
|
||||||
|
#[arg(help = "Record type (A, AAAA, MX, TXT, ...)")]
|
||||||
record_type: String,
|
record_type: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "DNS server IP[:port]")]
|
||||||
server: Option<String>,
|
server: Option<String>,
|
||||||
#[arg(long, default_value = "udp")]
|
#[arg(long, default_value = "udp", help = "Transport (udp|tcp|dot|doh)")]
|
||||||
transport: String,
|
transport: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "TLS server name for DoT/DoH")]
|
||||||
tls_name: Option<String>,
|
tls_name: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "SOCKS5 proxy URL")]
|
||||||
socks5: Option<String>,
|
socks5: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Prefer IPv4 resolution")]
|
||||||
prefer_ipv4: bool,
|
prefer_ipv4: bool,
|
||||||
#[arg(long, default_value_t = 2000)]
|
#[arg(long, default_value_t = 2000, help = "Timeout (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsDetectArgs {
|
struct DnsDetectArgs {
|
||||||
|
#[arg(help = "Domain name to test")]
|
||||||
domain: String,
|
domain: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Comma-separated DNS servers")]
|
||||||
servers: Option<String>,
|
servers: Option<String>,
|
||||||
#[arg(long, default_value = "udp")]
|
#[arg(long, default_value = "udp", help = "Transport (udp|tcp|dot|doh)")]
|
||||||
transport: String,
|
transport: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "TLS server name for DoT/DoH")]
|
||||||
tls_name: Option<String>,
|
tls_name: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "SOCKS5 proxy URL")]
|
||||||
socks5: Option<String>,
|
socks5: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Prefer IPv4 resolution")]
|
||||||
prefer_ipv4: bool,
|
prefer_ipv4: bool,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3, help = "Repeat count per server")]
|
||||||
repeat: u32,
|
repeat: u32,
|
||||||
#[arg(long, default_value_t = 2000)]
|
#[arg(long, default_value_t = 2000, help = "Timeout (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsWatchArgs {
|
struct DnsWatchArgs {
|
||||||
#[arg(long, default_value = "30s")]
|
#[arg(long, default_value = "30s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Keep running until Ctrl-C")]
|
||||||
|
follow: bool,
|
||||||
|
#[arg(long, help = "Capture interface name")]
|
||||||
iface: Option<String>,
|
iface: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Filter by domain substring")]
|
||||||
filter: Option<String>,
|
filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsLeakStatusArgs {
|
struct DnsLeakStatusArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Policy profile (full-tunnel|proxy-stub|split)")]
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Path to policy JSON")]
|
||||||
policy: Option<PathBuf>,
|
policy: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsLeakWatchArgs {
|
struct DnsLeakWatchArgs {
|
||||||
#[arg(long, default_value = "10s")]
|
#[arg(long, default_value = "10s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Keep running until Ctrl-C")]
|
||||||
|
follow: bool,
|
||||||
|
#[arg(long, help = "Capture interface name")]
|
||||||
iface: Option<String>,
|
iface: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Policy profile (full-tunnel|proxy-stub|split)")]
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Path to policy JSON")]
|
||||||
policy: Option<PathBuf>,
|
policy: Option<PathBuf>,
|
||||||
#[arg(long, default_value = "redacted")]
|
#[arg(long, default_value = "redacted", help = "Privacy mode (full|redacted|minimal)")]
|
||||||
privacy: String,
|
privacy: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Write JSON report to file")]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Only print summary (no events)")]
|
||||||
summary_only: bool,
|
summary_only: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "List capture-capable interfaces and exit")]
|
||||||
iface_diag: bool,
|
iface_diag: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DnsLeakReportArgs {
|
struct DnsLeakReportArgs {
|
||||||
|
#[arg(help = "Path to leak report JSON")]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
#[arg(long, default_value = "redacted")]
|
#[arg(long, default_value = "redacted", help = "Privacy mode (full|redacted|minimal)")]
|
||||||
privacy: String,
|
privacy: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CalcSubnetArgs {
|
struct CalcSubnetArgs {
|
||||||
|
#[arg(help = "CIDR or IP + mask")]
|
||||||
input: Vec<String>,
|
input: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CalcContainsArgs {
|
struct CalcContainsArgs {
|
||||||
|
#[arg(help = "CIDR A")]
|
||||||
a: String,
|
a: String,
|
||||||
|
#[arg(help = "CIDR B")]
|
||||||
b: String,
|
b: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CalcOverlapArgs {
|
struct CalcOverlapArgs {
|
||||||
|
#[arg(help = "CIDR A")]
|
||||||
a: String,
|
a: String,
|
||||||
|
#[arg(help = "CIDR B")]
|
||||||
b: String,
|
b: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct CalcSummarizeArgs {
|
struct CalcSummarizeArgs {
|
||||||
|
#[arg(help = "CIDR list to summarize")]
|
||||||
cidrs: Vec<String>,
|
cidrs: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct HttpRequestArgs {
|
struct HttpRequestArgs {
|
||||||
|
#[arg(help = "Target URL")]
|
||||||
url: String,
|
url: String,
|
||||||
#[arg(long, default_value_t = 3000)]
|
#[arg(long, default_value_t = 3000, help = "Request timeout (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Follow redirects (limit)")]
|
||||||
follow_redirects: Option<u32>,
|
follow_redirects: Option<u32>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Include response headers")]
|
||||||
show_headers: bool,
|
show_headers: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Include response body")]
|
||||||
show_body: bool,
|
show_body: bool,
|
||||||
#[arg(long, default_value_t = 8192)]
|
#[arg(long, default_value_t = 8192, help = "Max body bytes")]
|
||||||
max_body_bytes: usize,
|
max_body_bytes: usize,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Force HTTP/1.1 only")]
|
||||||
http1_only: bool,
|
http1_only: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Force HTTP/2 prior knowledge")]
|
||||||
http2_only: bool,
|
http2_only: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Enable HTTP/3 (feature gated)")]
|
||||||
http3: bool,
|
http3: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Require HTTP/3 (feature gated)")]
|
||||||
http3_only: bool,
|
http3_only: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Enable GeoIP enrichment")]
|
||||||
geoip: bool,
|
geoip: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "SOCKS5 proxy URL")]
|
||||||
socks5: Option<String>,
|
socks5: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct TlsArgs {
|
struct TlsArgs {
|
||||||
|
#[arg(help = "Target host:port")]
|
||||||
target: String,
|
target: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Override SNI")]
|
||||||
sni: Option<String>,
|
sni: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "ALPN protocols (comma-separated)")]
|
||||||
alpn: Option<String>,
|
alpn: Option<String>,
|
||||||
#[arg(long, default_value_t = 3000)]
|
#[arg(long, default_value_t = 3000, help = "Timeout (ms)")]
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Skip TLS verification")]
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "SOCKS5 proxy URL")]
|
||||||
socks5: Option<String>,
|
socks5: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Prefer IPv4 resolution")]
|
||||||
prefer_ipv4: bool,
|
prefer_ipv4: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show X.509 extensions")]
|
||||||
show_extensions: bool,
|
show_extensions: bool,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Show OCSP stapling if available")]
|
||||||
ocsp: bool,
|
ocsp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DiscoverMdnsArgs {
|
struct DiscoverMdnsArgs {
|
||||||
#[arg(long, default_value = "3s")]
|
#[arg(long, default_value = "3s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Service type filter")]
|
||||||
service: Option<String>,
|
service: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DiscoverSsdpArgs {
|
struct DiscoverSsdpArgs {
|
||||||
#[arg(long, default_value = "3s")]
|
#[arg(long, default_value = "3s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DiscoverLlmnrArgs {
|
struct DiscoverLlmnrArgs {
|
||||||
#[arg(long, default_value = "3s")]
|
#[arg(long, default_value = "3s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Query name (default: wpad)")]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DiscoverNbnsArgs {
|
struct DiscoverNbnsArgs {
|
||||||
#[arg(long, default_value = "3s")]
|
#[arg(long, default_value = "3s", help = "Capture duration")]
|
||||||
duration: String,
|
duration: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
struct DiagArgs {
|
struct DiagArgs {
|
||||||
#[arg(long)]
|
#[arg(long, help = "Write JSON report to file")]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Write zip bundle to file")]
|
||||||
bundle: Option<PathBuf>,
|
bundle: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long, help = "Run DNS detect on domain")]
|
||||||
dns_detect: Option<String>,
|
dns_detect: Option<String>,
|
||||||
#[arg(long, default_value_t = 2000)]
|
#[arg(long, default_value_t = 2000, help = "DNS detect timeout (ms)")]
|
||||||
dns_timeout_ms: u64,
|
dns_timeout_ms: u64,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3, help = "DNS detect repeat count")]
|
||||||
dns_repeat: u32,
|
dns_repeat: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1938,7 +2010,7 @@ async fn handle_dns_detect(cli: &Cli, args: DnsDetectArgs) -> i32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 {
|
async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 {
|
||||||
let duration_ms = match parse_duration_ms(&args.duration) {
|
let duration_ms = match resolve_follow_duration(args.follow, &args.duration) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("{err}");
|
eprintln!("{err}");
|
||||||
@@ -1951,11 +2023,27 @@ async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 {
|
|||||||
filter: args.filter.clone(),
|
filter: args.filter.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match wtfnet_dns::watch(options).await {
|
let watch_task = wtfnet_dns::watch(options);
|
||||||
|
let report = if args.follow {
|
||||||
|
tokio::select! {
|
||||||
|
result = watch_task => result,
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
eprintln!("dns watch interrupted by Ctrl-C");
|
||||||
|
return ExitKind::Ok.code();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
watch_task.await
|
||||||
|
};
|
||||||
|
|
||||||
|
match report {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
if cli.json {
|
if cli.json {
|
||||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||||
let mut command_args = vec!["--duration".to_string(), args.duration];
|
let mut command_args = vec!["--duration".to_string(), args.duration];
|
||||||
|
if args.follow {
|
||||||
|
command_args.push("--follow".to_string());
|
||||||
|
}
|
||||||
if let Some(iface) = args.iface {
|
if let Some(iface) = args.iface {
|
||||||
command_args.push("--iface".to_string());
|
command_args.push("--iface".to_string());
|
||||||
command_args.push(iface);
|
command_args.push(iface);
|
||||||
@@ -2086,7 +2174,7 @@ async fn handle_dns_leak_watch(cli: &Cli, args: DnsLeakWatchArgs) -> i32 {
|
|||||||
if args.iface_diag {
|
if args.iface_diag {
|
||||||
return handle_dns_leak_iface_diag(cli).await;
|
return handle_dns_leak_iface_diag(cli).await;
|
||||||
}
|
}
|
||||||
let duration_ms = match parse_duration_ms(&args.duration) {
|
let duration_ms = match resolve_follow_duration(args.follow, &args.duration) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("{err}");
|
eprintln!("{err}");
|
||||||
@@ -2121,12 +2209,29 @@ async fn handle_dns_leak_watch(cli: &Cli, args: DnsLeakWatchArgs) -> i32 {
|
|||||||
include_events: !args.summary_only,
|
include_events: !args.summary_only,
|
||||||
};
|
};
|
||||||
|
|
||||||
let report = match wtfnet_dnsleak::watch(options, Some(&*platform.flow_owner)).await {
|
let watch_task = wtfnet_dnsleak::watch(options, Some(&*platform.flow_owner));
|
||||||
|
let report = if args.follow {
|
||||||
|
match tokio::select! {
|
||||||
|
result = watch_task => result,
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
eprintln!("dns leak watch interrupted by Ctrl-C");
|
||||||
|
return ExitKind::Ok.code();
|
||||||
|
}
|
||||||
|
} {
|
||||||
Ok(report) => report,
|
Ok(report) => report,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("dns leak watch failed: {err}");
|
eprintln!("dns leak watch failed: {err}");
|
||||||
return ExitKind::Failed.code();
|
return ExitKind::Failed.code();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match watch_task.await {
|
||||||
|
Ok(report) => report,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("dns leak watch failed: {err}");
|
||||||
|
return ExitKind::Failed.code();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(path) = args.out.as_ref() {
|
if let Some(path) = args.out.as_ref() {
|
||||||
@@ -2145,6 +2250,9 @@ async fn handle_dns_leak_watch(cli: &Cli, args: DnsLeakWatchArgs) -> i32 {
|
|||||||
command_args.push("--iface".to_string());
|
command_args.push("--iface".to_string());
|
||||||
command_args.push(iface);
|
command_args.push(iface);
|
||||||
}
|
}
|
||||||
|
if args.follow {
|
||||||
|
command_args.push("--follow".to_string());
|
||||||
|
}
|
||||||
if let Some(profile) = args.profile {
|
if let Some(profile) = args.profile {
|
||||||
command_args.push("--profile".to_string());
|
command_args.push("--profile".to_string());
|
||||||
command_args.push(profile);
|
command_args.push(profile);
|
||||||
@@ -3054,6 +3162,13 @@ fn parse_duration_ms(value: &str) -> Result<u64, String> {
|
|||||||
Ok(ms)
|
Ok(ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_follow_duration(follow: bool, duration: &str) -> Result<u64, String> {
|
||||||
|
if follow {
|
||||||
|
return Ok(u64::MAX / 4);
|
||||||
|
}
|
||||||
|
parse_duration_ms(duration)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_answers_geoip(answers: &[DnsAnswerGeoIp]) -> String {
|
fn format_answers_geoip(answers: &[DnsAnswerGeoIp]) -> String {
|
||||||
if answers.is_empty() {
|
if answers.is_empty() {
|
||||||
return "-".to_string();
|
return "-".to_string();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::report::LeakTransport;
|
use crate::report::LeakTransport;
|
||||||
use hickory_proto::op::{Message, MessageType};
|
use hickory_proto::op::{Message, MessageType};
|
||||||
|
use hickory_proto::rr::RData;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use wtfnet_platform::FlowProtocol;
|
use wtfnet_platform::FlowProtocol;
|
||||||
@@ -17,16 +18,43 @@ pub struct ClassifiedEvent {
|
|||||||
pub qname: Option<String>,
|
pub qname: Option<String>,
|
||||||
pub qtype: Option<String>,
|
pub qtype: Option<String>,
|
||||||
pub rcode: Option<String>,
|
pub rcode: Option<String>,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn classify_dns_query(payload: &[u8]) -> Option<(String, String, String)> {
|
pub struct ParsedDns {
|
||||||
|
pub qname: String,
|
||||||
|
pub qtype: String,
|
||||||
|
pub rcode: String,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_dns_message(payload: &[u8]) -> Option<ParsedDns> {
|
||||||
let message = Message::from_vec(payload).ok()?;
|
let message = Message::from_vec(payload).ok()?;
|
||||||
if message.message_type() != MessageType::Query {
|
let is_response = message.message_type() == MessageType::Response;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let query = message.queries().first()?;
|
let query = message.queries().first()?;
|
||||||
let qname = query.name().to_utf8();
|
let qname = query.name().to_utf8();
|
||||||
let qtype = query.query_type().to_string();
|
let qtype = query.query_type().to_string();
|
||||||
let rcode = message.response_code().to_string();
|
let rcode = message.response_code().to_string();
|
||||||
Some((qname, qtype, rcode))
|
let mut answer_ips = Vec::new();
|
||||||
|
if is_response {
|
||||||
|
for record in message.answers() {
|
||||||
|
if let Some(data) = record.data() {
|
||||||
|
match data {
|
||||||
|
RData::A(addr) => answer_ips.push(IpAddr::V4(addr.0)),
|
||||||
|
RData::AAAA(addr) => answer_ips.push(IpAddr::V6(addr.0)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ParsedDns {
|
||||||
|
qname,
|
||||||
|
qtype,
|
||||||
|
rcode,
|
||||||
|
is_response,
|
||||||
|
answer_ips,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ mod rules;
|
|||||||
mod sensor;
|
mod sensor;
|
||||||
|
|
||||||
use crate::classify::ClassifiedEvent;
|
use crate::classify::ClassifiedEvent;
|
||||||
use crate::sensor::capture_events;
|
use crate::sensor::{capture_events, SensorEvent, TcpEvent};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -50,15 +50,32 @@ pub async fn watch(
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let events = capture_events(&options).await?;
|
let events = capture_events(&options).await?;
|
||||||
let mut leak_events = Vec::new();
|
let mut leak_events = Vec::new();
|
||||||
|
let mut dns_cache: std::collections::HashMap<std::net::IpAddr, DnsCacheEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
|
match event {
|
||||||
|
SensorEvent::Dns(event) => {
|
||||||
let enriched = enrich_event(event, flow_owner).await;
|
let enriched = enrich_event(event, flow_owner).await;
|
||||||
|
if enriched.is_response {
|
||||||
|
update_dns_cache(&mut dns_cache, &enriched);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(decision) = rules::evaluate(&enriched, &options.policy) {
|
if let Some(decision) = rules::evaluate(&enriched, &options.policy) {
|
||||||
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
|
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
|
||||||
privacy::apply_privacy(&mut leak_event, options.privacy);
|
privacy::apply_privacy(&mut leak_event, options.privacy);
|
||||||
leak_events.push(leak_event);
|
leak_events.push(leak_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SensorEvent::Tcp(event) => {
|
||||||
|
if let Some(leak_event) =
|
||||||
|
evaluate_mismatch(event, flow_owner, &mut dns_cache, options.privacy).await
|
||||||
|
{
|
||||||
|
leak_events.push(leak_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let summary = LeakSummary::from_events(&leak_events);
|
let summary = LeakSummary::from_events(&leak_events);
|
||||||
let report = LeakReport {
|
let report = LeakReport {
|
||||||
@@ -100,3 +117,106 @@ async fn enrich_event(
|
|||||||
}
|
}
|
||||||
enriched
|
enriched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DnsCacheEntry {
|
||||||
|
qname: String,
|
||||||
|
route_class: RouteClass,
|
||||||
|
timestamp_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DNS_CACHE_TTL_MS: u128 = 60_000;
|
||||||
|
|
||||||
|
fn update_dns_cache(cache: &mut std::collections::HashMap<std::net::IpAddr, DnsCacheEntry>, event: &report::EnrichedEvent) {
|
||||||
|
let Some(qname) = event.qname.as_ref() else { return };
|
||||||
|
let now = event.timestamp_ms;
|
||||||
|
prune_dns_cache(cache, now);
|
||||||
|
for ip in event.answer_ips.iter() {
|
||||||
|
debug!(
|
||||||
|
"dns leak cache insert ip={} qname={} route={:?}",
|
||||||
|
ip, qname, event.route_class
|
||||||
|
);
|
||||||
|
cache.insert(
|
||||||
|
*ip,
|
||||||
|
DnsCacheEntry {
|
||||||
|
qname: qname.clone(),
|
||||||
|
route_class: event.route_class,
|
||||||
|
timestamp_ms: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_dns_cache(
|
||||||
|
cache: &mut std::collections::HashMap<std::net::IpAddr, DnsCacheEntry>,
|
||||||
|
now_ms: u128,
|
||||||
|
) {
|
||||||
|
cache.retain(|_, entry| now_ms.saturating_sub(entry.timestamp_ms) <= DNS_CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn evaluate_mismatch(
|
||||||
|
event: TcpEvent,
|
||||||
|
flow_owner: Option<&dyn FlowOwnerProvider>,
|
||||||
|
cache: &mut std::collections::HashMap<std::net::IpAddr, DnsCacheEntry>,
|
||||||
|
privacy: PrivacyMode,
|
||||||
|
) -> Option<LeakEvent> {
|
||||||
|
prune_dns_cache(cache, event.timestamp_ms);
|
||||||
|
debug!(
|
||||||
|
"dns leak tcp syn dst_ip={} dst_port={} cache_size={}",
|
||||||
|
event.dst_ip,
|
||||||
|
event.dst_port,
|
||||||
|
cache.len()
|
||||||
|
);
|
||||||
|
let entry = cache.get(&event.dst_ip)?;
|
||||||
|
let tcp_route = route::route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref());
|
||||||
|
if tcp_route == entry.route_class {
|
||||||
|
debug!(
|
||||||
|
"dns leak mismatch skip dst_ip={} tcp_route={:?} dns_route={:?}",
|
||||||
|
event.dst_ip, tcp_route, entry.route_class
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut enriched = report::EnrichedEvent {
|
||||||
|
timestamp_ms: event.timestamp_ms,
|
||||||
|
proto: wtfnet_platform::FlowProtocol::Tcp,
|
||||||
|
src_ip: event.src_ip,
|
||||||
|
src_port: event.src_port,
|
||||||
|
dst_ip: event.dst_ip,
|
||||||
|
dst_port: event.dst_port,
|
||||||
|
iface_name: event.iface_name.clone(),
|
||||||
|
transport: LeakTransport::Unknown,
|
||||||
|
qname: Some(entry.qname.clone()),
|
||||||
|
qtype: None,
|
||||||
|
rcode: None,
|
||||||
|
is_response: false,
|
||||||
|
answer_ips: Vec::new(),
|
||||||
|
route_class: tcp_route,
|
||||||
|
owner: None,
|
||||||
|
owner_confidence: wtfnet_platform::FlowOwnerConfidence::None,
|
||||||
|
owner_failure: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(provider) = flow_owner {
|
||||||
|
let flow = FlowTuple {
|
||||||
|
proto: wtfnet_platform::FlowProtocol::Tcp,
|
||||||
|
src_ip: event.src_ip,
|
||||||
|
src_port: event.src_port,
|
||||||
|
dst_ip: event.dst_ip,
|
||||||
|
dst_port: event.dst_port,
|
||||||
|
};
|
||||||
|
if let Ok(result) = provider.owner_of(flow).await {
|
||||||
|
enriched.owner = result.owner;
|
||||||
|
enriched.owner_confidence = result.confidence;
|
||||||
|
enriched.owner_failure = result.failure_reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decision = rules::LeakDecision {
|
||||||
|
leak_type: report::LeakType::D,
|
||||||
|
severity: Severity::P2,
|
||||||
|
policy_rule_id: "LEAK_D_MISMATCH".to_string(),
|
||||||
|
};
|
||||||
|
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
|
||||||
|
privacy::apply_privacy(&mut leak_event, privacy);
|
||||||
|
Some(leak_event)
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub enum LeakType {
|
|||||||
D,
|
D,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RouteClass {
|
pub enum RouteClass {
|
||||||
Loopback,
|
Loopback,
|
||||||
@@ -54,6 +54,8 @@ pub struct EnrichedEvent {
|
|||||||
pub qname: Option<String>,
|
pub qname: Option<String>,
|
||||||
pub qtype: Option<String>,
|
pub qtype: Option<String>,
|
||||||
pub rcode: Option<String>,
|
pub rcode: Option<String>,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
pub route_class: RouteClass,
|
pub route_class: RouteClass,
|
||||||
pub owner: Option<FlowOwner>,
|
pub owner: Option<FlowOwner>,
|
||||||
pub owner_confidence: FlowOwnerConfidence,
|
pub owner_confidence: FlowOwnerConfidence,
|
||||||
|
|||||||
@@ -3,20 +3,7 @@ use crate::report::{EnrichedEvent, RouteClass};
|
|||||||
use wtfnet_platform::FlowOwnerConfidence;
|
use wtfnet_platform::FlowOwnerConfidence;
|
||||||
|
|
||||||
pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
||||||
let route_class = if event.src_ip.is_loopback() || event.dst_ip.is_loopback() {
|
let route_class = route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref());
|
||||||
RouteClass::Loopback
|
|
||||||
} else if event
|
|
||||||
.iface_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|name| is_tunnel_iface(name))
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
RouteClass::Tunnel
|
|
||||||
} else if event.iface_name.is_some() {
|
|
||||||
RouteClass::Physical
|
|
||||||
} else {
|
|
||||||
RouteClass::Unknown
|
|
||||||
};
|
|
||||||
|
|
||||||
EnrichedEvent {
|
EnrichedEvent {
|
||||||
timestamp_ms: event.timestamp_ms,
|
timestamp_ms: event.timestamp_ms,
|
||||||
@@ -30,6 +17,8 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
|||||||
qname: event.qname,
|
qname: event.qname,
|
||||||
qtype: event.qtype,
|
qtype: event.qtype,
|
||||||
rcode: event.rcode,
|
rcode: event.rcode,
|
||||||
|
is_response: event.is_response,
|
||||||
|
answer_ips: event.answer_ips,
|
||||||
route_class,
|
route_class,
|
||||||
owner: None,
|
owner: None,
|
||||||
owner_confidence: FlowOwnerConfidence::None,
|
owner_confidence: FlowOwnerConfidence::None,
|
||||||
@@ -37,6 +26,22 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn route_class_for(
|
||||||
|
src_ip: std::net::IpAddr,
|
||||||
|
dst_ip: std::net::IpAddr,
|
||||||
|
iface_name: Option<&str>,
|
||||||
|
) -> RouteClass {
|
||||||
|
if src_ip.is_loopback() || dst_ip.is_loopback() {
|
||||||
|
RouteClass::Loopback
|
||||||
|
} else if iface_name.map(is_tunnel_iface).unwrap_or(false) {
|
||||||
|
RouteClass::Tunnel
|
||||||
|
} else if iface_name.is_some() {
|
||||||
|
RouteClass::Physical
|
||||||
|
} else {
|
||||||
|
RouteClass::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_tunnel_iface(name: &str) -> bool {
|
fn is_tunnel_iface(name: &str) -> bool {
|
||||||
let name = name.to_ascii_lowercase();
|
let name = name.to_ascii_lowercase();
|
||||||
name.contains("tun")
|
name.contains("tun")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::classify::{classify_dns_query, ClassifiedEvent};
|
use crate::classify::{parse_dns_message, ClassifiedEvent};
|
||||||
use crate::report::LeakTransport;
|
use crate::report::LeakTransport;
|
||||||
use crate::DnsLeakError;
|
use crate::DnsLeakError;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -14,18 +14,28 @@ use pnet::datalink::{self, Channel, Config as DatalinkConfig};
|
|||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
const OPEN_IFACE_TIMEOUT_MS: u64 = 700;
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
const FRAME_RECV_TIMEOUT_MS: u64 = 200;
|
||||||
|
|
||||||
#[cfg(not(feature = "pcap"))]
|
#[cfg(not(feature = "pcap"))]
|
||||||
pub async fn capture_events(_options: &LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
pub async fn capture_events(_options: &LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
Err(DnsLeakError::NotSupported(
|
Err(DnsLeakError::NotSupported(
|
||||||
"dns leak watch requires pcap feature".to_string(),
|
"dns leak watch requires pcap feature".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
let options = options.clone();
|
let options = options.clone();
|
||||||
let candidates = format_iface_list(&datalink::interfaces());
|
let iface_list = datalink::interfaces();
|
||||||
let timeout_ms = options.duration_ms.saturating_add(2000);
|
let candidates = format_iface_list(&iface_list);
|
||||||
|
let select_budget_ms = (iface_list.len().max(1) as u64).saturating_mul(OPEN_IFACE_TIMEOUT_MS);
|
||||||
|
let timeout_ms = options
|
||||||
|
.duration_ms
|
||||||
|
.saturating_add(select_budget_ms)
|
||||||
|
.saturating_add(2000);
|
||||||
let handle = tokio::task::spawn_blocking(move || capture_events_blocking(options));
|
let handle = tokio::task::spawn_blocking(move || capture_events_blocking(options));
|
||||||
match tokio::time::timeout(Duration::from_millis(timeout_ms), handle).await {
|
match tokio::time::timeout(Duration::from_millis(timeout_ms), handle).await {
|
||||||
Ok(joined) => joined.map_err(|err| DnsLeakError::Io(err.to_string()))?,
|
Ok(joined) => joined.map_err(|err| DnsLeakError::Io(err.to_string()))?,
|
||||||
@@ -39,6 +49,22 @@ pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<Classified
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TcpEvent {
|
||||||
|
pub timestamp_ms: u128,
|
||||||
|
pub src_ip: IpAddr,
|
||||||
|
pub src_port: u16,
|
||||||
|
pub dst_ip: IpAddr,
|
||||||
|
pub dst_port: u16,
|
||||||
|
pub iface_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SensorEvent {
|
||||||
|
Dns(ClassifiedEvent),
|
||||||
|
Tcp(TcpEvent),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IfaceDiag {
|
pub struct IfaceDiag {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -78,7 +104,7 @@ pub fn iface_diagnostics() -> Result<Vec<IfaceDiag>, DnsLeakError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
|
|
||||||
@@ -88,16 +114,28 @@ fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEv
|
|||||||
let local_ips = iface.ips.iter().map(|ip| ip.ip()).collect::<Vec<_>>();
|
let local_ips = iface.ips.iter().map(|ip| ip.ip()).collect::<Vec<_>>();
|
||||||
let iface_name = iface.name.clone();
|
let iface_name = iface.name.clone();
|
||||||
|
|
||||||
|
let (frame_tx, frame_rx) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
match rx.next() {
|
||||||
|
Ok(frame) => {
|
||||||
|
if frame_tx.send(frame.to_vec()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
let frame = match rx.next() {
|
let frame = match frame_rx.recv_timeout(Duration::from_millis(FRAME_RECV_TIMEOUT_MS)) {
|
||||||
Ok(frame) => frame,
|
Ok(frame) => frame,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let ethernet = match EthernetPacket::new(frame) {
|
let ethernet = match EthernetPacket::new(&frame) {
|
||||||
Some(packet) => packet,
|
Some(packet) => packet,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
@@ -115,19 +153,38 @@ fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEv
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(event) = event {
|
if let Some(event) = event {
|
||||||
let key = format!(
|
let key = match &event {
|
||||||
"{:?}|{}|{}|{}|{}",
|
SensorEvent::Dns(value) => format!(
|
||||||
event.transport, event.src_ip, event.src_port, event.dst_ip, event.dst_port
|
"dns:{:?}|{}|{}|{}|{}",
|
||||||
);
|
value.transport, value.src_ip, value.src_port, value.dst_ip, value.dst_port
|
||||||
|
),
|
||||||
|
SensorEvent::Tcp(value) => format!(
|
||||||
|
"tcp:{}|{}|{}|{}",
|
||||||
|
value.src_ip, value.src_port, value.dst_ip, value.dst_port
|
||||||
|
),
|
||||||
|
};
|
||||||
if seen.insert(key) {
|
if seen.insert(key) {
|
||||||
|
match &event {
|
||||||
|
SensorEvent::Dns(value) => {
|
||||||
debug!(
|
debug!(
|
||||||
transport = ?event.transport,
|
transport = ?value.transport,
|
||||||
src_ip = %event.src_ip,
|
src_ip = %value.src_ip,
|
||||||
src_port = event.src_port,
|
src_port = value.src_port,
|
||||||
dst_ip = %event.dst_ip,
|
dst_ip = %value.dst_ip,
|
||||||
dst_port = event.dst_port,
|
dst_port = value.dst_port,
|
||||||
"dns leak event"
|
"dns leak event"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
SensorEvent::Tcp(value) => {
|
||||||
|
debug!(
|
||||||
|
src_ip = %value.src_ip,
|
||||||
|
src_port = value.src_port,
|
||||||
|
dst_ip = %value.dst_ip,
|
||||||
|
dst_port = value.dst_port,
|
||||||
|
"dns leak tcp event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,28 +198,19 @@ fn parse_ipv4(
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
local_ips: &[IpAddr],
|
local_ips: &[IpAddr],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::ip::IpNextHeaderProtocols;
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
use pnet::packet::ipv4::Ipv4Packet;
|
use pnet::packet::ipv4::Ipv4Packet;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let ipv4 = Ipv4Packet::new(payload)?;
|
let ipv4 = Ipv4Packet::new(payload)?;
|
||||||
let src = IpAddr::V4(ipv4.get_source());
|
let src = IpAddr::V4(ipv4.get_source());
|
||||||
if !local_ips.contains(&src) {
|
let dst = IpAddr::V4(ipv4.get_destination());
|
||||||
|
if !local_ips.contains(&src) && !local_ips.contains(&dst) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match ipv4.get_next_level_protocol() {
|
match ipv4.get_next_level_protocol() {
|
||||||
IpNextHeaderProtocols::Udp => parse_udp(
|
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv4.payload(), iface_name),
|
||||||
src,
|
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv4.payload(), iface_name),
|
||||||
IpAddr::V4(ipv4.get_destination()),
|
|
||||||
ipv4.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
IpNextHeaderProtocols::Tcp => parse_tcp(
|
|
||||||
src,
|
|
||||||
IpAddr::V4(ipv4.get_destination()),
|
|
||||||
ipv4.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,28 +220,19 @@ fn parse_ipv6(
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
local_ips: &[IpAddr],
|
local_ips: &[IpAddr],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::ip::IpNextHeaderProtocols;
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
use pnet::packet::ipv6::Ipv6Packet;
|
use pnet::packet::ipv6::Ipv6Packet;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let ipv6 = Ipv6Packet::new(payload)?;
|
let ipv6 = Ipv6Packet::new(payload)?;
|
||||||
let src = IpAddr::V6(ipv6.get_source());
|
let src = IpAddr::V6(ipv6.get_source());
|
||||||
if !local_ips.contains(&src) {
|
let dst = IpAddr::V6(ipv6.get_destination());
|
||||||
|
if !local_ips.contains(&src) && !local_ips.contains(&dst) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match ipv6.get_next_header() {
|
match ipv6.get_next_header() {
|
||||||
IpNextHeaderProtocols::Udp => parse_udp(
|
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv6.payload(), iface_name),
|
||||||
src,
|
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv6.payload(), iface_name),
|
||||||
IpAddr::V6(ipv6.get_destination()),
|
|
||||||
ipv6.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
IpNextHeaderProtocols::Tcp => parse_tcp(
|
|
||||||
src,
|
|
||||||
IpAddr::V6(ipv6.get_destination()),
|
|
||||||
ipv6.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,28 +243,31 @@ fn parse_udp(
|
|||||||
dst_ip: IpAddr,
|
dst_ip: IpAddr,
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::udp::UdpPacket;
|
use pnet::packet::udp::UdpPacket;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let udp = UdpPacket::new(payload)?;
|
let udp = UdpPacket::new(payload)?;
|
||||||
|
let src_port = udp.get_source();
|
||||||
let dst_port = udp.get_destination();
|
let dst_port = udp.get_destination();
|
||||||
if dst_port != 53 {
|
if src_port != 53 && dst_port != 53 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (qname, qtype, rcode) = classify_dns_query(udp.payload())?;
|
let parsed = parse_dns_message(udp.payload())?;
|
||||||
Some(ClassifiedEvent {
|
Some(SensorEvent::Dns(ClassifiedEvent {
|
||||||
timestamp_ms: now_ms(),
|
timestamp_ms: now_ms(),
|
||||||
proto: FlowProtocol::Udp,
|
proto: FlowProtocol::Udp,
|
||||||
src_ip,
|
src_ip,
|
||||||
src_port: udp.get_source(),
|
src_port,
|
||||||
dst_ip,
|
dst_ip,
|
||||||
dst_port,
|
dst_port,
|
||||||
iface_name: Some(iface_name.to_string()),
|
iface_name: Some(iface_name.to_string()),
|
||||||
transport: LeakTransport::Udp53,
|
transport: LeakTransport::Udp53,
|
||||||
qname: Some(qname),
|
qname: Some(parsed.qname),
|
||||||
qtype: Some(qtype),
|
qtype: Some(parsed.qtype),
|
||||||
rcode: Some(rcode),
|
rcode: Some(parsed.rcode),
|
||||||
})
|
is_response: parsed.is_response,
|
||||||
|
answer_ips: parsed.answer_ips,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
@@ -234,20 +276,36 @@ fn parse_tcp(
|
|||||||
dst_ip: IpAddr,
|
dst_ip: IpAddr,
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::tcp::TcpPacket;
|
use pnet::packet::tcp::TcpPacket;
|
||||||
let tcp = TcpPacket::new(payload)?;
|
let tcp = TcpPacket::new(payload)?;
|
||||||
let dst_port = tcp.get_destination();
|
let dst_port = tcp.get_destination();
|
||||||
|
let src_port = tcp.get_source();
|
||||||
let transport = match dst_port {
|
let transport = match dst_port {
|
||||||
53 => LeakTransport::Tcp53,
|
53 => LeakTransport::Tcp53,
|
||||||
853 => LeakTransport::Dot,
|
853 => LeakTransport::Dot,
|
||||||
_ => return None,
|
_ => {
|
||||||
|
let flags = tcp.get_flags();
|
||||||
|
let syn = flags & 0x02 != 0;
|
||||||
|
let ack = flags & 0x10 != 0;
|
||||||
|
if syn && !ack {
|
||||||
|
return Some(SensorEvent::Tcp(TcpEvent {
|
||||||
|
timestamp_ms: now_ms(),
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
dst_ip,
|
||||||
|
dst_port,
|
||||||
|
iface_name: Some(iface_name.to_string()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Some(ClassifiedEvent {
|
Some(SensorEvent::Dns(ClassifiedEvent {
|
||||||
timestamp_ms: now_ms(),
|
timestamp_ms: now_ms(),
|
||||||
proto: FlowProtocol::Tcp,
|
proto: FlowProtocol::Tcp,
|
||||||
src_ip,
|
src_ip,
|
||||||
src_port: tcp.get_source(),
|
src_port,
|
||||||
dst_ip,
|
dst_ip,
|
||||||
dst_port,
|
dst_port,
|
||||||
iface_name: Some(iface_name.to_string()),
|
iface_name: Some(iface_name.to_string()),
|
||||||
@@ -255,7 +313,9 @@ fn parse_tcp(
|
|||||||
qname: None,
|
qname: None,
|
||||||
qtype: None,
|
qtype: None,
|
||||||
rcode: None,
|
rcode: None,
|
||||||
})
|
is_response: false,
|
||||||
|
answer_ips: Vec::new(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
@@ -265,6 +325,7 @@ fn select_interface(
|
|||||||
) -> Result<(datalink::NetworkInterface, Box<dyn datalink::DataLinkReceiver>), DnsLeakError> {
|
) -> Result<(datalink::NetworkInterface, Box<dyn datalink::DataLinkReceiver>), DnsLeakError> {
|
||||||
let interfaces = datalink::interfaces();
|
let interfaces = datalink::interfaces();
|
||||||
if let Some(name) = name {
|
if let Some(name) = name {
|
||||||
|
debug!("dns leak iface pick: requested={name}");
|
||||||
let iface = interfaces
|
let iface = interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.find(|iface| iface.name == name)
|
.find(|iface| iface.name == name)
|
||||||
@@ -283,16 +344,18 @@ fn select_interface(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(iface) = pick_stable_iface(&interfaces) {
|
let ordered = order_interfaces(&interfaces);
|
||||||
if let Ok(channel) = open_channel_with_timeout(iface, config) {
|
for iface in ordered.iter() {
|
||||||
return Ok(channel);
|
debug!("dns leak iface pick: try={}", iface.name);
|
||||||
|
match open_channel_with_timeout(iface.clone(), config) {
|
||||||
|
Ok(channel) => return Ok(channel),
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"dns leak iface pick: failed iface={} err={}",
|
||||||
|
iface.name, err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for iface in interfaces.iter() {
|
|
||||||
if let Ok(channel) = open_channel_with_timeout(iface.clone(), config) {
|
|
||||||
return Ok(channel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(DnsLeakError::Io(format!(
|
Err(DnsLeakError::Io(format!(
|
||||||
@@ -317,7 +380,7 @@ fn open_channel_with_timeout(
|
|||||||
let _ = tx.send((iface, result));
|
let _ = tx.send((iface, result));
|
||||||
});
|
});
|
||||||
|
|
||||||
let timeout = Duration::from_millis(700);
|
let timeout = Duration::from_millis(OPEN_IFACE_TIMEOUT_MS);
|
||||||
match rx.recv_timeout(timeout) {
|
match rx.recv_timeout(timeout) {
|
||||||
Ok((iface, Ok(rx))) => Ok((iface, rx)),
|
Ok((iface, Ok(rx))) => Ok((iface, rx)),
|
||||||
Ok((_iface, Err(err))) => Err(err),
|
Ok((_iface, Err(err))) => Err(err),
|
||||||
@@ -337,26 +400,27 @@ fn is_named_fallback(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
fn pick_stable_iface(
|
fn order_interfaces(
|
||||||
interfaces: &[datalink::NetworkInterface],
|
interfaces: &[datalink::NetworkInterface],
|
||||||
) -> Option<datalink::NetworkInterface> {
|
) -> Vec<datalink::NetworkInterface> {
|
||||||
let mut preferred = interfaces
|
let mut preferred = Vec::new();
|
||||||
.iter()
|
let mut others = Vec::new();
|
||||||
.filter(|iface| {
|
for iface in interfaces.iter() {
|
||||||
iface.is_up()
|
if iface.is_loopback() {
|
||||||
&& !iface.is_loopback()
|
continue;
|
||||||
&& (is_named_fallback(&iface.name) || !iface.ips.is_empty())
|
}
|
||||||
})
|
if is_named_fallback(&iface.name) || !iface.ips.is_empty() {
|
||||||
.cloned()
|
preferred.push(iface.clone());
|
||||||
.collect::<Vec<_>>();
|
} else {
|
||||||
if preferred.is_empty() {
|
others.push(iface.clone());
|
||||||
preferred = interfaces
|
}
|
||||||
.iter()
|
}
|
||||||
.filter(|iface| !iface.is_loopback())
|
preferred.extend(others);
|
||||||
.cloned()
|
if preferred.is_empty() {
|
||||||
.collect();
|
interfaces.to_vec()
|
||||||
|
} else {
|
||||||
|
preferred
|
||||||
}
|
}
|
||||||
preferred.into_iter().next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use quinn::ClientConfig as QuinnClientConfig;
|
|||||||
#[cfg(feature = "http3")]
|
#[cfg(feature = "http3")]
|
||||||
use quinn::Endpoint;
|
use quinn::Endpoint;
|
||||||
#[cfg(feature = "http3")]
|
#[cfg(feature = "http3")]
|
||||||
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
|
#[cfg(feature = "http3")]
|
||||||
use webpki_roots::TLS_SERVER_ROOTS;
|
use webpki_roots::TLS_SERVER_ROOTS;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -468,26 +470,54 @@ async fn http3_request(
|
|||||||
let port = parsed
|
let port = parsed
|
||||||
.port_or_known_default()
|
.port_or_known_default()
|
||||||
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
|
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
|
||||||
let ip = resolved_ips
|
|
||||||
.first()
|
|
||||||
.and_then(|value| value.parse::<IpAddr>().ok())
|
|
||||||
.ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?;
|
|
||||||
|
|
||||||
let quinn_config = build_quinn_config()?;
|
let quinn_config = build_quinn_config()?;
|
||||||
|
let candidates = resolved_ips
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.parse::<IpAddr>().ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err(HttpError::Request("no resolved IPs for http3".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())
|
let mut endpoint_guard = None;
|
||||||
|
let mut connection = None;
|
||||||
|
let mut connect_ms = None;
|
||||||
|
for ip in candidates {
|
||||||
|
let bind_addr = match ip {
|
||||||
|
IpAddr::V4(_) => "0.0.0.0:0",
|
||||||
|
IpAddr::V6(_) => "[::]:0",
|
||||||
|
};
|
||||||
|
let mut endpoint = Endpoint::client(bind_addr.parse().unwrap())
|
||||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||||
endpoint.set_default_client_config(quinn_config);
|
endpoint.set_default_client_config(quinn_config.clone());
|
||||||
|
|
||||||
let connect_start = Instant::now();
|
let connect_start = Instant::now();
|
||||||
let connecting = endpoint
|
let connecting = match endpoint.connect(SocketAddr::new(ip, port), host) {
|
||||||
.connect(SocketAddr::new(ip, port), host)
|
Ok(connecting) => connecting,
|
||||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
Err(err) => {
|
||||||
let connection = timeout(Duration::from_millis(opts.timeout_ms), connecting)
|
warnings.push(format!("http3 connect failed to {ip}: {err}"));
|
||||||
.await
|
continue;
|
||||||
.map_err(|_| HttpError::Request("http3 connect timed out".to_string()))?
|
}
|
||||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
};
|
||||||
let connect_ms = connect_start.elapsed().as_millis();
|
match timeout(Duration::from_millis(opts.timeout_ms), connecting).await {
|
||||||
|
Ok(Ok(conn)) => {
|
||||||
|
connect_ms = Some(connect_start.elapsed().as_millis());
|
||||||
|
connection = Some(conn);
|
||||||
|
endpoint_guard = Some(endpoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warnings.push(format!("http3 connect failed to {ip}: {err}"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warnings.push(format!("http3 connect to {ip} timed out"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection = connection.ok_or_else(|| {
|
||||||
|
HttpError::Request("http3 connect failed for all resolved IPs".to_string())
|
||||||
|
})?;
|
||||||
|
let connect_ms = connect_ms.unwrap_or_default();
|
||||||
|
|
||||||
let conn = h3_quinn::Connection::new(connection);
|
let conn = h3_quinn::Connection::new(connection);
|
||||||
let (mut driver, mut send_request) = h3::client::new(conn)
|
let (mut driver, mut send_request) = h3::client::new(conn)
|
||||||
@@ -563,8 +593,8 @@ async fn http3_request(
|
|||||||
|
|
||||||
warnings.push("http3 timing for tls/connect is best-effort".to_string());
|
warnings.push("http3 timing for tls/connect is best-effort".to_string());
|
||||||
|
|
||||||
Ok((
|
let _endpoint_guard = endpoint_guard;
|
||||||
HttpReport {
|
let report = HttpReport {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
final_url: Some(final_url),
|
final_url: Some(final_url),
|
||||||
method: match opts.method {
|
method: match opts.method {
|
||||||
@@ -584,9 +614,9 @@ async fn http3_request(
|
|||||||
tls_ms: None,
|
tls_ms: None,
|
||||||
ttfb_ms: Some(ttfb_ms),
|
ttfb_ms: Some(ttfb_ms),
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
warnings,
|
|
||||||
))
|
Ok((report, warnings))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "http3")]
|
#[cfg(feature = "http3")]
|
||||||
@@ -594,10 +624,14 @@ fn build_quinn_config() -> Result<QuinnClientConfig, HttpError> {
|
|||||||
let mut roots = quinn::rustls::RootCertStore::empty();
|
let mut roots = quinn::rustls::RootCertStore::empty();
|
||||||
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
|
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
|
||||||
let mut client_config =
|
let mut crypto = quinn::rustls::ClientConfig::builder()
|
||||||
QuinnClientConfig::with_root_certificates(Arc::new(roots)).map_err(|err| {
|
.with_root_certificates(roots)
|
||||||
HttpError::Request(format!("quinn config error: {err}"))
|
.with_no_client_auth();
|
||||||
})?;
|
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||||
|
let mut client_config = QuinnClientConfig::new(Arc::new(
|
||||||
|
QuicClientConfig::try_from(crypto)
|
||||||
|
.map_err(|err| HttpError::Request(format!("quinn config error: {err}")))?,
|
||||||
|
));
|
||||||
let mut transport = quinn::TransportConfig::default();
|
let mut transport = quinn::TransportConfig::default();
|
||||||
transport.keep_alive_interval(Some(Duration::from_secs(5)));
|
transport.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||||
client_config.transport_config(Arc::new(transport));
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
|||||||
@@ -3,36 +3,36 @@
|
|||||||
This document lists CLI commands and supported flags. Output defaults to text; use `--json` for structured output.
|
This document lists CLI commands and supported flags. Output defaults to text; use `--json` for structured output.
|
||||||
|
|
||||||
## Global flags
|
## Global flags
|
||||||
- `--json` / `--pretty`
|
- `--json` / `--pretty`: emit JSON output (pretty-print if requested)
|
||||||
- `--no-color` / `--quiet`
|
- `--no-color` / `--quiet`: disable ANSI colors / reduce stdout output
|
||||||
- `-v` / `-vv` / `--verbose`
|
- `-v` / `-vv` / `--verbose`: increase log verbosity
|
||||||
- `--log-level <error|warn|info|debug|trace>`
|
- `--log-level <error|warn|info|debug|trace>`: set log level
|
||||||
- `--log-format <text|json>`
|
- `--log-format <text|json>`: set log format
|
||||||
- `--log-file <path>`
|
- `--log-file <path>`: write logs to file
|
||||||
- `NETTOOL_LOG_FILTER` or `RUST_LOG` can override log filters (ex: `maxminddb::decoder=debug`)
|
- `NETTOOL_LOG_FILTER` or `RUST_LOG` can override log filters (ex: `maxminddb::decoder=debug`)
|
||||||
|
|
||||||
## sys
|
## sys
|
||||||
- `sys ifaces`
|
- `sys ifaces`: list network interfaces
|
||||||
- `sys ip` flags: `--all`, `--iface <name>`
|
- `sys ip` flags: `--all` (include link-local), `--iface <name>` (filter by interface)
|
||||||
- `sys route` flags: `--ipv4`, `--ipv6`, `--to <ip>`
|
- `sys route` flags: `--ipv4`, `--ipv6`, `--to <ip>` (filter by destination)
|
||||||
- `sys dns`
|
- `sys dns`: show DNS configuration
|
||||||
|
|
||||||
## ports
|
## ports
|
||||||
- `ports listen` flags: `--tcp`, `--udp`, `--port <n>`
|
- `ports listen` flags: `--tcp`, `--udp`, `--port <n>` (filter by port)
|
||||||
- `ports who <port>`
|
- `ports who <port>`: find owning processes for a port
|
||||||
- `ports conns` flags: `--top <n>`, `--by-process`
|
- `ports conns` flags: `--top <n>`, `--by-process` (summaries)
|
||||||
|
|
||||||
## neigh
|
## neigh
|
||||||
- `neigh list` flags: `--ipv4`, `--ipv6`, `--iface <name>`
|
- `neigh list` flags: `--ipv4`, `--ipv6`, `--iface <name>`
|
||||||
|
|
||||||
## cert
|
## cert
|
||||||
- `cert roots`
|
- `cert roots`: list trusted root certificates
|
||||||
- `cert baseline <path>`
|
- `cert baseline <path>`: write baseline JSON
|
||||||
- `cert diff <path>`
|
- `cert diff <path>`: diff against baseline JSON
|
||||||
|
|
||||||
## geoip
|
## geoip
|
||||||
- `geoip lookup <ip>`
|
- `geoip lookup <ip>`: lookup GeoIP
|
||||||
- `geoip status`
|
- `geoip status`: show GeoIP database status
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
- `probe ping <host>` flags: `--count <n>`, `--timeout-ms <n>`, `--interval-ms <n>`, `--no-geoip`
|
- `probe ping <host>` flags: `--count <n>`, `--timeout-ms <n>`, `--interval-ms <n>`, `--no-geoip`
|
||||||
@@ -42,13 +42,13 @@ This document lists CLI commands and supported flags. Output defaults to text; u
|
|||||||
## dns
|
## dns
|
||||||
- `dns query <domain> <type>` flags: `--server <ip[:port]>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--timeout-ms <n>`
|
- `dns query <domain> <type>` flags: `--server <ip[:port]>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--timeout-ms <n>`
|
||||||
- `dns detect <domain>` flags: `--servers <csv>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--repeat <n>`, `--timeout-ms <n>`
|
- `dns detect <domain>` flags: `--servers <csv>`, `--transport <udp|tcp|dot|doh>`, `--tls-name <name>`, `--socks5 <url>`, `--prefer-ipv4`, `--repeat <n>`, `--timeout-ms <n>`
|
||||||
- `dns watch` flags: `--duration <Ns|Nms>`, `--iface <name>`, `--filter <pattern>`
|
- `dns watch` flags: `--duration <Ns|Nms>`, `--follow` (run until Ctrl-C), `--iface <name>`, `--filter <pattern>`
|
||||||
- `dns leak status` flags: `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`
|
- `dns leak status` flags: `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`
|
||||||
- `dns leak watch` flags: `--duration <Ns|Nms>`, `--iface <name>`, `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`, `--privacy <full|redacted|minimal>`, `--out <path>`, `--summary-only`, `--iface-diag`
|
- `dns leak watch` flags: `--duration <Ns|Nms>`, `--follow` (run until Ctrl-C), `--iface <name>`, `--profile <full-tunnel|proxy-stub|split>`, `--policy <path>`, `--privacy <full|redacted|minimal>`, `--out <path>`, `--summary-only`, `--iface-diag` (list capture-capable interfaces)
|
||||||
- `dns leak report` flags: `<path>`, `--privacy <full|redacted|minimal>`
|
- `dns leak report` flags: `<path>`, `--privacy <full|redacted|minimal>`
|
||||||
|
|
||||||
## http
|
## http
|
||||||
- `http head|get <url>` flags: `--timeout-ms <n>`, `--follow-redirects <n>`, `--show-headers`, `--show-body`, `--max-body-bytes <n>`, `--http1-only`, `--http2-only`, `--http3` (feature `http3`), `--http3-only` (feature `http3`), `--geoip`, `--socks5 <url>`
|
- `http head|get <url>` flags: `--timeout-ms <n>`, `--follow-redirects <n>`, `--show-headers`, `--show-body`, `--max-body-bytes <n>`, `--http1-only`, `--http2-only`, `--http3` (required feature `http3`), `--http3-only` (required feature `http3`), `--geoip`, `--socks5 <url>`
|
||||||
|
|
||||||
## tls
|
## tls
|
||||||
- `tls handshake|cert|verify|alpn <host:port>` flags: `--sni <name>`, `--alpn <csv>`, `--timeout-ms <n>`, `--insecure`, `--socks5 <url>`, `--prefer-ipv4`, `--show-extensions`, `--ocsp`
|
- `tls handshake|cert|verify|alpn <host:port>` flags: `--sni <name>`, `--alpn <csv>`, `--timeout-ms <n>`, `--insecure`, `--socks5 <url>`, `--prefer-ipv4`, `--show-extensions`, `--ocsp`
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ Add under `dns` command group:
|
|||||||
- summary report (human) by default
|
- summary report (human) by default
|
||||||
- `--json` returns structured report with events list
|
- `--json` returns structured report with events list
|
||||||
|
|
||||||
|
`--follow` keeps the watch running by resolving the duration to a large
|
||||||
|
placeholder (one year in milliseconds) and then racing the watch against
|
||||||
|
`tokio::signal::ctrl_c()`; Ctrl-C returns early with a clean exit code so the
|
||||||
|
outer loop stops.
|
||||||
|
|
||||||
## 9) Recommended incremental build plan
|
## 9) Recommended incremental build plan
|
||||||
|
|
||||||
Phase 1 (core passive detection):
|
Phase 1 (core passive detection):
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ This is a practical checklist to execute v0.4.0.
|
|||||||
|
|
||||||
## 5) follow-ups
|
## 5) follow-ups
|
||||||
- [ ] add DoH heuristic classification (optional)
|
- [ ] add DoH heuristic classification (optional)
|
||||||
- [ ] add Leak-D mismatch correlation (optional)
|
- [x] add Leak-D mismatch correlation (optional)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This document tracks the current DNS leak detector implementation against the de
|
|||||||
- Leak-A (plaintext DNS outside safe path).
|
- Leak-A (plaintext DNS outside safe path).
|
||||||
- Leak-B (split-policy intent leak based on proxy-required/allowlist domains).
|
- Leak-B (split-policy intent leak based on proxy-required/allowlist domains).
|
||||||
- Leak-C (encrypted DNS bypass for DoT).
|
- Leak-C (encrypted DNS bypass for DoT).
|
||||||
|
- Leak-D (basic mismatch: DNS response IP -> outbound TCP SYN on different route).
|
||||||
- Policy profiles: `full-tunnel`, `proxy-stub`, `split`.
|
- Policy profiles: `full-tunnel`, `proxy-stub`, `split`.
|
||||||
- Privacy modes: full/redacted/minimal (redacts qname).
|
- Privacy modes: full/redacted/minimal (redacts qname).
|
||||||
- Process attribution:
|
- Process attribution:
|
||||||
@@ -23,9 +24,15 @@ This document tracks the current DNS leak detector implementation against the de
|
|||||||
- `dns leak watch`
|
- `dns leak watch`
|
||||||
- `dns leak report`
|
- `dns leak report`
|
||||||
- `dns leak watch --iface-diag` (diagnostics for capture-capable interfaces).
|
- `dns leak watch --iface-diag` (diagnostics for capture-capable interfaces).
|
||||||
|
- `dns leak watch --follow` runs until Ctrl-C by combining a long duration with
|
||||||
|
a `tokio::signal::ctrl_c()` early-exit path.
|
||||||
- Interface selection:
|
- Interface selection:
|
||||||
- per-interface open timeout to avoid capture hangs
|
- per-interface open timeout to avoid capture hangs
|
||||||
- stable default pick (up, non-loopback, named ethernet/wlan) before fallback scan
|
- ordered scan prefers non-loopback + named ethernet/wlan and interfaces with IPs
|
||||||
|
- verbose logging of interface selection attempts + failures (use `-v` / `-vv`)
|
||||||
|
- overall watch timeout accounts for worst-case interface scan time
|
||||||
|
- Capture loop:
|
||||||
|
- receiver runs in a worker thread; main loop polls with a short timeout to avoid blocking
|
||||||
|
|
||||||
## Partially implemented
|
## Partially implemented
|
||||||
- Route/interface classification: heuristic only (loopback/tunnel/physical by iface name).
|
- Route/interface classification: heuristic only (loopback/tunnel/physical by iface name).
|
||||||
@@ -33,10 +40,16 @@ This document tracks the current DNS leak detector implementation against the de
|
|||||||
|
|
||||||
## Not implemented (v0.4 backlog)
|
## Not implemented (v0.4 backlog)
|
||||||
- DoH heuristic detection (SNI/endpoint list/traffic shape).
|
- DoH heuristic detection (SNI/endpoint list/traffic shape).
|
||||||
- Leak-D mismatch correlation (DNS -> TCP/TLS flows).
|
|
||||||
- GeoIP enrichment of leak events.
|
- GeoIP enrichment of leak events.
|
||||||
- Process tree reporting (PPID chain).
|
- Process tree reporting (PPID chain).
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
- On Windows, pcap capture may require selecting a specific NPF interface; use
|
- On Windows, pcap capture may require selecting a specific NPF interface; use
|
||||||
`dns leak watch --iface-diag` to list interfaces that can be opened.
|
`dns leak watch --iface-diag` to list interfaces that can be opened.
|
||||||
|
- Leak-D test attempts on Windows did not fire; see test notes below.
|
||||||
|
|
||||||
|
## Test notes
|
||||||
|
- `dns leak watch --duration 8s --summary-only --iface <NPF>` captured UDP/53 and produced Leak-A.
|
||||||
|
- `dns leak watch --duration 15s --iface <NPF>` with scripted DNS query + TCP connect:
|
||||||
|
- UDP/53 query/response captured (Leak-A).
|
||||||
|
- TCP SYNs observed, but did not match cached DNS response IPs, so Leak-D did not trigger.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ This document tracks current implementation status against the original design i
|
|||||||
- DNS watch uses `pnet` and is feature-gated as best-effort.
|
- DNS watch uses `pnet` and is feature-gated as best-effort.
|
||||||
|
|
||||||
## Gaps vs design (as of now)
|
## Gaps vs design (as of now)
|
||||||
- HTTP/3 is feature-gated and incomplete; not enabled in default builds.
|
- HTTP/3 is feature-gated and best-effort; not enabled in default builds.
|
||||||
- TLS verification is rustls-based (no OS-native verifier).
|
- TLS verification is rustls-based (no OS-native verifier).
|
||||||
- DNS leak DoH detection is heuristic and currently optional.
|
- DNS leak DoH detection is heuristic and currently optional.
|
||||||
|
|
||||||
|
|||||||
13
justfile
Normal file
13
justfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# justfile (cross-platform, no bash)
|
||||||
|
python := env_var_or_default("PYTHON", if os() == "windows" { "python" } else { "python3" })
|
||||||
|
dist_dir := "dist"
|
||||||
|
stage_root := "target/release-package"
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
release bin='' target='':
|
||||||
|
{{python}} scripts/release_meta.py --bin "{{bin}}" --target "{{target}}" --dist-dir "{{dist_dir}}" --stage-root "{{stage_root}}"
|
||||||
|
|
||||||
|
clean-dist:
|
||||||
|
{{python}} -c "import shutil; shutil.rmtree('dist', ignore_errors=True); shutil.rmtree('target/release-package', ignore_errors=True)"
|
||||||
175
scripts/release_meta.py
Normal file
175
scripts/release_meta.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], *, capture: bool = False) -> str:
|
||||||
|
if capture:
|
||||||
|
return subprocess.check_output(cmd, text=True).strip()
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cargo_metadata() -> dict[str, Any]:
|
||||||
|
out = run(["cargo", "metadata", "--no-deps", "--format-version", "1"], capture=True)
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
|
||||||
|
def rustc_host_triple() -> str:
|
||||||
|
v = run(["rustc", "-vV"], capture=True)
|
||||||
|
for line in v.splitlines():
|
||||||
|
if line.startswith("host: "):
|
||||||
|
return line.split("host: ", 1)[1].strip()
|
||||||
|
raise RuntimeError("Could not determine host target triple from `rustc -vV`")
|
||||||
|
|
||||||
|
|
||||||
|
def is_windows_host() -> bool:
|
||||||
|
# Works for normal Windows Python and most MSYS/Cygwin Pythons too.
|
||||||
|
sp = sys.platform.lower()
|
||||||
|
ps = platform.system().lower()
|
||||||
|
return (
|
||||||
|
os.name == "nt"
|
||||||
|
or sp.startswith("win")
|
||||||
|
or sp.startswith("cygwin")
|
||||||
|
or sp.startswith("msys")
|
||||||
|
or "windows" in ps
|
||||||
|
or "cygwin" in ps
|
||||||
|
or "msys" in ps
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exe_suffix_for_target(target_triple: str) -> str:
|
||||||
|
return ".exe" if "windows" in target_triple else ""
|
||||||
|
|
||||||
|
|
||||||
|
def find_bin_targets(meta: dict[str, Any]) -> list[tuple[str, str, str]]:
|
||||||
|
bins: list[tuple[str, str, str]] = []
|
||||||
|
for p in meta.get("packages", []):
|
||||||
|
for t in p.get("targets", []):
|
||||||
|
if "bin" in t.get("kind", []):
|
||||||
|
bins.append((p["name"], p["version"], t["name"]))
|
||||||
|
bins.sort(key=lambda x: (x[0], x[2], x[1])) # stable deterministic choice
|
||||||
|
return bins
|
||||||
|
|
||||||
|
|
||||||
|
def find_owner_package_for_bin(meta: dict[str, Any], bin_name: str) -> tuple[str, str]:
|
||||||
|
for p in meta.get("packages", []):
|
||||||
|
for t in p.get("targets", []):
|
||||||
|
if t.get("name") == bin_name and "bin" in t.get("kind", []):
|
||||||
|
return p["name"], p["version"]
|
||||||
|
raise RuntimeError(f"Could not find a package providing bin '{bin_name}'")
|
||||||
|
|
||||||
|
|
||||||
|
def stage_and_archive(
|
||||||
|
*,
|
||||||
|
pkg_name: str,
|
||||||
|
pkg_version: str,
|
||||||
|
bin_path: Path,
|
||||||
|
data_dir: Path,
|
||||||
|
dist_dir: Path,
|
||||||
|
stage_root: Path,
|
||||||
|
target_triple_for_name: str,
|
||||||
|
) -> Path:
|
||||||
|
pkg_base = f"{pkg_name}-v{pkg_version}-{target_triple_for_name}"
|
||||||
|
stage_dir = stage_root / pkg_base
|
||||||
|
stage_data_dir = stage_dir / "data"
|
||||||
|
|
||||||
|
if stage_root.exists():
|
||||||
|
shutil.rmtree(stage_root)
|
||||||
|
stage_data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
shutil.copy2(bin_path, stage_dir / bin_path.name)
|
||||||
|
|
||||||
|
mmdbs = sorted(data_dir.glob("*.mmdb")) if data_dir.exists() else []
|
||||||
|
if mmdbs:
|
||||||
|
for f in mmdbs:
|
||||||
|
shutil.copy2(f, stage_data_dir / f.name)
|
||||||
|
else:
|
||||||
|
print("WARN: no ./data/*.mmdb found; packaging binary only.", file=sys.stderr)
|
||||||
|
|
||||||
|
if is_windows_host():
|
||||||
|
out = dist_dir / f"{pkg_base}.zip"
|
||||||
|
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for p in stage_dir.rglob("*"):
|
||||||
|
if p.is_file():
|
||||||
|
z.write(p, arcname=str(Path(pkg_base) / p.relative_to(stage_dir)))
|
||||||
|
return out
|
||||||
|
else:
|
||||||
|
out = dist_dir / f"{pkg_base}.tar.gz"
|
||||||
|
with tarfile.open(out, "w:gz") as tf:
|
||||||
|
tf.add(stage_dir, arcname=pkg_base)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Build and package Rust binary + data/*.mmdb")
|
||||||
|
ap.add_argument("--bin", default="", help="Binary target name (optional)")
|
||||||
|
ap.add_argument("--target", default="", help="Cargo target triple (optional)")
|
||||||
|
ap.add_argument("--dist-dir", default="dist", help="Output directory for archives")
|
||||||
|
ap.add_argument("--stage-root", default="target/release-package", help="Staging directory root")
|
||||||
|
ap.add_argument("--data-dir", default="data", help="Directory containing .mmdb files")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
meta = cargo_metadata()
|
||||||
|
bins = find_bin_targets(meta)
|
||||||
|
if not bins:
|
||||||
|
print("ERROR: no binary targets found in workspace.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
bin_name = args.bin.strip()
|
||||||
|
if not bin_name:
|
||||||
|
_, _, bin_name = bins[0]
|
||||||
|
print(f"INFO: --bin not provided; defaulting to '{bin_name}'", file=sys.stderr)
|
||||||
|
|
||||||
|
pkg_name, pkg_version = find_owner_package_for_bin(meta, bin_name)
|
||||||
|
|
||||||
|
host_triple = rustc_host_triple()
|
||||||
|
target_triple_for_name = args.target.strip() or host_triple
|
||||||
|
|
||||||
|
# Build only the owning package
|
||||||
|
build_cmd = ["cargo", "build", "-p", pkg_name, "--release"]
|
||||||
|
if args.target.strip():
|
||||||
|
build_cmd += ["--target", args.target.strip()]
|
||||||
|
run(build_cmd)
|
||||||
|
|
||||||
|
# Locate binary
|
||||||
|
exe_suffix = exe_suffix_for_target(target_triple_for_name)
|
||||||
|
bin_dir = Path("target") / (args.target.strip() if args.target.strip() else "release") / "release" \
|
||||||
|
if args.target.strip() else Path("target") / "release"
|
||||||
|
if args.target.strip():
|
||||||
|
bin_dir = Path("target") / args.target.strip() / "release"
|
||||||
|
|
||||||
|
bin_path = bin_dir / f"{bin_name}{exe_suffix}"
|
||||||
|
if not bin_path.exists():
|
||||||
|
print(f"ERROR: built binary not found: {bin_path}", file=sys.stderr)
|
||||||
|
print("Hint: pass the correct bin target name: just release bin=<name>", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
out = stage_and_archive(
|
||||||
|
pkg_name=pkg_name,
|
||||||
|
pkg_version=pkg_version,
|
||||||
|
bin_path=bin_path,
|
||||||
|
data_dir=Path(args.data_dir),
|
||||||
|
dist_dir=Path(args.dist_dir),
|
||||||
|
stage_root=Path(args.stage_root),
|
||||||
|
target_triple_for_name=target_triple_for_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Created: {out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user