Add: dns leak detection
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user