Compare commits

...

11 Commits

Author SHA1 Message Date
DaZuo0122
5a23175a83 Update: Build instructions for just 2026-02-02 13:00:19 +08:00
DaZuo0122
57492ab654 Add: Justfile to replace cmake and make 2026-02-02 12:32:56 +08:00
DaZuo0122
7054ff77a7 Fix: http/3 alpn bugs 2026-01-18 23:05:41 +08:00
DaZuo0122
9bcb7549f3 Bump version to 0.4.0 2026-01-17 22:09:23 +08:00
DaZuo0122
1da9b915d8 Update documents 2026-01-17 20:13:37 +08:00
DaZuo0122
94762d139a Add: flag to make watch keep running 2026-01-17 20:07:13 +08:00
DaZuo0122
f349d4b4fa Add: description in help message 2026-01-17 19:49:53 +08:00
DaZuo0122
7f6ee839b2 Add: Leak-D for dns leak detection 2026-01-17 19:42:54 +08:00
DaZuo0122
a82a7fe2ad Add: include interface pickup failure in log 2026-01-17 19:10:52 +08:00
DaZuo0122
d5b92ede7b Fix: main thread timeout early than work thread 2026-01-17 19:07:10 +08:00
DaZuo0122
144e801e13 Add: verbose for dns leak iface picking process 2026-01-17 18:53:07 +08:00
20 changed files with 902 additions and 394 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
/data /data
/dist

View File

@@ -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
View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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" }

View File

@@ -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();

View File

@@ -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 {
let message = Message::from_vec(payload).ok()?; pub qname: String,
if message.message_type() != MessageType::Query { pub qtype: String,
return None; 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 is_response = message.message_type() == MessageType::Response;
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,
})
} }

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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")]

View File

@@ -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));

View File

@@ -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`

View File

@@ -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):

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
View 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
View 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())