Compare commits

..

8 Commits

Author SHA1 Message Date
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
13 changed files with 613 additions and 282 deletions

10
Cargo.lock generated
View File

@@ -2230,6 +2230,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "simd-adler32"
version = "0.3.8"
@@ -2504,6 +2513,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.61.2",

View File

@@ -36,23 +36,25 @@ wtfn neigh list --ipv6
wtfn geoip lookup 8.8.8.8
wtfn probe ping example.com --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
# DNS
wtfn dns query example.com A
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 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 watch --duration 10s --filter example.com
wtfn dns watch --follow
wtfn dns leak status
wtfn dns leak watch --duration 10s --profile proxy-stub
wtfn dns leak watch --follow
wtfn dns leak report report.json
# TLS
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 verify example.com:443
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
```
## Supported flags
Global flags:
- `--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>`
## Command reference
See `docs/COMMANDS.md` for the full list of commands and flags (with descriptions).
## GeoIP data files
GeoLite2 mmdb files should live in `data/`.

View File

@@ -1,6 +1,6 @@
[package]
name = "wtfnet-cli"
version = "0.1.0"
version = "0.4.0"
edition = "2024"
[[bin]]
@@ -12,7 +12,7 @@ clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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-calc = { path = "../wtfnet-calc" }
wtfnet-geoip = { path = "../wtfnet-geoip" }

View File

@@ -16,21 +16,21 @@ use wtfnet_platform::{Platform, PlatformError};
arg_required_else_help = true
)]
struct Cli {
#[arg(long)]
#[arg(long, help = "Emit JSON output")]
json: bool,
#[arg(long)]
#[arg(long, help = "Pretty-print JSON output")]
pretty: bool,
#[arg(long)]
#[arg(long, help = "Disable ANSI colors")]
no_color: bool,
#[arg(long)]
#[arg(long, help = "Reduce stdout output")]
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,
#[arg(long)]
#[arg(long, help = "Set log level (error|warn|info|debug|trace)")]
log_level: Option<String>,
#[arg(long)]
#[arg(long, help = "Set log format (text|json)")]
log_format: Option<String>,
#[arg(long)]
#[arg(long, help = "Write logs to a file")]
log_file: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
@@ -38,98 +38,130 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// System snapshot: interfaces, IPs, routes, DNS
Sys {
#[command(subcommand)]
command: SysCommand,
},
/// Ports and socket ownership
Ports {
#[command(subcommand)]
command: PortsCommand,
},
/// Neighbor table (ARP/NDP)
Neigh {
#[command(subcommand)]
command: NeighCommand,
},
/// Certificate roots and baselines
Cert {
#[command(subcommand)]
command: CertCommand,
},
/// GeoIP lookup helpers
Geoip {
#[command(subcommand)]
command: GeoIpCommand,
},
/// Probing tools (ping/tcping/trace)
Probe {
#[command(subcommand)]
command: ProbeCommand,
},
/// DNS query, detect, watch, and leak detection
Dns {
#[command(subcommand)]
command: DnsCommand,
},
/// Subnet calculator
Calc {
#[command(subcommand)]
command: CalcCommand,
},
/// HTTP head/get diagnostics
Http {
#[command(subcommand)]
command: HttpCommand,
},
/// TLS handshake and certificate analysis
Tls {
#[command(subcommand)]
command: TlsCommand,
},
/// Local network discovery (mDNS/SSDP/LLMNR/NBNS)
Discover {
#[command(subcommand)]
command: DiscoverCommand,
},
/// Bundle a diagnostic report
Diag(DiagArgs),
}
#[derive(Subcommand, Debug)]
enum SysCommand {
/// List network interfaces
Ifaces,
/// Show IP addresses
Ip(SysIpArgs),
/// Show route table
Route(SysRouteArgs),
/// Show DNS configuration
Dns,
}
#[derive(Subcommand, Debug)]
enum PortsCommand {
/// List listening sockets
Listen(PortsListenArgs),
/// Find socket owners for a port
Who(PortsWhoArgs),
/// List active TCP connections
Conns(PortsConnsArgs),
}
#[derive(Subcommand, Debug)]
enum NeighCommand {
/// List ARP/NDP neighbors
List(NeighListArgs),
}
#[derive(Subcommand, Debug)]
enum CertCommand {
/// List trusted root certificates
Roots,
/// Write a baseline file of trusted roots
Baseline(CertBaselineArgs),
/// Diff trusted roots against a baseline
Diff(CertDiffArgs),
}
#[derive(Subcommand, Debug)]
enum GeoIpCommand {
/// Lookup GeoIP for an IP address
Lookup(GeoIpLookupArgs),
/// Show GeoIP database status
Status,
}
#[derive(Subcommand, Debug)]
enum ProbeCommand {
/// ICMP ping
Ping(ProbePingArgs),
/// TCP ping (connect timing)
Tcping(ProbeTcpingArgs),
/// Traceroute
Trace(ProbeTraceArgs),
}
#[derive(Subcommand, Debug)]
enum DnsCommand {
/// DNS query
Query(DnsQueryArgs),
/// DNS poisoning detection (active)
Detect(DnsDetectArgs),
/// DNS passive watch (pcap)
Watch(DnsWatchArgs),
/// DNS leak detection
Leak {
#[command(subcommand)]
command: DnsLeakCommand,
@@ -138,345 +170,385 @@ enum DnsCommand {
#[derive(Subcommand, Debug)]
enum DnsLeakCommand {
/// Show current policy and interface snapshot
Status(DnsLeakStatusArgs),
/// Passive leak detection watch
Watch(DnsLeakWatchArgs),
/// Summarize a saved leak report
Report(DnsLeakReportArgs),
}
#[derive(Subcommand, Debug)]
enum CalcCommand {
/// Subnet info
Subnet(CalcSubnetArgs),
/// Check CIDR containment
Contains(CalcContainsArgs),
/// Check CIDR overlap
Overlap(CalcOverlapArgs),
/// Summarize CIDRs
Summarize(CalcSummarizeArgs),
}
#[derive(Subcommand, Debug)]
enum HttpCommand {
/// HTTP HEAD request
Head(HttpRequestArgs),
/// HTTP GET request
Get(HttpRequestArgs),
}
#[derive(Subcommand, Debug)]
enum TlsCommand {
/// TLS handshake summary
Handshake(TlsArgs),
/// TLS certificate details
Cert(TlsArgs),
/// TLS verification
Verify(TlsArgs),
/// TLS ALPN negotiation
Alpn(TlsArgs),
}
#[derive(Subcommand, Debug)]
enum DiscoverCommand {
/// mDNS discovery
Mdns(DiscoverMdnsArgs),
/// SSDP discovery
Ssdp(DiscoverSsdpArgs),
/// LLMNR discovery
Llmnr(DiscoverLlmnrArgs),
/// NBNS discovery
Nbns(DiscoverNbnsArgs),
}
#[derive(Parser, Debug, Clone)]
struct SysIpArgs {
#[arg(long)]
#[arg(long, help = "Show all addresses (including link-local)")]
all: bool,
#[arg(long)]
#[arg(long, help = "Filter by interface name")]
iface: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct SysRouteArgs {
#[arg(long)]
#[arg(long, help = "Show IPv4 routes")]
ipv4: bool,
#[arg(long)]
#[arg(long, help = "Show IPv6 routes")]
ipv6: bool,
#[arg(long)]
#[arg(long, help = "Filter routes by destination")]
to: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct PortsListenArgs {
#[arg(long)]
#[arg(long, help = "Show TCP listeners")]
tcp: bool,
#[arg(long)]
#[arg(long, help = "Show UDP listeners")]
udp: bool,
#[arg(long)]
#[arg(long, help = "Filter by port")]
port: Option<u16>,
}
#[derive(Parser, Debug, Clone)]
struct PortsWhoArgs {
#[arg(help = "Target port number")]
target: String,
}
#[derive(Parser, Debug, Clone)]
struct PortsConnsArgs {
#[arg(long)]
#[arg(long, help = "Show top N remote endpoints")]
top: Option<usize>,
#[arg(long)]
#[arg(long, help = "Group by process")]
by_process: bool,
}
#[derive(Parser, Debug, Clone)]
struct NeighListArgs {
#[arg(long)]
#[arg(long, help = "Show IPv4 neighbors")]
ipv4: bool,
#[arg(long)]
#[arg(long, help = "Show IPv6 neighbors")]
ipv6: bool,
#[arg(long)]
#[arg(long, help = "Filter by interface name")]
iface: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct GeoIpLookupArgs {
#[arg(help = "Target IP address")]
target: String,
}
#[derive(Parser, Debug, Clone)]
struct CertBaselineArgs {
#[arg(help = "Path to write baseline JSON")]
path: PathBuf,
}
#[derive(Parser, Debug, Clone)]
struct CertDiffArgs {
#[arg(help = "Path to baseline JSON")]
path: PathBuf,
}
#[derive(Parser, Debug, Clone)]
struct ProbePingArgs {
#[arg(help = "Target hostname or IP")]
target: String,
#[arg(long, default_value_t = 4)]
#[arg(long, default_value_t = 4, help = "Ping count")]
count: u32,
#[arg(long, default_value_t = 800)]
#[arg(long, default_value_t = 800, help = "Timeout per ping (ms)")]
timeout_ms: u64,
#[arg(long, default_value_t = 200)]
#[arg(long, default_value_t = 200, help = "Interval between pings (ms)")]
interval_ms: u64,
#[arg(long)]
#[arg(long, help = "Disable GeoIP enrichment")]
no_geoip: bool,
}
#[derive(Parser, Debug, Clone)]
struct ProbeTcpingArgs {
#[arg(help = "Target host:port")]
target: String,
#[arg(long, default_value_t = 4)]
#[arg(long, default_value_t = 4, help = "Ping count")]
count: u32,
#[arg(long, default_value_t = 800)]
#[arg(long, default_value_t = 800, help = "Timeout per connect (ms)")]
timeout_ms: u64,
#[arg(long)]
#[arg(long, help = "SOCKS5 proxy URL")]
socks5: Option<String>,
#[arg(long)]
#[arg(long, help = "Prefer IPv4 resolution")]
prefer_ipv4: bool,
#[arg(long)]
#[arg(long, help = "Disable GeoIP enrichment")]
no_geoip: bool,
}
#[derive(Parser, Debug, Clone)]
struct ProbeTraceArgs {
#[arg(help = "Target host:port")]
target: String,
#[arg(long, default_value_t = 30)]
#[arg(long, default_value_t = 30, help = "Maximum hops")]
max_hops: u8,
#[arg(long, default_value_t = 3)]
#[arg(long, default_value_t = 3, help = "Probes per hop")]
per_hop: u32,
#[arg(long, default_value_t = 800)]
#[arg(long, default_value_t = 800, help = "Timeout per hop (ms)")]
timeout_ms: u64,
#[arg(long)]
#[arg(long, help = "Use UDP traceroute")]
udp: bool,
#[arg(long, default_value_t = 33434)]
#[arg(long, default_value_t = 33434, help = "Destination port for UDP/TCP trace")]
port: u16,
#[arg(long)]
#[arg(long, help = "Reverse DNS lookup per hop")]
rdns: bool,
#[arg(long)]
#[arg(long, help = "Disable GeoIP enrichment")]
no_geoip: bool,
}
#[derive(Parser, Debug, Clone)]
struct DnsQueryArgs {
#[arg(help = "Domain name to query")]
domain: String,
#[arg(help = "Record type (A, AAAA, MX, TXT, ...)")]
record_type: String,
#[arg(long)]
#[arg(long, help = "DNS server IP[:port]")]
server: Option<String>,
#[arg(long, default_value = "udp")]
#[arg(long, default_value = "udp", help = "Transport (udp|tcp|dot|doh)")]
transport: String,
#[arg(long)]
#[arg(long, help = "TLS server name for DoT/DoH")]
tls_name: Option<String>,
#[arg(long)]
#[arg(long, help = "SOCKS5 proxy URL")]
socks5: Option<String>,
#[arg(long)]
#[arg(long, help = "Prefer IPv4 resolution")]
prefer_ipv4: bool,
#[arg(long, default_value_t = 2000)]
#[arg(long, default_value_t = 2000, help = "Timeout (ms)")]
timeout_ms: u64,
}
#[derive(Parser, Debug, Clone)]
struct DnsDetectArgs {
#[arg(help = "Domain name to test")]
domain: String,
#[arg(long)]
#[arg(long, help = "Comma-separated DNS servers")]
servers: Option<String>,
#[arg(long, default_value = "udp")]
#[arg(long, default_value = "udp", help = "Transport (udp|tcp|dot|doh)")]
transport: String,
#[arg(long)]
#[arg(long, help = "TLS server name for DoT/DoH")]
tls_name: Option<String>,
#[arg(long)]
#[arg(long, help = "SOCKS5 proxy URL")]
socks5: Option<String>,
#[arg(long)]
#[arg(long, help = "Prefer IPv4 resolution")]
prefer_ipv4: bool,
#[arg(long, default_value_t = 3)]
#[arg(long, default_value_t = 3, help = "Repeat count per server")]
repeat: u32,
#[arg(long, default_value_t = 2000)]
#[arg(long, default_value_t = 2000, help = "Timeout (ms)")]
timeout_ms: u64,
}
#[derive(Parser, Debug, Clone)]
struct DnsWatchArgs {
#[arg(long, default_value = "30s")]
#[arg(long, default_value = "30s", help = "Capture duration")]
duration: String,
#[arg(long)]
#[arg(long, help = "Keep running until Ctrl-C")]
follow: bool,
#[arg(long, help = "Capture interface name")]
iface: Option<String>,
#[arg(long)]
#[arg(long, help = "Filter by domain substring")]
filter: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakStatusArgs {
#[arg(long)]
#[arg(long, help = "Policy profile (full-tunnel|proxy-stub|split)")]
profile: Option<String>,
#[arg(long)]
#[arg(long, help = "Path to policy JSON")]
policy: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakWatchArgs {
#[arg(long, default_value = "10s")]
#[arg(long, default_value = "10s", help = "Capture duration")]
duration: String,
#[arg(long)]
#[arg(long, help = "Keep running until Ctrl-C")]
follow: bool,
#[arg(long, help = "Capture interface name")]
iface: Option<String>,
#[arg(long)]
#[arg(long, help = "Policy profile (full-tunnel|proxy-stub|split)")]
profile: Option<String>,
#[arg(long)]
#[arg(long, help = "Path to policy JSON")]
policy: Option<PathBuf>,
#[arg(long, default_value = "redacted")]
#[arg(long, default_value = "redacted", help = "Privacy mode (full|redacted|minimal)")]
privacy: String,
#[arg(long)]
#[arg(long, help = "Write JSON report to file")]
out: Option<PathBuf>,
#[arg(long)]
#[arg(long, help = "Only print summary (no events)")]
summary_only: bool,
#[arg(long)]
#[arg(long, help = "List capture-capable interfaces and exit")]
iface_diag: bool,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakReportArgs {
#[arg(help = "Path to leak report JSON")]
path: PathBuf,
#[arg(long, default_value = "redacted")]
#[arg(long, default_value = "redacted", help = "Privacy mode (full|redacted|minimal)")]
privacy: String,
}
#[derive(Parser, Debug, Clone)]
struct CalcSubnetArgs {
#[arg(help = "CIDR or IP + mask")]
input: Vec<String>,
}
#[derive(Parser, Debug, Clone)]
struct CalcContainsArgs {
#[arg(help = "CIDR A")]
a: String,
#[arg(help = "CIDR B")]
b: String,
}
#[derive(Parser, Debug, Clone)]
struct CalcOverlapArgs {
#[arg(help = "CIDR A")]
a: String,
#[arg(help = "CIDR B")]
b: String,
}
#[derive(Parser, Debug, Clone)]
struct CalcSummarizeArgs {
#[arg(help = "CIDR list to summarize")]
cidrs: Vec<String>,
}
#[derive(Parser, Debug, Clone)]
struct HttpRequestArgs {
#[arg(help = "Target URL")]
url: String,
#[arg(long, default_value_t = 3000)]
#[arg(long, default_value_t = 3000, help = "Request timeout (ms)")]
timeout_ms: u64,
#[arg(long)]
#[arg(long, help = "Follow redirects (limit)")]
follow_redirects: Option<u32>,
#[arg(long)]
#[arg(long, help = "Include response headers")]
show_headers: bool,
#[arg(long)]
#[arg(long, help = "Include response body")]
show_body: bool,
#[arg(long, default_value_t = 8192)]
#[arg(long, default_value_t = 8192, help = "Max body bytes")]
max_body_bytes: usize,
#[arg(long)]
#[arg(long, help = "Force HTTP/1.1 only")]
http1_only: bool,
#[arg(long)]
#[arg(long, help = "Force HTTP/2 prior knowledge")]
http2_only: bool,
#[arg(long)]
#[arg(long, help = "Enable HTTP/3 (feature gated)")]
http3: bool,
#[arg(long)]
#[arg(long, help = "Require HTTP/3 (feature gated)")]
http3_only: bool,
#[arg(long)]
#[arg(long, help = "Enable GeoIP enrichment")]
geoip: bool,
#[arg(long)]
#[arg(long, help = "SOCKS5 proxy URL")]
socks5: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct TlsArgs {
#[arg(help = "Target host:port")]
target: String,
#[arg(long)]
#[arg(long, help = "Override SNI")]
sni: Option<String>,
#[arg(long)]
#[arg(long, help = "ALPN protocols (comma-separated)")]
alpn: Option<String>,
#[arg(long, default_value_t = 3000)]
#[arg(long, default_value_t = 3000, help = "Timeout (ms)")]
timeout_ms: u64,
#[arg(long)]
#[arg(long, help = "Skip TLS verification")]
insecure: bool,
#[arg(long)]
#[arg(long, help = "SOCKS5 proxy URL")]
socks5: Option<String>,
#[arg(long)]
#[arg(long, help = "Prefer IPv4 resolution")]
prefer_ipv4: bool,
#[arg(long)]
#[arg(long, help = "Show X.509 extensions")]
show_extensions: bool,
#[arg(long)]
#[arg(long, help = "Show OCSP stapling if available")]
ocsp: bool,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverMdnsArgs {
#[arg(long, default_value = "3s")]
#[arg(long, default_value = "3s", help = "Capture duration")]
duration: String,
#[arg(long)]
#[arg(long, help = "Service type filter")]
service: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverSsdpArgs {
#[arg(long, default_value = "3s")]
#[arg(long, default_value = "3s", help = "Capture duration")]
duration: String,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverLlmnrArgs {
#[arg(long, default_value = "3s")]
#[arg(long, default_value = "3s", help = "Capture duration")]
duration: String,
#[arg(long)]
#[arg(long, help = "Query name (default: wpad)")]
name: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverNbnsArgs {
#[arg(long, default_value = "3s")]
#[arg(long, default_value = "3s", help = "Capture duration")]
duration: String,
}
#[derive(Parser, Debug, Clone)]
struct DiagArgs {
#[arg(long)]
#[arg(long, help = "Write JSON report to file")]
out: Option<PathBuf>,
#[arg(long)]
#[arg(long, help = "Write zip bundle to file")]
bundle: Option<PathBuf>,
#[arg(long)]
#[arg(long, help = "Run DNS detect on domain")]
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,
#[arg(long, default_value_t = 3)]
#[arg(long, default_value_t = 3, help = "DNS detect repeat count")]
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 {
let duration_ms = match parse_duration_ms(&args.duration) {
let duration_ms = match resolve_follow_duration(args.follow, &args.duration) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
@@ -1951,11 +2023,27 @@ async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 {
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) => {
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
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 {
command_args.push("--iface".to_string());
command_args.push(iface);
@@ -2086,7 +2174,7 @@ async fn handle_dns_leak_watch(cli: &Cli, args: DnsLeakWatchArgs) -> i32 {
if args.iface_diag {
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,
Err(err) => {
eprintln!("{err}");
@@ -2121,12 +2209,29 @@ async fn handle_dns_leak_watch(cli: &Cli, args: DnsLeakWatchArgs) -> i32 {
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,
Err(err) => {
eprintln!("dns leak watch failed: {err}");
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() {
@@ -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);
}
if args.follow {
command_args.push("--follow".to_string());
}
if let Some(profile) = args.profile {
command_args.push("--profile".to_string());
command_args.push(profile);
@@ -3054,6 +3162,13 @@ fn parse_duration_ms(value: &str) -> Result<u64, String> {
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 {
if answers.is_empty() {
return "-".to_string();

View File

@@ -1,5 +1,6 @@
use crate::report::LeakTransport;
use hickory_proto::op::{Message, MessageType};
use hickory_proto::rr::RData;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use wtfnet_platform::FlowProtocol;
@@ -17,16 +18,43 @@ pub struct ClassifiedEvent {
pub qname: Option<String>,
pub qtype: 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)> {
let message = Message::from_vec(payload).ok()?;
if message.message_type() != MessageType::Query {
return None;
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 is_response = message.message_type() == MessageType::Response;
let query = message.queries().first()?;
let qname = query.name().to_utf8();
let qtype = query.query_type().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;
use crate::classify::ClassifiedEvent;
use crate::sensor::capture_events;
use crate::sensor::{capture_events, SensorEvent, TcpEvent};
use std::time::Instant;
use thiserror::Error;
use tracing::debug;
@@ -50,15 +50,32 @@ pub async fn watch(
let start = Instant::now();
let events = capture_events(&options).await?;
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 {
match event {
SensorEvent::Dns(event) => {
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) {
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
privacy::apply_privacy(&mut leak_event, options.privacy);
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 report = LeakReport {
@@ -100,3 +117,106 @@ async fn enrich_event(
}
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,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RouteClass {
Loopback,
@@ -54,6 +54,8 @@ pub struct EnrichedEvent {
pub qname: Option<String>,
pub qtype: Option<String>,
pub rcode: Option<String>,
pub is_response: bool,
pub answer_ips: Vec<IpAddr>,
pub route_class: RouteClass,
pub owner: Option<FlowOwner>,
pub owner_confidence: FlowOwnerConfidence,

View File

@@ -3,20 +3,7 @@ use crate::report::{EnrichedEvent, RouteClass};
use wtfnet_platform::FlowOwnerConfidence;
pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
let route_class = if event.src_ip.is_loopback() || event.dst_ip.is_loopback() {
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
};
let route_class = route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref());
EnrichedEvent {
timestamp_ms: event.timestamp_ms,
@@ -30,6 +17,8 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
qname: event.qname,
qtype: event.qtype,
rcode: event.rcode,
is_response: event.is_response,
answer_ips: event.answer_ips,
route_class,
owner: 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 {
let name = name.to_ascii_lowercase();
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::DnsLeakError;
use std::collections::HashSet;
@@ -14,18 +14,28 @@ use pnet::datalink::{self, Channel, Config as DatalinkConfig};
#[cfg(feature = "pcap")]
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"))]
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(
"dns leak watch requires pcap feature".to_string(),
))
}
#[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 candidates = format_iface_list(&datalink::interfaces());
let timeout_ms = options.duration_ms.saturating_add(2000);
let iface_list = datalink::interfaces();
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));
match tokio::time::timeout(Duration::from_millis(timeout_ms), handle).await {
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)]
pub struct IfaceDiag {
pub name: String,
@@ -78,7 +104,7 @@ pub fn iface_diagnostics() -> Result<Vec<IfaceDiag>, DnsLeakError> {
}
#[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::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 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 mut events = Vec::new();
let mut seen = HashSet::new();
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,
Err(_) => continue,
};
let ethernet = match EthernetPacket::new(frame) {
let ethernet = match EthernetPacket::new(&frame) {
Some(packet) => packet,
None => continue,
};
@@ -115,19 +153,38 @@ fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEv
_ => None,
};
if let Some(event) = event {
let key = format!(
"{:?}|{}|{}|{}|{}",
event.transport, event.src_ip, event.src_port, event.dst_ip, event.dst_port
);
let key = match &event {
SensorEvent::Dns(value) => format!(
"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) {
match &event {
SensorEvent::Dns(value) => {
debug!(
transport = ?event.transport,
src_ip = %event.src_ip,
src_port = event.src_port,
dst_ip = %event.dst_ip,
dst_port = event.dst_port,
transport = ?value.transport,
src_ip = %value.src_ip,
src_port = value.src_port,
dst_ip = %value.dst_ip,
dst_port = value.dst_port,
"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);
}
}
@@ -141,28 +198,19 @@ fn parse_ipv4(
payload: &[u8],
local_ips: &[IpAddr],
iface_name: &str,
) -> Option<ClassifiedEvent> {
) -> Option<SensorEvent> {
use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::ipv4::Ipv4Packet;
use pnet::packet::Packet;
let ipv4 = Ipv4Packet::new(payload)?;
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;
}
match ipv4.get_next_level_protocol() {
IpNextHeaderProtocols::Udp => parse_udp(
src,
IpAddr::V4(ipv4.get_destination()),
ipv4.payload(),
iface_name,
),
IpNextHeaderProtocols::Tcp => parse_tcp(
src,
IpAddr::V4(ipv4.get_destination()),
ipv4.payload(),
iface_name,
),
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv4.payload(), iface_name),
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv4.payload(), iface_name),
_ => None,
}
}
@@ -172,28 +220,19 @@ fn parse_ipv6(
payload: &[u8],
local_ips: &[IpAddr],
iface_name: &str,
) -> Option<ClassifiedEvent> {
) -> Option<SensorEvent> {
use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::ipv6::Ipv6Packet;
use pnet::packet::Packet;
let ipv6 = Ipv6Packet::new(payload)?;
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;
}
match ipv6.get_next_header() {
IpNextHeaderProtocols::Udp => parse_udp(
src,
IpAddr::V6(ipv6.get_destination()),
ipv6.payload(),
iface_name,
),
IpNextHeaderProtocols::Tcp => parse_tcp(
src,
IpAddr::V6(ipv6.get_destination()),
ipv6.payload(),
iface_name,
),
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv6.payload(), iface_name),
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv6.payload(), iface_name),
_ => None,
}
}
@@ -204,28 +243,31 @@ fn parse_udp(
dst_ip: IpAddr,
payload: &[u8],
iface_name: &str,
) -> Option<ClassifiedEvent> {
) -> Option<SensorEvent> {
use pnet::packet::udp::UdpPacket;
use pnet::packet::Packet;
let udp = UdpPacket::new(payload)?;
let src_port = udp.get_source();
let dst_port = udp.get_destination();
if dst_port != 53 {
if src_port != 53 && dst_port != 53 {
return None;
}
let (qname, qtype, rcode) = classify_dns_query(udp.payload())?;
Some(ClassifiedEvent {
let parsed = parse_dns_message(udp.payload())?;
Some(SensorEvent::Dns(ClassifiedEvent {
timestamp_ms: now_ms(),
proto: FlowProtocol::Udp,
src_ip,
src_port: udp.get_source(),
src_port,
dst_ip,
dst_port,
iface_name: Some(iface_name.to_string()),
transport: LeakTransport::Udp53,
qname: Some(qname),
qtype: Some(qtype),
rcode: Some(rcode),
})
qname: Some(parsed.qname),
qtype: Some(parsed.qtype),
rcode: Some(parsed.rcode),
is_response: parsed.is_response,
answer_ips: parsed.answer_ips,
}))
}
#[cfg(feature = "pcap")]
@@ -234,20 +276,36 @@ fn parse_tcp(
dst_ip: IpAddr,
payload: &[u8],
iface_name: &str,
) -> Option<ClassifiedEvent> {
) -> Option<SensorEvent> {
use pnet::packet::tcp::TcpPacket;
let tcp = TcpPacket::new(payload)?;
let dst_port = tcp.get_destination();
let src_port = tcp.get_source();
let transport = match dst_port {
53 => LeakTransport::Tcp53,
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(),
proto: FlowProtocol::Tcp,
src_ip,
src_port: tcp.get_source(),
src_port,
dst_ip,
dst_port,
iface_name: Some(iface_name.to_string()),
@@ -255,7 +313,9 @@ fn parse_tcp(
qname: None,
qtype: None,
rcode: None,
})
is_response: false,
answer_ips: Vec::new(),
}))
}
#[cfg(feature = "pcap")]
@@ -265,6 +325,7 @@ fn select_interface(
) -> Result<(datalink::NetworkInterface, Box<dyn datalink::DataLinkReceiver>), DnsLeakError> {
let interfaces = datalink::interfaces();
if let Some(name) = name {
debug!("dns leak iface pick: requested={name}");
let iface = interfaces
.iter()
.find(|iface| iface.name == name)
@@ -283,16 +344,18 @@ fn select_interface(
});
}
if let Some(iface) = pick_stable_iface(&interfaces) {
if let Ok(channel) = open_channel_with_timeout(iface, config) {
return Ok(channel);
let ordered = order_interfaces(&interfaces);
for iface in ordered.iter() {
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!(
@@ -317,7 +380,7 @@ fn open_channel_with_timeout(
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) {
Ok((iface, Ok(rx))) => Ok((iface, rx)),
Ok((_iface, Err(err))) => Err(err),
@@ -337,26 +400,27 @@ fn is_named_fallback(name: &str) -> bool {
}
#[cfg(feature = "pcap")]
fn pick_stable_iface(
fn order_interfaces(
interfaces: &[datalink::NetworkInterface],
) -> Option<datalink::NetworkInterface> {
let mut preferred = interfaces
.iter()
.filter(|iface| {
iface.is_up()
&& !iface.is_loopback()
&& (is_named_fallback(&iface.name) || !iface.ips.is_empty())
})
.cloned()
.collect::<Vec<_>>();
if preferred.is_empty() {
preferred = interfaces
.iter()
.filter(|iface| !iface.is_loopback())
.cloned()
.collect();
) -> Vec<datalink::NetworkInterface> {
let mut preferred = Vec::new();
let mut others = Vec::new();
for iface in interfaces.iter() {
if iface.is_loopback() {
continue;
}
if is_named_fallback(&iface.name) || !iface.ips.is_empty() {
preferred.push(iface.clone());
} else {
others.push(iface.clone());
}
}
preferred.extend(others);
if preferred.is_empty() {
interfaces.to_vec()
} else {
preferred
}
preferred.into_iter().next()
}
#[cfg(feature = "pcap")]

View File

@@ -3,36 +3,36 @@
This document lists CLI commands and supported flags. Output defaults to text; use `--json` for structured output.
## Global flags
- `--json` / `--pretty`
- `--no-color` / `--quiet`
- `-v` / `-vv` / `--verbose`
- `--log-level <error|warn|info|debug|trace>`
- `--log-format <text|json>`
- `--log-file <path>`
- `--json` / `--pretty`: emit JSON output (pretty-print if requested)
- `--no-color` / `--quiet`: disable ANSI colors / reduce stdout output
- `-v` / `-vv` / `--verbose`: increase log verbosity
- `--log-level <error|warn|info|debug|trace>`: set log level
- `--log-format <text|json>`: set log format
- `--log-file <path>`: write logs to file
- `NETTOOL_LOG_FILTER` or `RUST_LOG` can override log filters (ex: `maxminddb::decoder=debug`)
## sys
- `sys ifaces`
- `sys ip` flags: `--all`, `--iface <name>`
- `sys route` flags: `--ipv4`, `--ipv6`, `--to <ip>`
- `sys dns`
- `sys ifaces`: list network interfaces
- `sys ip` flags: `--all` (include link-local), `--iface <name>` (filter by interface)
- `sys route` flags: `--ipv4`, `--ipv6`, `--to <ip>` (filter by destination)
- `sys dns`: show DNS configuration
## ports
- `ports listen` flags: `--tcp`, `--udp`, `--port <n>`
- `ports who <port>`
- `ports conns` flags: `--top <n>`, `--by-process`
- `ports listen` flags: `--tcp`, `--udp`, `--port <n>` (filter by port)
- `ports who <port>`: find owning processes for a port
- `ports conns` flags: `--top <n>`, `--by-process` (summaries)
## neigh
- `neigh list` flags: `--ipv4`, `--ipv6`, `--iface <name>`
## cert
- `cert roots`
- `cert baseline <path>`
- `cert diff <path>`
- `cert roots`: list trusted root certificates
- `cert baseline <path>`: write baseline JSON
- `cert diff <path>`: diff against baseline JSON
## geoip
- `geoip lookup <ip>`
- `geoip status`
- `geoip lookup <ip>`: lookup GeoIP
- `geoip status`: show GeoIP database status
## probe
- `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 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 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 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>`
## 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 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
- `--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
Phase 1 (core passive detection):

View File

@@ -30,4 +30,4 @@ This is a practical checklist to execute v0.4.0.
## 5) follow-ups
- [ ] 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-B (split-policy intent leak based on proxy-required/allowlist domains).
- 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`.
- Privacy modes: full/redacted/minimal (redacts qname).
- Process attribution:
@@ -23,9 +24,15 @@ This document tracks the current DNS leak detector implementation against the de
- `dns leak watch`
- `dns leak report`
- `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:
- 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
- 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)
- DoH heuristic detection (SNI/endpoint list/traffic shape).
- Leak-D mismatch correlation (DNS -> TCP/TLS flows).
- GeoIP enrichment of leak events.
- Process tree reporting (PPID chain).
## Known limitations
- On Windows, pcap capture may require selecting a specific NPF interface; use
`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.