Add: dns leak detection

This commit is contained in:
DaZuo0122
2026-01-17 18:45:24 +08:00
parent ccd4a31d21
commit cfa96bde08
30 changed files with 3973 additions and 16 deletions

View File

@@ -130,6 +130,17 @@ enum DnsCommand {
Query(DnsQueryArgs),
Detect(DnsDetectArgs),
Watch(DnsWatchArgs),
Leak {
#[command(subcommand)]
command: DnsLeakCommand,
},
}
#[derive(Subcommand, Debug)]
enum DnsLeakCommand {
Status(DnsLeakStatusArgs),
Watch(DnsLeakWatchArgs),
Report(DnsLeakReportArgs),
}
#[derive(Subcommand, Debug)]
@@ -158,6 +169,8 @@ enum TlsCommand {
enum DiscoverCommand {
Mdns(DiscoverMdnsArgs),
Ssdp(DiscoverSsdpArgs),
Llmnr(DiscoverLlmnrArgs),
Nbns(DiscoverNbnsArgs),
}
#[derive(Parser, Debug, Clone)]
@@ -320,6 +333,41 @@ struct DnsWatchArgs {
filter: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakStatusArgs {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
policy: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakWatchArgs {
#[arg(long, default_value = "10s")]
duration: String,
#[arg(long)]
iface: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
policy: Option<PathBuf>,
#[arg(long, default_value = "redacted")]
privacy: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
summary_only: bool,
#[arg(long)]
iface_diag: bool,
}
#[derive(Parser, Debug, Clone)]
struct DnsLeakReportArgs {
path: PathBuf,
#[arg(long, default_value = "redacted")]
privacy: String,
}
#[derive(Parser, Debug, Clone)]
struct CalcSubnetArgs {
input: Vec<String>,
@@ -404,6 +452,20 @@ struct DiscoverSsdpArgs {
duration: String,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverLlmnrArgs {
#[arg(long, default_value = "3s")]
duration: String,
#[arg(long)]
name: Option<String>,
}
#[derive(Parser, Debug, Clone)]
struct DiscoverNbnsArgs {
#[arg(long, default_value = "3s")]
duration: String,
}
#[derive(Parser, Debug, Clone)]
struct DiagArgs {
#[arg(long)]
@@ -550,6 +612,13 @@ async fn main() {
Commands::Dns {
command: DnsCommand::Watch(args),
} => handle_dns_watch(&cli, args.clone()).await,
Commands::Dns {
command: DnsCommand::Leak { command },
} => match command {
DnsLeakCommand::Status(args) => handle_dns_leak_status(&cli, args.clone()).await,
DnsLeakCommand::Watch(args) => handle_dns_leak_watch(&cli, args.clone()).await,
DnsLeakCommand::Report(args) => handle_dns_leak_report(&cli, args.clone()).await,
},
Commands::Calc {
command: CalcCommand::Subnet(args),
} => handle_calc_subnet(&cli, args.clone()).await,
@@ -586,6 +655,12 @@ async fn main() {
Commands::Discover {
command: DiscoverCommand::Ssdp(args),
} => handle_discover_ssdp(&cli, args.clone()).await,
Commands::Discover {
command: DiscoverCommand::Llmnr(args),
} => handle_discover_llmnr(&cli, args.clone()).await,
Commands::Discover {
command: DiscoverCommand::Nbns(args),
} => handle_discover_nbns(&cli, args.clone()).await,
Commands::Diag(args) => handle_diag(&cli, args.clone()).await,
};
@@ -1926,6 +2001,242 @@ async fn handle_dns_watch(cli: &Cli, args: DnsWatchArgs) -> i32 {
}
}
#[derive(Debug, Serialize)]
struct DnsLeakStatusReport {
pub policy: wtfnet_dnsleak::PolicySummary,
pub interfaces: Vec<wtfnet_platform::NetInterface>,
pub routes: Vec<wtfnet_platform::RouteEntry>,
pub dns: wtfnet_platform::DnsConfigSnapshot,
}
async fn handle_dns_leak_status(cli: &Cli, args: DnsLeakStatusArgs) -> i32 {
let platform = platform();
let interfaces = match platform.sys.interfaces().await {
Ok(value) => value,
Err(err) => return emit_platform_error(cli, err),
};
let routes = match platform.sys.routes().await {
Ok(value) => value,
Err(err) => return emit_platform_error(cli, err),
};
let dns = match platform.sys.dns_config().await {
Ok(value) => value,
Err(err) => return emit_platform_error(cli, err),
};
let policy = match resolve_leak_policy(&args.profile, args.policy.as_ref(), &interfaces) {
Ok(policy) => policy,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let report = DnsLeakStatusReport {
policy: policy.summary(),
interfaces,
routes,
dns,
};
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
let mut command_args = Vec::new();
if let Some(profile) = args.profile {
command_args.push("--profile".to_string());
command_args.push(profile);
}
if let Some(policy_path) = args.policy {
command_args.push("--policy".to_string());
command_args.push(policy_path.display().to_string());
}
let command = CommandInfo::new("dns leak status", command_args);
let envelope = CommandEnvelope::new(meta, command, report);
emit_json(cli, &envelope)
} else {
println!("policy: {:?}", report.policy.profile);
if !report.policy.allowed_ifaces.is_empty() {
println!("allowed ifaces: {}", report.policy.allowed_ifaces.join(", "));
}
if !report.policy.allowed_destinations.is_empty() {
println!(
"allowed destinations: {}",
report.policy.allowed_destinations.join(", ")
);
}
if !report.policy.allowed_ports.is_empty() {
println!(
"allowed ports: {}",
report
.policy
.allowed_ports
.iter()
.map(|port| port.to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
println!("interfaces: {}", report.interfaces.len());
println!("routes: {}", report.routes.len());
println!("dns servers: {}", report.dns.servers.join(", "));
ExitKind::Ok.code()
}
}
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) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let privacy = match parse_leak_privacy(&args.privacy) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let platform = platform();
let interfaces = match platform.sys.interfaces().await {
Ok(value) => value,
Err(err) => return emit_platform_error(cli, err),
};
let policy = match resolve_leak_policy(&args.profile, args.policy.as_ref(), &interfaces) {
Ok(policy) => policy,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let options = wtfnet_dnsleak::LeakWatchOptions {
duration_ms,
iface: args.iface.clone(),
policy,
privacy,
include_events: !args.summary_only,
};
let report = match wtfnet_dnsleak::watch(options, Some(&*platform.flow_owner)).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 Ok(payload) = serde_json::to_string_pretty(&report) {
if let Err(err) = std::fs::write(path, payload) {
eprintln!("failed to write report: {err}");
return ExitKind::Failed.code();
}
}
}
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
let mut command_args = vec!["--duration".to_string(), args.duration];
if let Some(iface) = args.iface {
command_args.push("--iface".to_string());
command_args.push(iface);
}
if let Some(profile) = args.profile {
command_args.push("--profile".to_string());
command_args.push(profile);
}
if let Some(policy_path) = args.policy {
command_args.push("--policy".to_string());
command_args.push(policy_path.display().to_string());
}
if let Some(out) = args.out {
command_args.push("--out".to_string());
command_args.push(out.display().to_string());
}
if args.summary_only {
command_args.push("--summary-only".to_string());
}
command_args.push("--privacy".to_string());
command_args.push(args.privacy);
let command = CommandInfo::new("dns leak watch", command_args);
let envelope = CommandEnvelope::new(meta, command, report);
emit_json(cli, &envelope)
} else {
print_leak_summary(&report);
if !report.events.is_empty() {
for event in report.events {
println!(
"[{:?}] {:?} {}:{} via {:?}",
event.severity, event.leak_type, event.dst_ip, event.dst_port, event.route_class
);
if let Some(qname) = event.qname.as_ref() {
println!(" qname: {}", qname);
}
if let Some(process) = event.process_name.as_ref() {
println!(" process: {}", process);
}
}
}
ExitKind::Ok.code()
}
}
async fn handle_dns_leak_iface_diag(_cli: &Cli) -> i32 {
match wtfnet_dnsleak::iface_diagnostics() {
Ok(entries) => {
for entry in entries {
println!("iface: {} open: {} {}", entry.name, entry.open_ok, entry.error);
}
ExitKind::Ok.code()
}
Err(err) => {
eprintln!("iface diag failed: {err}");
ExitKind::Failed.code()
}
}
}
async fn handle_dns_leak_report(cli: &Cli, args: DnsLeakReportArgs) -> i32 {
let privacy = match parse_leak_privacy(&args.privacy) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let payload = match std::fs::read_to_string(&args.path) {
Ok(value) => value,
Err(err) => {
eprintln!("failed to read report: {err}");
return ExitKind::Failed.code();
}
};
let mut report: wtfnet_dnsleak::LeakReport = match serde_json::from_str(&payload) {
Ok(value) => value,
Err(err) => {
eprintln!("failed to parse report: {err}");
return ExitKind::Failed.code();
}
};
for event in report.events.iter_mut() {
wtfnet_dnsleak::apply_privacy(event, privacy);
}
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
let command = CommandInfo::new("dns leak report", vec![args.path.display().to_string()]);
let envelope = CommandEnvelope::new(meta, command, report);
emit_json(cli, &envelope)
} else {
print_leak_summary(&report);
ExitKind::Ok.code()
}
}
async fn handle_calc_subnet(cli: &Cli, args: CalcSubnetArgs) -> i32 {
let input = match normalize_subnet_input(&args.input) {
Ok(value) => value,
@@ -2335,6 +2646,81 @@ fn parse_alpn(value: Option<&str>) -> Vec<String> {
.collect()
}
fn parse_leak_privacy(value: &str) -> Result<wtfnet_dnsleak::PrivacyMode, String> {
match value.to_ascii_lowercase().as_str() {
"full" => Ok(wtfnet_dnsleak::PrivacyMode::Full),
"redacted" => Ok(wtfnet_dnsleak::PrivacyMode::Redacted),
"minimal" => Ok(wtfnet_dnsleak::PrivacyMode::Minimal),
_ => Err(format!("invalid privacy mode: {value}")),
}
}
fn parse_leak_profile(
value: Option<&str>,
) -> Result<wtfnet_dnsleak::LeakPolicyProfile, String> {
let value = value.unwrap_or("proxy-stub");
match value.to_ascii_lowercase().as_str() {
"full-tunnel" => Ok(wtfnet_dnsleak::LeakPolicyProfile::FullTunnel),
"proxy-stub" => Ok(wtfnet_dnsleak::LeakPolicyProfile::ProxyStub),
"split" => Ok(wtfnet_dnsleak::LeakPolicyProfile::Split),
_ => Err(format!("invalid profile: {value}")),
}
}
fn resolve_leak_policy(
profile: &Option<String>,
policy_path: Option<&PathBuf>,
interfaces: &[wtfnet_platform::NetInterface],
) -> Result<wtfnet_dnsleak::LeakPolicy, String> {
if let Some(path) = policy_path {
let payload = std::fs::read_to_string(path)
.map_err(|err| format!("failed to read policy: {err}"))?;
let policy: wtfnet_dnsleak::LeakPolicy = serde_json::from_str(&payload)
.map_err(|err| format!("failed to parse policy: {err}"))?;
return Ok(policy);
}
let profile = parse_leak_profile(profile.as_deref())?;
let iface_names = interfaces.iter().map(|iface| iface.name.clone()).collect::<Vec<_>>();
Ok(wtfnet_dnsleak::LeakPolicy::from_profile(
profile,
&iface_names,
))
}
fn print_leak_summary(report: &wtfnet_dnsleak::LeakReport) {
println!("leaks: {}", report.summary.total);
if !report.summary.by_type.is_empty() {
let items = report
.summary
.by_type
.iter()
.map(|entry| format!("{:?}={}", entry.leak_type, entry.count))
.collect::<Vec<_>>()
.join(", ");
println!("by type: {items}");
}
if !report.summary.top_processes.is_empty() {
let items = report
.summary
.top_processes
.iter()
.map(|entry| format!("{}={}", entry.key, entry.count))
.collect::<Vec<_>>()
.join(", ");
println!("top processes: {items}");
}
if !report.summary.top_destinations.is_empty() {
let items = report
.summary
.top_destinations
.iter()
.map(|entry| format!("{}={}", entry.key, entry.count))
.collect::<Vec<_>>()
.join(", ");
println!("top destinations: {items}");
}
}
fn emit_tls_report<T: serde::Serialize>(cli: &Cli, name: &str, report: T) -> i32 {
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
@@ -2447,6 +2833,86 @@ async fn handle_discover_ssdp(cli: &Cli, args: DiscoverSsdpArgs) -> i32 {
}
}
async fn handle_discover_llmnr(cli: &Cli, args: DiscoverLlmnrArgs) -> i32 {
let duration_ms = match parse_duration_ms(&args.duration) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let options = wtfnet_discover::LlmnrOptions {
duration_ms,
name: args.name.clone(),
};
match wtfnet_discover::llmnr_discover(options).await {
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 let Some(name) = args.name {
command_args.push("--name".to_string());
command_args.push(name);
}
let command = CommandInfo::new("discover llmnr", command_args);
let envelope = CommandEnvelope::new(meta, command, report);
emit_json(cli, &envelope)
} else {
println!("query: {}", report.name);
for answer in report.answers {
println!("from: {}", answer.from);
println!(" name: {}", answer.name);
println!(" type: {}", answer.record_type);
println!(" data: {}", answer.data);
println!(" ttl: {}", answer.ttl);
}
ExitKind::Ok.code()
}
}
Err(err) => {
eprintln!("llmnr discover failed: {err}");
ExitKind::Failed.code()
}
}
}
async fn handle_discover_nbns(cli: &Cli, args: DiscoverNbnsArgs) -> i32 {
let duration_ms = match parse_duration_ms(&args.duration) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return ExitKind::Usage.code();
}
};
let options = wtfnet_discover::NbnsOptions { duration_ms };
match wtfnet_discover::nbns_discover(options).await {
Ok(report) => {
if cli.json {
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
let command = CommandInfo::new(
"discover nbns",
vec!["--duration".to_string(), args.duration],
);
let envelope = CommandEnvelope::new(meta, command, report);
emit_json(cli, &envelope)
} else {
for node in report.nodes {
println!("from: {}", node.from);
if node.names.is_empty() {
continue;
}
println!(" names: {}", node.names.join(", "));
}
ExitKind::Ok.code()
}
}
Err(err) => {
eprintln!("nbns discover failed: {err}");
ExitKind::Failed.code()
}
}
}
async fn handle_diag(cli: &Cli, args: DiagArgs) -> i32 {
let options = wtfnet_diag::DiagOptions {
dns_detect_domain: args.dns_detect.clone(),