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

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/`.

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 {
pub qname: String,
pub qtype: String,
pub rcode: String,
pub is_response: bool,
pub answer_ips: Vec<IpAddr>,
}
pub fn parse_dns_message(payload: &[u8]) -> Option<ParsedDns> {
let message = Message::from_vec(payload).ok()?; let message = Message::from_vec(payload).ok()?;
if message.message_type() != MessageType::Query { let is_response = message.message_type() == MessageType::Response;
return None;
}
let query = message.queries().first()?; let query = message.queries().first()?;
let qname = query.name().to_utf8(); let qname = query.name().to_utf8();
let qtype = query.query_type().to_string(); let qtype = query.query_type().to_string();
let rcode = message.response_code().to_string(); let rcode = message.response_code().to_string();
Some((qname, qtype, rcode)) let mut answer_ips = Vec::new();
if is_response {
for record in message.answers() {
if let Some(data) = record.data() {
match data {
RData::A(addr) => answer_ips.push(IpAddr::V4(addr.0)),
RData::AAAA(addr) => answer_ips.push(IpAddr::V6(addr.0)),
_ => {}
}
}
}
}
Some(ParsedDns {
qname,
qtype,
rcode,
is_response,
answer_ips,
})
} }

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

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