Compare commits

...

11 Commits

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

1
.gitignore vendored
View File

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

View File

@@ -1,41 +0,0 @@
cmake_minimum_required(VERSION 3.20)
project(wtfnet LANGUAGES NONE)
set(CARGO_CMD cargo)
set(CARGO_TARGET_DIR "${CMAKE_BINARY_DIR}/cargo-target")
set(BIN_NAME "wtfn${CMAKE_EXECUTABLE_SUFFIX}")
set(BIN_PATH "${CARGO_TARGET_DIR}/release/${BIN_NAME}")
file(READ "${CMAKE_SOURCE_DIR}/crates/wtfnet-cli/Cargo.toml" CLI_TOML)
string(REGEX MATCH "version = \"([0-9]+\\.[0-9]+\\.[0-9]+)\"" CLI_VERSION_MATCH "${CLI_TOML}")
if(CMAKE_MATCH_1)
set(PACKAGE_VERSION "${CMAKE_MATCH_1}")
else()
set(PACKAGE_VERSION "0.1.0")
endif()
add_custom_command(
OUTPUT "${BIN_PATH}"
COMMAND "${CMAKE_COMMAND}" -E env CARGO_TARGET_DIR="${CARGO_TARGET_DIR}"
"${CARGO_CMD}" build --release --workspace --bin wtfn
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Building wtfn with cargo"
VERBATIM
)
add_custom_target(wtfnet_build ALL DEPENDS "${BIN_PATH}")
install(PROGRAMS "${BIN_PATH}" DESTINATION bin)
install(DIRECTORY "${CMAKE_SOURCE_DIR}/data" DESTINATION share/wtfnet)
add_dependencies(install wtfnet_build)
set(CPACK_PACKAGE_NAME "wtfnet")
set(CPACK_PACKAGE_VERSION "${PACKAGE_VERSION}")
set(CPACK_PACKAGE_FILE_NAME "wtfnet-${PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
if(WIN32)
set(CPACK_GENERATOR "ZIP")
else()
set(CPACK_GENERATOR "TGZ")
endif()
include(CPack)

12
Cargo.lock generated
View File

@@ -2230,6 +2230,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
@@ -3225,7 +3235,7 @@ dependencies = [
[[package]]
name = "wtfnet-cli"
version = "0.1.0"
version = "0.4.0"
dependencies = [
"clap",
"serde",

View File

@@ -1,18 +0,0 @@
BUILD_DIR ?= build
.PHONY: build configure package install clean
configure:
cmake -S . -B $(BUILD_DIR)
build: configure
cmake --build $(BUILD_DIR)
package: build
cmake --build $(BUILD_DIR) --target package
install: build
cmake --build $(BUILD_DIR) --target install
clean:
cmake -E rm -rf $(BUILD_DIR)

View File

@@ -36,23 +36,25 @@ wtfn neigh list --ipv6
wtfn geoip lookup 8.8.8.8
wtfn 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/`.
@@ -116,20 +85,33 @@ Lookup order:
2) `data/` next to the CLI binary
3) `data/` in the current working directory
## Build and package
## Build
### Only build binary
```bash
cmake -S . -B build
cmake --build build
cmake --build build --target package
cargo build --release
```
Install:
### Build and package
1. Prepare GeoLite2 databases (required `GeoLite2-ASN.mmdb` and `GeoLite2-Country.mmdb` ):
```bash
cmake --build build --target install
# Place your mmdb files under data/
mkdir data
```
> **Note**: This step requires `python3` and `just`.
2. Use `just` to run build and package command (Note: you don't need bash environment on windows):
```bash
# You will find package under dist/, zip file on windows, tar.gz file on linux
just release
```
## HTTP/3 (experimental)
HTTP/3 support is feature-gated and incomplete. Do not enable it in production builds yet.
HTTP/3 support is feature-gated and best-effort. Enable it only when you want to test QUIC
connectivity.
To enable locally for testing:
```bash

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

@@ -21,6 +21,8 @@ use quinn::ClientConfig as QuinnClientConfig;
#[cfg(feature = "http3")]
use quinn::Endpoint;
#[cfg(feature = "http3")]
use quinn::crypto::rustls::QuicClientConfig;
#[cfg(feature = "http3")]
use webpki_roots::TLS_SERVER_ROOTS;
#[derive(Debug, Error)]
@@ -468,26 +470,54 @@ async fn http3_request(
let port = parsed
.port_or_known_default()
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
let ip = resolved_ips
.first()
.and_then(|value| value.parse::<IpAddr>().ok())
.ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?;
let quinn_config = build_quinn_config()?;
let candidates = resolved_ips
.iter()
.filter_map(|value| value.parse::<IpAddr>().ok())
.collect::<Vec<_>>();
if candidates.is_empty() {
return Err(HttpError::Request("no resolved IPs for http3".to_string()));
}
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())
let mut endpoint_guard = None;
let mut connection = None;
let mut connect_ms = None;
for ip in candidates {
let bind_addr = match ip {
IpAddr::V4(_) => "0.0.0.0:0",
IpAddr::V6(_) => "[::]:0",
};
let mut endpoint = Endpoint::client(bind_addr.parse().unwrap())
.map_err(|err| HttpError::Request(err.to_string()))?;
endpoint.set_default_client_config(quinn_config);
endpoint.set_default_client_config(quinn_config.clone());
let connect_start = Instant::now();
let connecting = endpoint
.connect(SocketAddr::new(ip, port), host)
.map_err(|err| HttpError::Request(err.to_string()))?;
let connection = timeout(Duration::from_millis(opts.timeout_ms), connecting)
.await
.map_err(|_| HttpError::Request("http3 connect timed out".to_string()))?
.map_err(|err| HttpError::Request(err.to_string()))?;
let connect_ms = connect_start.elapsed().as_millis();
let connecting = match endpoint.connect(SocketAddr::new(ip, port), host) {
Ok(connecting) => connecting,
Err(err) => {
warnings.push(format!("http3 connect failed to {ip}: {err}"));
continue;
}
};
match timeout(Duration::from_millis(opts.timeout_ms), connecting).await {
Ok(Ok(conn)) => {
connect_ms = Some(connect_start.elapsed().as_millis());
connection = Some(conn);
endpoint_guard = Some(endpoint);
break;
}
Ok(Err(err)) => {
warnings.push(format!("http3 connect failed to {ip}: {err}"));
}
Err(_) => {
warnings.push(format!("http3 connect to {ip} timed out"));
}
}
}
let connection = connection.ok_or_else(|| {
HttpError::Request("http3 connect failed for all resolved IPs".to_string())
})?;
let connect_ms = connect_ms.unwrap_or_default();
let conn = h3_quinn::Connection::new(connection);
let (mut driver, mut send_request) = h3::client::new(conn)
@@ -563,8 +593,8 @@ async fn http3_request(
warnings.push("http3 timing for tls/connect is best-effort".to_string());
Ok((
HttpReport {
let _endpoint_guard = endpoint_guard;
let report = HttpReport {
url: url.to_string(),
final_url: Some(final_url),
method: match opts.method {
@@ -584,9 +614,9 @@ async fn http3_request(
tls_ms: None,
ttfb_ms: Some(ttfb_ms),
},
},
warnings,
))
};
Ok((report, warnings))
}
#[cfg(feature = "http3")]
@@ -594,10 +624,14 @@ fn build_quinn_config() -> Result<QuinnClientConfig, HttpError> {
let mut roots = quinn::rustls::RootCertStore::empty();
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
let mut client_config =
QuinnClientConfig::with_root_certificates(Arc::new(roots)).map_err(|err| {
HttpError::Request(format!("quinn config error: {err}"))
})?;
let mut crypto = quinn::rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
crypto.alpn_protocols = vec![b"h3".to_vec()];
let mut client_config = QuinnClientConfig::new(Arc::new(
QuicClientConfig::try_from(crypto)
.map_err(|err| HttpError::Request(format!("quinn config error: {err}")))?,
));
let mut transport = quinn::TransportConfig::default();
transport.keep_alive_interval(Some(Duration::from_secs(5)));
client_config.transport_config(Arc::new(transport));

View File

@@ -3,36 +3,36 @@
This document lists CLI commands and supported flags. Output defaults to text; use `--json` for structured output.
## 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.

View File

@@ -22,7 +22,7 @@ This document tracks current implementation status against the original design i
- DNS watch uses `pnet` and is feature-gated as best-effort.
## Gaps vs design (as of now)
- HTTP/3 is feature-gated and incomplete; not enabled in default builds.
- HTTP/3 is feature-gated and best-effort; not enabled in default builds.
- TLS verification is rustls-based (no OS-native verifier).
- DNS leak DoH detection is heuristic and currently optional.

13
justfile Normal file
View File

@@ -0,0 +1,13 @@
# justfile (cross-platform, no bash)
python := env_var_or_default("PYTHON", if os() == "windows" { "python" } else { "python3" })
dist_dir := "dist"
stage_root := "target/release-package"
default:
@just --list
release bin='' target='':
{{python}} scripts/release_meta.py --bin "{{bin}}" --target "{{target}}" --dist-dir "{{dist_dir}}" --stage-root "{{stage_root}}"
clean-dist:
{{python}} -c "import shutil; shutil.rmtree('dist', ignore_errors=True); shutil.rmtree('target/release-package', ignore_errors=True)"

175
scripts/release_meta.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from typing import Any
def run(cmd: list[str], *, capture: bool = False) -> str:
if capture:
return subprocess.check_output(cmd, text=True).strip()
subprocess.check_call(cmd)
return ""
def cargo_metadata() -> dict[str, Any]:
out = run(["cargo", "metadata", "--no-deps", "--format-version", "1"], capture=True)
return json.loads(out)
def rustc_host_triple() -> str:
v = run(["rustc", "-vV"], capture=True)
for line in v.splitlines():
if line.startswith("host: "):
return line.split("host: ", 1)[1].strip()
raise RuntimeError("Could not determine host target triple from `rustc -vV`")
def is_windows_host() -> bool:
# Works for normal Windows Python and most MSYS/Cygwin Pythons too.
sp = sys.platform.lower()
ps = platform.system().lower()
return (
os.name == "nt"
or sp.startswith("win")
or sp.startswith("cygwin")
or sp.startswith("msys")
or "windows" in ps
or "cygwin" in ps
or "msys" in ps
)
def exe_suffix_for_target(target_triple: str) -> str:
return ".exe" if "windows" in target_triple else ""
def find_bin_targets(meta: dict[str, Any]) -> list[tuple[str, str, str]]:
bins: list[tuple[str, str, str]] = []
for p in meta.get("packages", []):
for t in p.get("targets", []):
if "bin" in t.get("kind", []):
bins.append((p["name"], p["version"], t["name"]))
bins.sort(key=lambda x: (x[0], x[2], x[1])) # stable deterministic choice
return bins
def find_owner_package_for_bin(meta: dict[str, Any], bin_name: str) -> tuple[str, str]:
for p in meta.get("packages", []):
for t in p.get("targets", []):
if t.get("name") == bin_name and "bin" in t.get("kind", []):
return p["name"], p["version"]
raise RuntimeError(f"Could not find a package providing bin '{bin_name}'")
def stage_and_archive(
*,
pkg_name: str,
pkg_version: str,
bin_path: Path,
data_dir: Path,
dist_dir: Path,
stage_root: Path,
target_triple_for_name: str,
) -> Path:
pkg_base = f"{pkg_name}-v{pkg_version}-{target_triple_for_name}"
stage_dir = stage_root / pkg_base
stage_data_dir = stage_dir / "data"
if stage_root.exists():
shutil.rmtree(stage_root)
stage_data_dir.mkdir(parents=True, exist_ok=True)
dist_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(bin_path, stage_dir / bin_path.name)
mmdbs = sorted(data_dir.glob("*.mmdb")) if data_dir.exists() else []
if mmdbs:
for f in mmdbs:
shutil.copy2(f, stage_data_dir / f.name)
else:
print("WARN: no ./data/*.mmdb found; packaging binary only.", file=sys.stderr)
if is_windows_host():
out = dist_dir / f"{pkg_base}.zip"
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in stage_dir.rglob("*"):
if p.is_file():
z.write(p, arcname=str(Path(pkg_base) / p.relative_to(stage_dir)))
return out
else:
out = dist_dir / f"{pkg_base}.tar.gz"
with tarfile.open(out, "w:gz") as tf:
tf.add(stage_dir, arcname=pkg_base)
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Build and package Rust binary + data/*.mmdb")
ap.add_argument("--bin", default="", help="Binary target name (optional)")
ap.add_argument("--target", default="", help="Cargo target triple (optional)")
ap.add_argument("--dist-dir", default="dist", help="Output directory for archives")
ap.add_argument("--stage-root", default="target/release-package", help="Staging directory root")
ap.add_argument("--data-dir", default="data", help="Directory containing .mmdb files")
args = ap.parse_args()
meta = cargo_metadata()
bins = find_bin_targets(meta)
if not bins:
print("ERROR: no binary targets found in workspace.", file=sys.stderr)
return 2
bin_name = args.bin.strip()
if not bin_name:
_, _, bin_name = bins[0]
print(f"INFO: --bin not provided; defaulting to '{bin_name}'", file=sys.stderr)
pkg_name, pkg_version = find_owner_package_for_bin(meta, bin_name)
host_triple = rustc_host_triple()
target_triple_for_name = args.target.strip() or host_triple
# Build only the owning package
build_cmd = ["cargo", "build", "-p", pkg_name, "--release"]
if args.target.strip():
build_cmd += ["--target", args.target.strip()]
run(build_cmd)
# Locate binary
exe_suffix = exe_suffix_for_target(target_triple_for_name)
bin_dir = Path("target") / (args.target.strip() if args.target.strip() else "release") / "release" \
if args.target.strip() else Path("target") / "release"
if args.target.strip():
bin_dir = Path("target") / args.target.strip() / "release"
bin_path = bin_dir / f"{bin_name}{exe_suffix}"
if not bin_path.exists():
print(f"ERROR: built binary not found: {bin_path}", file=sys.stderr)
print("Hint: pass the correct bin target name: just release bin=<name>", file=sys.stderr)
return 3
out = stage_and_archive(
pkg_name=pkg_name,
pkg_version=pkg_version,
bin_path=bin_path,
data_dir=Path(args.data_dir),
dist_dir=Path(args.dist_dir),
stage_root=Path(args.stage_root),
target_triple_for_name=target_triple_for_name,
)
print(f"Created: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())