Add: dns leak detection
This commit is contained in:
@@ -19,6 +19,7 @@ wtfnet-geoip = { path = "../wtfnet-geoip" }
|
||||
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||
wtfnet-probe = { path = "../wtfnet-probe" }
|
||||
wtfnet-dns = { path = "../wtfnet-dns", features = ["pcap"] }
|
||||
wtfnet-dnsleak = { path = "../wtfnet-dnsleak", features = ["pcap"] }
|
||||
wtfnet-http = { path = "../wtfnet-http" }
|
||||
wtfnet-tls = { path = "../wtfnet-tls" }
|
||||
wtfnet-discover = { path = "../wtfnet-discover" }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
hickory-proto = "0.24"
|
||||
mdns-sd = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use hickory_proto::op::{Message, MessageType, Query};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::net::{IpAddr, SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -24,6 +26,17 @@ pub struct SsdpOptions {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrOptions {
|
||||
pub duration_ms: u64,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsOptions {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsService {
|
||||
pub service_type: String,
|
||||
@@ -56,6 +69,34 @@ pub struct SsdpReport {
|
||||
pub services: Vec<SsdpService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrAnswer {
|
||||
pub from: String,
|
||||
pub name: String,
|
||||
pub record_type: String,
|
||||
pub data: String,
|
||||
pub ttl: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrReport {
|
||||
pub duration_ms: u64,
|
||||
pub name: String,
|
||||
pub answers: Vec<LlmnrAnswer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsNodeStatus {
|
||||
pub from: String,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsReport {
|
||||
pub duration_ms: u64,
|
||||
pub nodes: Vec<NbnsNodeStatus>,
|
||||
}
|
||||
|
||||
pub async fn mdns_discover(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || mdns_discover_blocking(options))
|
||||
.await
|
||||
@@ -68,6 +109,18 @@ pub async fn ssdp_discover(options: SsdpOptions) -> Result<SsdpReport, DiscoverE
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn llmnr_discover(options: LlmnrOptions) -> Result<LlmnrReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || llmnr_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn nbns_discover(options: NbnsOptions) -> Result<NbnsReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || nbns_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
fn mdns_discover_blocking(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
let daemon = ServiceDaemon::new().map_err(|err| DiscoverError::Mdns(err.to_string()))?;
|
||||
let mut service_types = BTreeSet::new();
|
||||
@@ -174,6 +227,94 @@ fn ssdp_discover_blocking(options: SsdpOptions) -> Result<SsdpReport, DiscoverEr
|
||||
})
|
||||
}
|
||||
|
||||
fn llmnr_discover_blocking(options: LlmnrOptions) -> Result<LlmnrReport, DiscoverError> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
|
||||
let name = options
|
||||
.name
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "wpad".to_string());
|
||||
|
||||
let query = build_llmnr_query(&name)
|
||||
.map_err(|err| DiscoverError::Io(format!("llmnr build query: {err}")))?;
|
||||
let target = "224.0.0.252:5355";
|
||||
let _ = socket.send_to(&query, target);
|
||||
|
||||
let mut answers = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Some(entries) = parse_llmnr_response(&buf[..len], from.ip()) {
|
||||
for entry in entries {
|
||||
let key = format!(
|
||||
"{}|{}|{}|{}",
|
||||
entry.from, entry.name, entry.record_type, entry.data
|
||||
);
|
||||
if seen.insert(key) {
|
||||
answers.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LlmnrReport {
|
||||
duration_ms: options.duration_ms,
|
||||
name,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
fn nbns_discover_blocking(options: NbnsOptions) -> Result<NbnsReport, DiscoverError> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_broadcast(true)
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
|
||||
let query = build_nbns_node_status_query();
|
||||
let _ = socket.send_to(&query, "255.255.255.255:137");
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Some(names) = parse_nbns_node_status(&buf[..len]) {
|
||||
let key = format!("{}|{}", from.ip(), names.join(","));
|
||||
if seen.insert(key) {
|
||||
nodes.push(NbnsNodeStatus {
|
||||
from: from.ip().to_string(),
|
||||
names,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NbnsReport {
|
||||
duration_ms: options.duration_ms,
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option<SsdpService> {
|
||||
let mut st = None;
|
||||
let mut usn = None;
|
||||
@@ -207,3 +348,183 @@ fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option<SsdpService> {
|
||||
server,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_llmnr_query(name: &str) -> Result<Vec<u8>, String> {
|
||||
let name = Name::from_ascii(name).map_err(|err| format!("invalid name: {err}"))?;
|
||||
let mut message = Message::new();
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_recursion_desired(false)
|
||||
.add_query(Query::query(name.clone(), RecordType::A))
|
||||
.add_query(Query::query(name, RecordType::AAAA));
|
||||
message.to_vec().map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn parse_llmnr_response(payload: &[u8], from: IpAddr) -> Option<Vec<LlmnrAnswer>> {
|
||||
let message = Message::from_vec(payload).ok()?;
|
||||
if message.message_type() != MessageType::Response {
|
||||
return None;
|
||||
}
|
||||
let mut answers = Vec::new();
|
||||
for record in message.answers() {
|
||||
let record_type = record.record_type();
|
||||
let data = match record.data() {
|
||||
Some(RData::A(addr)) => addr.to_string(),
|
||||
Some(RData::AAAA(addr)) => addr.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
answers.push(LlmnrAnswer {
|
||||
from: from.to_string(),
|
||||
name: record.name().to_string(),
|
||||
record_type: record_type.to_string(),
|
||||
data,
|
||||
ttl: record.ttl(),
|
||||
});
|
||||
}
|
||||
if answers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answers)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_nbns_node_status_query() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(50);
|
||||
let id = nbns_query_id();
|
||||
buf.extend_from_slice(&id.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // flags
|
||||
buf.extend_from_slice(&1u16.to_be_bytes()); // qdcount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // ancount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // nscount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // arcount
|
||||
buf.extend_from_slice(&nbns_encode_name("*", 0x00));
|
||||
buf.extend_from_slice(&0x0021u16.to_be_bytes()); // NBSTAT
|
||||
buf.extend_from_slice(&0x0001u16.to_be_bytes()); // IN
|
||||
buf
|
||||
}
|
||||
|
||||
fn nbns_query_id() -> u16 {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
(nanos & 0xffff) as u16
|
||||
}
|
||||
|
||||
fn nbns_encode_name(name: &str, suffix: u8) -> Vec<u8> {
|
||||
let mut raw = [b' '; 16];
|
||||
let mut bytes = name.as_bytes().to_vec();
|
||||
for byte in bytes.iter_mut() {
|
||||
byte.make_ascii_uppercase();
|
||||
}
|
||||
for (idx, byte) in bytes.iter().take(15).enumerate() {
|
||||
raw[idx] = *byte;
|
||||
}
|
||||
raw[15] = suffix;
|
||||
|
||||
let mut encoded = Vec::with_capacity(34);
|
||||
encoded.push(32);
|
||||
for byte in raw {
|
||||
let high = ((byte >> 4) & 0x0f) + b'A';
|
||||
let low = (byte & 0x0f) + b'A';
|
||||
encoded.push(high);
|
||||
encoded.push(low);
|
||||
}
|
||||
encoded.push(0);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn parse_nbns_node_status(payload: &[u8]) -> Option<Vec<String>> {
|
||||
if payload.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let flags = u16::from_be_bytes([payload[2], payload[3]]);
|
||||
if flags & 0x8000 == 0 {
|
||||
return None;
|
||||
}
|
||||
let qdcount = u16::from_be_bytes([payload[4], payload[5]]) as usize;
|
||||
let ancount = u16::from_be_bytes([payload[6], payload[7]]) as usize;
|
||||
let mut offset = 12;
|
||||
for _ in 0..qdcount {
|
||||
offset = skip_dns_name(payload, offset)?;
|
||||
if offset + 4 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
let mut names = Vec::new();
|
||||
for _ in 0..ancount {
|
||||
offset = skip_dns_name(payload, offset)?;
|
||||
if offset + 10 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let rr_type = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
|
||||
let _rr_class = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]);
|
||||
let _ttl = u32::from_be_bytes([
|
||||
payload[offset + 4],
|
||||
payload[offset + 5],
|
||||
payload[offset + 6],
|
||||
payload[offset + 7],
|
||||
]);
|
||||
let rdlength = u16::from_be_bytes([payload[offset + 8], payload[offset + 9]]) as usize;
|
||||
offset += 10;
|
||||
if offset + rdlength > payload.len() {
|
||||
return None;
|
||||
}
|
||||
if rr_type == 0x0021 && rdlength > 0 {
|
||||
if let Some(list) = parse_nbns_name_list(&payload[offset..offset + rdlength]) {
|
||||
names.extend(list);
|
||||
}
|
||||
}
|
||||
offset += rdlength;
|
||||
}
|
||||
|
||||
if names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(names)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nbns_name_list(payload: &[u8]) -> Option<Vec<String>> {
|
||||
let count = *payload.first()? as usize;
|
||||
let mut offset = 1;
|
||||
let mut names = Vec::new();
|
||||
for _ in 0..count {
|
||||
if offset + 18 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let name_bytes = &payload[offset..offset + 15];
|
||||
let suffix = payload[offset + 15];
|
||||
let name = String::from_utf8_lossy(name_bytes)
|
||||
.trim_end()
|
||||
.to_string();
|
||||
names.push(format!("{name}<{suffix:02x}>"));
|
||||
offset += 18;
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
fn skip_dns_name(payload: &[u8], mut offset: usize) -> Option<usize> {
|
||||
if offset >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let len = *payload.get(offset)?;
|
||||
if len & 0xc0 == 0xc0 {
|
||||
if offset + 1 >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
return Some(offset + 2);
|
||||
}
|
||||
if len == 0 {
|
||||
return Some(offset + 1);
|
||||
}
|
||||
offset += 1 + len as usize;
|
||||
if offset >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
crates/wtfnet-dnsleak/Cargo.toml
Normal file
17
crates/wtfnet-dnsleak/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "wtfnet-dnsleak"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
hickory-proto = "0.24"
|
||||
ipnet = { version = "2", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = "0.1"
|
||||
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||
pnet = { version = "0.34", optional = true }
|
||||
|
||||
[features]
|
||||
pcap = ["dep:pnet"]
|
||||
32
crates/wtfnet-dnsleak/src/classify.rs
Normal file
32
crates/wtfnet-dnsleak/src/classify.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::report::LeakTransport;
|
||||
use hickory_proto::op::{Message, MessageType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
use wtfnet_platform::FlowProtocol;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassifiedEvent {
|
||||
pub timestamp_ms: u128,
|
||||
pub proto: FlowProtocol,
|
||||
pub src_ip: IpAddr,
|
||||
pub src_port: u16,
|
||||
pub dst_ip: IpAddr,
|
||||
pub dst_port: u16,
|
||||
pub iface_name: Option<String>,
|
||||
pub transport: LeakTransport,
|
||||
pub qname: Option<String>,
|
||||
pub qtype: Option<String>,
|
||||
pub rcode: Option<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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))
|
||||
}
|
||||
102
crates/wtfnet-dnsleak/src/lib.rs
Normal file
102
crates/wtfnet-dnsleak/src/lib.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
mod classify;
|
||||
mod policy;
|
||||
mod privacy;
|
||||
mod report;
|
||||
mod route;
|
||||
mod rules;
|
||||
mod sensor;
|
||||
|
||||
use crate::classify::ClassifiedEvent;
|
||||
use crate::sensor::capture_events;
|
||||
use std::time::Instant;
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
use wtfnet_platform::{FlowOwnerProvider, FlowTuple};
|
||||
|
||||
pub use crate::policy::{LeakPolicy, LeakPolicyProfile, PolicySummary};
|
||||
pub use crate::privacy::{apply_privacy, PrivacyMode};
|
||||
pub use crate::report::{LeakEvent, LeakReport, LeakSummary, LeakTransport, RouteClass, Severity};
|
||||
pub use crate::sensor::{iface_diagnostics, IfaceDiag};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DnsLeakError {
|
||||
#[error("not supported: {0}")]
|
||||
NotSupported(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
#[error("policy error: {0}")]
|
||||
Policy(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LeakWatchOptions {
|
||||
pub duration_ms: u64,
|
||||
pub iface: Option<String>,
|
||||
pub policy: LeakPolicy,
|
||||
pub privacy: PrivacyMode,
|
||||
pub include_events: bool,
|
||||
}
|
||||
|
||||
pub async fn watch(
|
||||
options: LeakWatchOptions,
|
||||
flow_owner: Option<&dyn FlowOwnerProvider>,
|
||||
) -> Result<LeakReport, DnsLeakError> {
|
||||
debug!(
|
||||
duration_ms = options.duration_ms,
|
||||
iface = ?options.iface,
|
||||
include_events = options.include_events,
|
||||
"dns leak watch start"
|
||||
);
|
||||
let start = Instant::now();
|
||||
let events = capture_events(&options).await?;
|
||||
let mut leak_events = Vec::new();
|
||||
|
||||
for event in events {
|
||||
let enriched = enrich_event(event, flow_owner).await;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let summary = LeakSummary::from_events(&leak_events);
|
||||
let report = LeakReport {
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
policy: options.policy.summary(),
|
||||
summary,
|
||||
events: if options.include_events {
|
||||
leak_events
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn enrich_event(
|
||||
event: ClassifiedEvent,
|
||||
flow_owner: Option<&dyn FlowOwnerProvider>,
|
||||
) -> report::EnrichedEvent {
|
||||
let mut enriched = route::enrich_route(event);
|
||||
if let Some(provider) = flow_owner {
|
||||
let flow = FlowTuple {
|
||||
proto: enriched.proto,
|
||||
src_ip: enriched.src_ip,
|
||||
src_port: enriched.src_port,
|
||||
dst_ip: enriched.dst_ip,
|
||||
dst_port: enriched.dst_port,
|
||||
};
|
||||
match provider.owner_of(flow).await {
|
||||
Ok(result) => {
|
||||
enriched.owner = result.owner;
|
||||
enriched.owner_confidence = result.confidence;
|
||||
enriched.owner_failure = result.failure_reason;
|
||||
}
|
||||
Err(err) => {
|
||||
enriched.owner_failure = Some(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
enriched
|
||||
}
|
||||
113
crates/wtfnet-dnsleak/src/policy.rs
Normal file
113
crates/wtfnet-dnsleak/src/policy.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LeakPolicyProfile {
|
||||
FullTunnel,
|
||||
ProxyStub,
|
||||
Split,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeakPolicy {
|
||||
pub profile: LeakPolicyProfile,
|
||||
pub allowed_ifaces: Vec<String>,
|
||||
pub tunnel_ifaces: Vec<String>,
|
||||
pub loopback_ifaces: Vec<String>,
|
||||
pub allowed_destinations: Vec<IpNet>,
|
||||
pub allowed_ports: Vec<u16>,
|
||||
pub allowed_processes: Vec<String>,
|
||||
pub proxy_required_domains: Vec<String>,
|
||||
pub allowlist_domains: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicySummary {
|
||||
pub profile: LeakPolicyProfile,
|
||||
pub allowed_ifaces: Vec<String>,
|
||||
pub tunnel_ifaces: Vec<String>,
|
||||
pub allowed_destinations: Vec<String>,
|
||||
pub allowed_ports: Vec<u16>,
|
||||
pub allowed_processes: Vec<String>,
|
||||
}
|
||||
|
||||
impl LeakPolicy {
|
||||
pub fn from_profile(profile: LeakPolicyProfile, ifaces: &[String]) -> Self {
|
||||
let loopback_ifaces = detect_loopback_ifaces(ifaces);
|
||||
let tunnel_ifaces = detect_tunnel_ifaces(ifaces);
|
||||
let allowed_ifaces = match profile {
|
||||
LeakPolicyProfile::FullTunnel | LeakPolicyProfile::ProxyStub => {
|
||||
merge_lists(&loopback_ifaces, &tunnel_ifaces)
|
||||
}
|
||||
LeakPolicyProfile::Split => merge_lists(&loopback_ifaces, &tunnel_ifaces),
|
||||
};
|
||||
|
||||
LeakPolicy {
|
||||
profile,
|
||||
allowed_ifaces,
|
||||
tunnel_ifaces,
|
||||
loopback_ifaces,
|
||||
allowed_destinations: Vec::new(),
|
||||
allowed_ports: Vec::new(),
|
||||
allowed_processes: Vec::new(),
|
||||
proxy_required_domains: Vec::new(),
|
||||
allowlist_domains: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> PolicySummary {
|
||||
PolicySummary {
|
||||
profile: self.profile,
|
||||
allowed_ifaces: self.allowed_ifaces.clone(),
|
||||
tunnel_ifaces: self.tunnel_ifaces.clone(),
|
||||
allowed_destinations: self
|
||||
.allowed_destinations
|
||||
.iter()
|
||||
.map(|net| net.to_string())
|
||||
.collect(),
|
||||
allowed_ports: self.allowed_ports.clone(),
|
||||
allowed_processes: self.allowed_processes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_loopback_ifaces(ifaces: &[String]) -> Vec<String> {
|
||||
ifaces
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name == "lo"
|
||||
|| name == "lo0"
|
||||
|| name.contains("loopback")
|
||||
|| name.contains("localhost")
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn detect_tunnel_ifaces(ifaces: &[String]) -> Vec<String> {
|
||||
ifaces
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.contains("tun")
|
||||
|| name.contains("tap")
|
||||
|| name.contains("wg")
|
||||
|| name.contains("wireguard")
|
||||
|| name.contains("vpn")
|
||||
|| name.contains("ppp")
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge_lists(a: &[String], b: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for value in a.iter().chain(b.iter()) {
|
||||
if !out.iter().any(|entry| entry == value) {
|
||||
out.push(value.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
35
crates/wtfnet-dnsleak/src/privacy.rs
Normal file
35
crates/wtfnet-dnsleak/src/privacy.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::report::LeakEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PrivacyMode {
|
||||
Full,
|
||||
Redacted,
|
||||
Minimal,
|
||||
}
|
||||
|
||||
pub fn apply_privacy(event: &mut LeakEvent, mode: PrivacyMode) {
|
||||
match mode {
|
||||
PrivacyMode::Full => {}
|
||||
PrivacyMode::Redacted => {
|
||||
if let Some(value) = event.qname.as_ref() {
|
||||
event.qname = Some(redact_domain(value));
|
||||
}
|
||||
}
|
||||
PrivacyMode::Minimal => {
|
||||
event.qname = None;
|
||||
event.qtype = None;
|
||||
event.rcode = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_domain(value: &str) -> String {
|
||||
let parts: Vec<&str> = value.split('.').filter(|part| !part.is_empty()).collect();
|
||||
if parts.len() >= 2 {
|
||||
format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
192
crates/wtfnet-dnsleak/src/report.rs
Normal file
192
crates/wtfnet-dnsleak/src/report.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use crate::policy::PolicySummary;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::net::IpAddr;
|
||||
use wtfnet_platform::{FlowOwner, FlowOwnerConfidence, FlowProtocol};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LeakTransport {
|
||||
Udp53,
|
||||
Tcp53,
|
||||
Dot,
|
||||
Doh,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LeakType {
|
||||
A,
|
||||
B,
|
||||
C,
|
||||
D,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RouteClass {
|
||||
Loopback,
|
||||
Tunnel,
|
||||
Physical,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
P0,
|
||||
P1,
|
||||
P2,
|
||||
P3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnrichedEvent {
|
||||
pub timestamp_ms: u128,
|
||||
pub proto: FlowProtocol,
|
||||
pub src_ip: IpAddr,
|
||||
pub src_port: u16,
|
||||
pub dst_ip: IpAddr,
|
||||
pub dst_port: u16,
|
||||
pub iface_name: Option<String>,
|
||||
pub transport: LeakTransport,
|
||||
pub qname: Option<String>,
|
||||
pub qtype: Option<String>,
|
||||
pub rcode: Option<String>,
|
||||
pub route_class: RouteClass,
|
||||
pub owner: Option<FlowOwner>,
|
||||
pub owner_confidence: FlowOwnerConfidence,
|
||||
pub owner_failure: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeakEvent {
|
||||
pub timestamp_ms: u128,
|
||||
pub transport: LeakTransport,
|
||||
pub qname: Option<String>,
|
||||
pub qtype: Option<String>,
|
||||
pub rcode: Option<String>,
|
||||
pub iface_name: Option<String>,
|
||||
pub route_class: RouteClass,
|
||||
pub dst_ip: String,
|
||||
pub dst_port: u16,
|
||||
pub pid: Option<u32>,
|
||||
pub ppid: Option<u32>,
|
||||
pub process_name: Option<String>,
|
||||
pub process_path: Option<String>,
|
||||
pub attribution_confidence: FlowOwnerConfidence,
|
||||
pub attribution_failure: Option<String>,
|
||||
pub leak_type: LeakType,
|
||||
pub severity: Severity,
|
||||
pub policy_rule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeakTypeCount {
|
||||
pub leak_type: LeakType,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SummaryItem {
|
||||
pub key: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeakSummary {
|
||||
pub total: usize,
|
||||
pub by_type: Vec<LeakTypeCount>,
|
||||
pub top_processes: Vec<SummaryItem>,
|
||||
pub top_destinations: Vec<SummaryItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeakReport {
|
||||
pub duration_ms: u64,
|
||||
pub policy: PolicySummary,
|
||||
pub summary: LeakSummary,
|
||||
pub events: Vec<LeakEvent>,
|
||||
}
|
||||
|
||||
impl LeakEvent {
|
||||
pub fn from_decision(event: EnrichedEvent, decision: crate::rules::LeakDecision) -> Self {
|
||||
let (pid, ppid, process_name, process_path) = event
|
||||
.owner
|
||||
.as_ref()
|
||||
.map(|owner| {
|
||||
(
|
||||
owner.pid,
|
||||
owner.ppid,
|
||||
owner.process_name.clone(),
|
||||
owner.process_path.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None, None, None));
|
||||
|
||||
LeakEvent {
|
||||
timestamp_ms: event.timestamp_ms,
|
||||
transport: event.transport,
|
||||
qname: event.qname,
|
||||
qtype: event.qtype,
|
||||
rcode: event.rcode,
|
||||
iface_name: event.iface_name,
|
||||
route_class: event.route_class,
|
||||
dst_ip: event.dst_ip.to_string(),
|
||||
dst_port: event.dst_port,
|
||||
pid,
|
||||
ppid,
|
||||
process_name,
|
||||
process_path,
|
||||
attribution_confidence: event.owner_confidence,
|
||||
attribution_failure: event.owner_failure,
|
||||
leak_type: decision.leak_type,
|
||||
severity: decision.severity,
|
||||
policy_rule_id: decision.policy_rule_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LeakSummary {
|
||||
pub fn from_events(events: &[LeakEvent]) -> Self {
|
||||
let total = events.len();
|
||||
let mut by_type_map: HashMap<LeakType, usize> = HashMap::new();
|
||||
let mut process_map: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut dest_map: BTreeMap<String, usize> = BTreeMap::new();
|
||||
|
||||
for event in events {
|
||||
*by_type_map.entry(event.leak_type).or_insert(0) += 1;
|
||||
if let Some(name) = event.process_name.as_ref() {
|
||||
*process_map.entry(name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
let dst_key = format!("{}:{}", event.dst_ip, event.dst_port);
|
||||
*dest_map.entry(dst_key).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut by_type = by_type_map
|
||||
.into_iter()
|
||||
.map(|(leak_type, count)| LeakTypeCount { leak_type, count })
|
||||
.collect::<Vec<_>>();
|
||||
by_type.sort_by(|a, b| a.leak_type.cmp(&b.leak_type));
|
||||
let top_processes = top_items(process_map, 5);
|
||||
let top_destinations = top_items(dest_map, 5);
|
||||
|
||||
LeakSummary {
|
||||
total,
|
||||
by_type,
|
||||
top_processes,
|
||||
top_destinations,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn top_items(map: BTreeMap<String, usize>, limit: usize) -> Vec<SummaryItem> {
|
||||
let mut items = map
|
||||
.into_iter()
|
||||
.map(|(key, count)| SummaryItem { key, count })
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
48
crates/wtfnet-dnsleak/src/route.rs
Normal file
48
crates/wtfnet-dnsleak/src/route.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::classify::ClassifiedEvent;
|
||||
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
|
||||
};
|
||||
|
||||
EnrichedEvent {
|
||||
timestamp_ms: event.timestamp_ms,
|
||||
proto: event.proto,
|
||||
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,
|
||||
transport: event.transport,
|
||||
qname: event.qname,
|
||||
qtype: event.qtype,
|
||||
rcode: event.rcode,
|
||||
route_class,
|
||||
owner: None,
|
||||
owner_confidence: FlowOwnerConfidence::None,
|
||||
owner_failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tunnel_iface(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.contains("tun")
|
||||
|| name.contains("tap")
|
||||
|| name.contains("wg")
|
||||
|| name.contains("wireguard")
|
||||
|| name.contains("vpn")
|
||||
|| name.contains("ppp")
|
||||
}
|
||||
116
crates/wtfnet-dnsleak/src/rules.rs
Normal file
116
crates/wtfnet-dnsleak/src/rules.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use crate::policy::LeakPolicy;
|
||||
use crate::report::{EnrichedEvent, LeakTransport, LeakType, Severity};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LeakDecision {
|
||||
pub leak_type: LeakType,
|
||||
pub severity: Severity,
|
||||
pub policy_rule_id: String,
|
||||
}
|
||||
|
||||
pub fn evaluate(event: &EnrichedEvent, policy: &LeakPolicy) -> Option<LeakDecision> {
|
||||
match event.transport {
|
||||
LeakTransport::Udp53 | LeakTransport::Tcp53 => {
|
||||
if is_proxy_required(event, policy) && !is_allowed(event, policy) {
|
||||
return Some(LeakDecision {
|
||||
leak_type: LeakType::B,
|
||||
severity: Severity::P1,
|
||||
policy_rule_id: "LEAK_B_PROXY_REQUIRED".to_string(),
|
||||
});
|
||||
}
|
||||
if !is_allowed(event, policy) {
|
||||
return Some(LeakDecision {
|
||||
leak_type: LeakType::A,
|
||||
severity: Severity::P0,
|
||||
policy_rule_id: "LEAK_A_PLAINTEXT".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
LeakTransport::Dot | LeakTransport::Doh => {
|
||||
if !is_allowed(event, policy) {
|
||||
return Some(LeakDecision {
|
||||
leak_type: LeakType::C,
|
||||
severity: Severity::P1,
|
||||
policy_rule_id: "LEAK_C_ENCRYPTED".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
LeakTransport::Unknown => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_allowed(event: &EnrichedEvent, policy: &LeakPolicy) -> bool {
|
||||
let has_rules = !policy.allowed_ifaces.is_empty()
|
||||
|| !policy.allowed_destinations.is_empty()
|
||||
|| !policy.allowed_ports.is_empty()
|
||||
|| !policy.allowed_processes.is_empty();
|
||||
if !has_rules {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(iface) = event.iface_name.as_ref() {
|
||||
if policy
|
||||
.allowed_ifaces
|
||||
.iter()
|
||||
.any(|allowed| allowed.eq_ignore_ascii_case(iface))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if policy
|
||||
.allowed_ports
|
||||
.iter()
|
||||
.any(|port| *port == event.dst_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if policy
|
||||
.allowed_destinations
|
||||
.iter()
|
||||
.any(|net| net.contains(&event.dst_ip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(name) = event
|
||||
.owner
|
||||
.as_ref()
|
||||
.and_then(|owner| owner.process_name.as_ref())
|
||||
{
|
||||
if policy
|
||||
.allowed_processes
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_proxy_required(event: &EnrichedEvent, policy: &LeakPolicy) -> bool {
|
||||
let Some(qname) = event.qname.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let qname = qname.to_ascii_lowercase();
|
||||
if policy.proxy_required_domains.iter().any(|domain| {
|
||||
let domain = domain.to_ascii_lowercase();
|
||||
qname == domain || qname.ends_with(&format!(".{domain}"))
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !policy.allowlist_domains.is_empty() {
|
||||
let allowed = policy.allowlist_domains.iter().any(|domain| {
|
||||
let domain = domain.to_ascii_lowercase();
|
||||
qname == domain || qname.ends_with(&format!(".{domain}"))
|
||||
});
|
||||
return !allowed;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
380
crates/wtfnet-dnsleak/src/sensor.rs
Normal file
380
crates/wtfnet-dnsleak/src/sensor.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use crate::classify::{classify_dns_query, ClassifiedEvent};
|
||||
use crate::report::LeakTransport;
|
||||
use crate::DnsLeakError;
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tracing::debug;
|
||||
use wtfnet_platform::FlowProtocol;
|
||||
|
||||
use crate::LeakWatchOptions;
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
use pnet::datalink::{self, Channel, Config as DatalinkConfig};
|
||||
#[cfg(feature = "pcap")]
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[cfg(not(feature = "pcap"))]
|
||||
pub async fn capture_events(_options: &LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, 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> {
|
||||
let options = options.clone();
|
||||
let candidates = format_iface_list(&datalink::interfaces());
|
||||
let timeout_ms = options.duration_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()))?,
|
||||
Err(_) => {
|
||||
return Err(DnsLeakError::Io(
|
||||
format!(
|
||||
"capture timed out waiting for interface; candidates: {candidates}"
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IfaceDiag {
|
||||
pub name: String,
|
||||
pub open_ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "pcap"))]
|
||||
pub fn iface_diagnostics() -> Result<Vec<IfaceDiag>, DnsLeakError> {
|
||||
Err(DnsLeakError::NotSupported(
|
||||
"dns leak watch requires pcap feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
pub fn iface_diagnostics() -> Result<Vec<IfaceDiag>, DnsLeakError> {
|
||||
let interfaces = datalink::interfaces();
|
||||
let mut config = DatalinkConfig::default();
|
||||
config.read_timeout = Some(Duration::from_millis(500));
|
||||
let mut out = Vec::new();
|
||||
for iface in interfaces {
|
||||
let result = match open_channel_with_timeout(iface.clone(), &config) {
|
||||
Ok((_iface, _rx)) => IfaceDiag {
|
||||
name: iface.name,
|
||||
open_ok: true,
|
||||
error: "-".to_string(),
|
||||
},
|
||||
Err(err) => IfaceDiag {
|
||||
name: iface.name,
|
||||
open_ok: false,
|
||||
error: err,
|
||||
},
|
||||
};
|
||||
out.push(result);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
||||
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
||||
use pnet::packet::Packet;
|
||||
|
||||
let mut config = DatalinkConfig::default();
|
||||
config.read_timeout = Some(Duration::from_millis(500));
|
||||
let (iface, mut rx) = select_interface(options.iface.as_deref(), &config)?;
|
||||
let local_ips = iface.ips.iter().map(|ip| ip.ip()).collect::<Vec<_>>();
|
||||
let iface_name = iface.name.clone();
|
||||
|
||||
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() {
|
||||
Ok(frame) => frame,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let ethernet = match EthernetPacket::new(frame) {
|
||||
Some(packet) => packet,
|
||||
None => continue,
|
||||
};
|
||||
let event = match ethernet.get_ethertype() {
|
||||
EtherTypes::Ipv4 => parse_ipv4(
|
||||
ethernet.payload(),
|
||||
&local_ips,
|
||||
&iface_name,
|
||||
),
|
||||
EtherTypes::Ipv6 => parse_ipv6(
|
||||
ethernet.payload(),
|
||||
&local_ips,
|
||||
&iface_name,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let key = format!(
|
||||
"{:?}|{}|{}|{}|{}",
|
||||
event.transport, event.src_ip, event.src_port, event.dst_ip, event.dst_port
|
||||
);
|
||||
if seen.insert(key) {
|
||||
debug!(
|
||||
transport = ?event.transport,
|
||||
src_ip = %event.src_ip,
|
||||
src_port = event.src_port,
|
||||
dst_ip = %event.dst_ip,
|
||||
dst_port = event.dst_port,
|
||||
"dns leak event"
|
||||
);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn parse_ipv4(
|
||||
payload: &[u8],
|
||||
local_ips: &[IpAddr],
|
||||
iface_name: &str,
|
||||
) -> Option<ClassifiedEvent> {
|
||||
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) {
|
||||
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,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn parse_ipv6(
|
||||
payload: &[u8],
|
||||
local_ips: &[IpAddr],
|
||||
iface_name: &str,
|
||||
) -> Option<ClassifiedEvent> {
|
||||
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) {
|
||||
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,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn parse_udp(
|
||||
src_ip: IpAddr,
|
||||
dst_ip: IpAddr,
|
||||
payload: &[u8],
|
||||
iface_name: &str,
|
||||
) -> Option<ClassifiedEvent> {
|
||||
use pnet::packet::udp::UdpPacket;
|
||||
use pnet::packet::Packet;
|
||||
let udp = UdpPacket::new(payload)?;
|
||||
let dst_port = udp.get_destination();
|
||||
if dst_port != 53 {
|
||||
return None;
|
||||
}
|
||||
let (qname, qtype, rcode) = classify_dns_query(udp.payload())?;
|
||||
Some(ClassifiedEvent {
|
||||
timestamp_ms: now_ms(),
|
||||
proto: FlowProtocol::Udp,
|
||||
src_ip,
|
||||
src_port: udp.get_source(),
|
||||
dst_ip,
|
||||
dst_port,
|
||||
iface_name: Some(iface_name.to_string()),
|
||||
transport: LeakTransport::Udp53,
|
||||
qname: Some(qname),
|
||||
qtype: Some(qtype),
|
||||
rcode: Some(rcode),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn parse_tcp(
|
||||
src_ip: IpAddr,
|
||||
dst_ip: IpAddr,
|
||||
payload: &[u8],
|
||||
iface_name: &str,
|
||||
) -> Option<ClassifiedEvent> {
|
||||
use pnet::packet::tcp::TcpPacket;
|
||||
let tcp = TcpPacket::new(payload)?;
|
||||
let dst_port = tcp.get_destination();
|
||||
let transport = match dst_port {
|
||||
53 => LeakTransport::Tcp53,
|
||||
853 => LeakTransport::Dot,
|
||||
_ => return None,
|
||||
};
|
||||
Some(ClassifiedEvent {
|
||||
timestamp_ms: now_ms(),
|
||||
proto: FlowProtocol::Tcp,
|
||||
src_ip,
|
||||
src_port: tcp.get_source(),
|
||||
dst_ip,
|
||||
dst_port,
|
||||
iface_name: Some(iface_name.to_string()),
|
||||
transport,
|
||||
qname: None,
|
||||
qtype: None,
|
||||
rcode: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn select_interface(
|
||||
name: Option<&str>,
|
||||
config: &DatalinkConfig,
|
||||
) -> Result<(datalink::NetworkInterface, Box<dyn datalink::DataLinkReceiver>), DnsLeakError> {
|
||||
let interfaces = datalink::interfaces();
|
||||
if let Some(name) = name {
|
||||
let iface = interfaces
|
||||
.iter()
|
||||
.find(|iface| iface.name == name)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DnsLeakError::Io(format!(
|
||||
"interface '{name}' not found; candidates: {}",
|
||||
format_iface_list(&interfaces)
|
||||
))
|
||||
})?;
|
||||
return open_channel_with_timeout(iface, config).map_err(|err| {
|
||||
DnsLeakError::Io(format!(
|
||||
"failed to open capture on interface ({err}); candidates: {}",
|
||||
format_iface_list(&interfaces)
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(iface) = pick_stable_iface(&interfaces) {
|
||||
if let Ok(channel) = open_channel_with_timeout(iface, config) {
|
||||
return Ok(channel);
|
||||
}
|
||||
}
|
||||
|
||||
for iface in interfaces.iter() {
|
||||
if let Ok(channel) = open_channel_with_timeout(iface.clone(), config) {
|
||||
return Ok(channel);
|
||||
}
|
||||
}
|
||||
|
||||
Err(DnsLeakError::Io(format!(
|
||||
"no suitable interface found; candidates: {}",
|
||||
format_iface_list(&interfaces)
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn open_channel_with_timeout(
|
||||
iface: datalink::NetworkInterface,
|
||||
config: &DatalinkConfig,
|
||||
) -> Result<(datalink::NetworkInterface, Box<dyn datalink::DataLinkReceiver>), String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let config = config.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = match datalink::channel(&iface, config) {
|
||||
Ok(Channel::Ethernet(_, rx)) => Ok(rx),
|
||||
Ok(_) => Err("unsupported channel".to_string()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
};
|
||||
let _ = tx.send((iface, result));
|
||||
});
|
||||
|
||||
let timeout = Duration::from_millis(700);
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok((iface, Ok(rx))) => Ok((iface, rx)),
|
||||
Ok((_iface, Err(err))) => Err(err),
|
||||
Err(_) => Err("timeout opening capture".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn is_named_fallback(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.contains("wlan")
|
||||
|| name.contains("wifi")
|
||||
|| name.contains("wi-fi")
|
||||
|| name.contains("ethernet")
|
||||
|| name.contains("eth")
|
||||
|| name.contains("lan")
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn pick_stable_iface(
|
||||
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();
|
||||
}
|
||||
preferred.into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn format_iface_list(interfaces: &[datalink::NetworkInterface]) -> String {
|
||||
if interfaces.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
interfaces
|
||||
.iter()
|
||||
.map(|iface| iface.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(feature = "pcap")]
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
}
|
||||
@@ -148,7 +148,11 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("http3 failed: {err}"));
|
||||
let err_string = err.to_string();
|
||||
let category = classify_http3_error(&err_string);
|
||||
warnings.push(format!(
|
||||
"http3 failed (category={category}): {err_string}"
|
||||
));
|
||||
if opts.http3_only {
|
||||
return Err(err);
|
||||
}
|
||||
@@ -410,6 +414,37 @@ fn build_tls_connector() -> Result<TlsConnector, HttpError> {
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
fn classify_http3_error(message: &str) -> &'static str {
|
||||
let message = message.to_ascii_lowercase();
|
||||
if message.contains("timeout") || message.contains("timed out") {
|
||||
return "timeout";
|
||||
}
|
||||
if message.contains("no resolved ips") || message.contains("no addresses resolved") {
|
||||
return "resolve";
|
||||
}
|
||||
if message.contains("udp") && message.contains("blocked") {
|
||||
return "udp_blocked";
|
||||
}
|
||||
if message.contains("quic") || message.contains("connection refused") {
|
||||
return "connect";
|
||||
}
|
||||
if message.contains("alpn") || message.contains("application protocol") {
|
||||
return "alpn";
|
||||
}
|
||||
if message.contains("tls")
|
||||
|| message.contains("certificate")
|
||||
|| message.contains("crypto")
|
||||
|| message.contains("handshake")
|
||||
{
|
||||
return "tls";
|
||||
}
|
||||
if message.contains("permission denied") || message.contains("access is denied") {
|
||||
return "permission";
|
||||
}
|
||||
"unknown"
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
async fn http3_request(
|
||||
url: &str,
|
||||
|
||||
@@ -2,10 +2,12 @@ use async_trait::async_trait;
|
||||
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||
use sha2::Digest;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use wtfnet_core::ErrorCode;
|
||||
use wtfnet_platform::{
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider,
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, FlowOwner, FlowOwnerConfidence, FlowOwnerProvider,
|
||||
FlowOwnerResult, FlowProtocol, FlowTuple, ListenSocket, NeighborEntry, NeighProvider,
|
||||
NetInterface, Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
};
|
||||
use x509_parser::oid_registry::{
|
||||
@@ -19,6 +21,7 @@ pub fn platform() -> Platform {
|
||||
ports: Arc::new(LinuxPortsProvider),
|
||||
cert: Arc::new(LinuxCertProvider),
|
||||
neigh: Arc::new(LinuxNeighProvider),
|
||||
flow_owner: Arc::new(LinuxFlowOwnerProvider),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +29,7 @@ struct LinuxSysProvider;
|
||||
struct LinuxPortsProvider;
|
||||
struct LinuxCertProvider;
|
||||
struct LinuxNeighProvider;
|
||||
struct LinuxFlowOwnerProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl SysProvider for LinuxSysProvider {
|
||||
@@ -375,6 +379,20 @@ fn parse_proc_socket_addr(value: &str, is_v6: bool) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_proc_socket_addr_value(value: &str, is_v6: bool) -> Option<SocketAddr> {
|
||||
let mut parts = value.split(':');
|
||||
let addr_hex = parts.next()?;
|
||||
let port_hex = parts.next()?;
|
||||
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||
if is_v6 {
|
||||
let addr = parse_ipv6_hex(addr_hex)?;
|
||||
Some(SocketAddr::new(IpAddr::V6(addr), port))
|
||||
} else {
|
||||
let addr = parse_ipv4_hex(addr_hex)?;
|
||||
Some(SocketAddr::new(IpAddr::V4(addr), port))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linux_arp(contents: &str) -> Vec<NeighborEntry> {
|
||||
let mut neighbors = Vec::new();
|
||||
for (idx, line) in contents.lines().enumerate() {
|
||||
@@ -482,6 +500,138 @@ fn read_ppid(pid: u32) -> Option<u32> {
|
||||
Some(ppid)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProcSocketEntry {
|
||||
local: SocketAddr,
|
||||
remote: SocketAddr,
|
||||
inode: String,
|
||||
}
|
||||
|
||||
fn parse_proc_socket_entries(
|
||||
path: &str,
|
||||
is_v6: bool,
|
||||
) -> Result<Vec<ProcSocketEntry>, PlatformError> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
let mut entries = Vec::new();
|
||||
for (idx, line) in contents.lines().enumerate() {
|
||||
if idx == 0 {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 10 {
|
||||
continue;
|
||||
}
|
||||
let local = parts[1];
|
||||
let remote = parts[2];
|
||||
let inode = match parts.get(9) {
|
||||
Some(value) => (*value).to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let local_addr = match parse_proc_socket_addr_value(local, is_v6) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let remote_addr = match parse_proc_socket_addr_value(remote, is_v6) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
entries.push(ProcSocketEntry {
|
||||
local: local_addr,
|
||||
remote: remote_addr,
|
||||
inode,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn match_flow_entry<'a>(
|
||||
flow: &FlowTuple,
|
||||
entries: &'a [ProcSocketEntry],
|
||||
match_remote: bool,
|
||||
) -> Option<(&'a ProcSocketEntry, FlowOwnerConfidence)> {
|
||||
for entry in entries {
|
||||
let local_match = entry.local.port() == flow.src_port
|
||||
&& (entry.local.ip() == flow.src_ip
|
||||
|| entry.local.ip().is_unspecified()
|
||||
|| entry.local.ip().is_loopback() && flow.src_ip.is_loopback());
|
||||
if !local_match {
|
||||
continue;
|
||||
}
|
||||
if match_remote {
|
||||
let remote_match = entry.remote.port() == flow.dst_port
|
||||
&& (entry.remote.ip() == flow.dst_ip
|
||||
|| entry.remote.ip().is_unspecified());
|
||||
if remote_match {
|
||||
return Some((entry, FlowOwnerConfidence::High));
|
||||
}
|
||||
} else {
|
||||
return Some((entry, FlowOwnerConfidence::Medium));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_flow_owner(
|
||||
flow: &FlowTuple,
|
||||
) -> Result<FlowOwnerResult, PlatformError> {
|
||||
let inode_map = build_inode_map();
|
||||
let entries = match flow.proto {
|
||||
FlowProtocol::Tcp => {
|
||||
let mut out = parse_proc_socket_entries("/proc/net/tcp", false)?;
|
||||
out.extend(parse_proc_socket_entries("/proc/net/tcp6", true)?);
|
||||
out
|
||||
}
|
||||
FlowProtocol::Udp => {
|
||||
let mut out = parse_proc_socket_entries("/proc/net/udp", false)?;
|
||||
out.extend(parse_proc_socket_entries("/proc/net/udp6", true)?);
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
let match_remote = matches!(flow.proto, FlowProtocol::Tcp);
|
||||
let matched = match_flow_entry(flow, &entries, match_remote)
|
||||
.or_else(|| {
|
||||
if matches!(flow.proto, FlowProtocol::Udp) {
|
||||
match_flow_entry(flow, &entries, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let (entry, confidence) = match matched {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
return Ok(FlowOwnerResult {
|
||||
owner: None,
|
||||
confidence: FlowOwnerConfidence::None,
|
||||
failure_reason: Some("no socket match".to_string()),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let owner = inode_map.get(&entry.inode).map(|info| FlowOwner {
|
||||
pid: Some(info.pid),
|
||||
ppid: info.ppid,
|
||||
process_name: info.name.clone(),
|
||||
process_path: info.path.clone(),
|
||||
});
|
||||
|
||||
if owner.is_none() {
|
||||
return Ok(FlowOwnerResult {
|
||||
owner: None,
|
||||
confidence: FlowOwnerConfidence::Low,
|
||||
failure_reason: Some("socket owner not found".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(FlowOwnerResult {
|
||||
owner,
|
||||
confidence,
|
||||
failure_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||
let certs = rustls_native_certs::load_native_certs()
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
@@ -626,3 +776,10 @@ impl NeighProvider for LinuxNeighProvider {
|
||||
Ok(parse_linux_arp(&contents))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FlowOwnerProvider for LinuxFlowOwnerProvider {
|
||||
async fn owner_of(&self, flow: FlowTuple) -> Result<FlowOwnerResult, PlatformError> {
|
||||
resolve_flow_owner(&flow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use sha2::Digest;
|
||||
use x509_parser::oid_registry::{
|
||||
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
||||
@@ -10,7 +11,8 @@ use x509_parser::oid_registry::{
|
||||
use std::sync::Arc;
|
||||
use wtfnet_core::ErrorCode;
|
||||
use wtfnet_platform::{
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider,
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, FlowOwner, FlowOwnerConfidence, FlowOwnerProvider,
|
||||
FlowOwnerResult, FlowProtocol, FlowTuple, ListenSocket, NeighborEntry, NeighProvider,
|
||||
NetInterface, Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
};
|
||||
|
||||
@@ -20,6 +22,7 @@ pub fn platform() -> Platform {
|
||||
ports: Arc::new(WindowsPortsProvider),
|
||||
cert: Arc::new(WindowsCertProvider),
|
||||
neigh: Arc::new(WindowsNeighProvider),
|
||||
flow_owner: Arc::new(WindowsFlowOwnerProvider),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +30,7 @@ struct WindowsSysProvider;
|
||||
struct WindowsPortsProvider;
|
||||
struct WindowsCertProvider;
|
||||
struct WindowsNeighProvider;
|
||||
struct WindowsFlowOwnerProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl SysProvider for WindowsSysProvider {
|
||||
@@ -579,6 +583,155 @@ fn parse_csv_line(line: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FlowEntry {
|
||||
proto: FlowProtocol,
|
||||
local: SocketAddr,
|
||||
remote: Option<SocketAddr>,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
fn parse_netstat_flow_entries() -> Result<Vec<FlowEntry>, PlatformError> {
|
||||
let output = std::process::Command::new("netstat")
|
||||
.arg("-ano")
|
||||
.output()
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
if !output.status.success() {
|
||||
return Err(PlatformError::new(ErrorCode::IoError, "netstat -ano failed"));
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("TCP") {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let state = parts[3];
|
||||
if state == "LISTENING" {
|
||||
continue;
|
||||
}
|
||||
let local = match parse_netstat_addr(parts[1]) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let remote = match parse_netstat_addr(parts[2]) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let pid = match parts[4].parse::<u32>() {
|
||||
Ok(pid) => pid,
|
||||
Err(_) => continue,
|
||||
};
|
||||
entries.push(FlowEntry {
|
||||
proto: FlowProtocol::Tcp,
|
||||
local,
|
||||
remote: Some(remote),
|
||||
pid,
|
||||
});
|
||||
} else if trimmed.starts_with("UDP") {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let local = match parse_netstat_addr(parts[1]) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let pid = match parts[3].parse::<u32>() {
|
||||
Ok(pid) => pid,
|
||||
Err(_) => continue,
|
||||
};
|
||||
entries.push(FlowEntry {
|
||||
proto: FlowProtocol::Udp,
|
||||
local,
|
||||
remote: None,
|
||||
pid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_netstat_addr(value: &str) -> Option<SocketAddr> {
|
||||
let value = value.trim();
|
||||
if value == "*:*" {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix('[') {
|
||||
let end = rest.find(']')?;
|
||||
let host = &rest[..end];
|
||||
let port = rest[end + 2..].parse::<u16>().ok()?;
|
||||
let host = host.split('%').next().unwrap_or(host);
|
||||
let ip: IpAddr = host.parse().ok()?;
|
||||
return Some(SocketAddr::new(ip, port));
|
||||
}
|
||||
let pos = value.rfind(':')?;
|
||||
let host = &value[..pos];
|
||||
let port = value[pos + 1..].parse::<u16>().ok()?;
|
||||
let ip: IpAddr = host.parse().ok()?;
|
||||
Some(SocketAddr::new(ip, port))
|
||||
}
|
||||
|
||||
fn resolve_flow_owner(flow: &FlowTuple) -> Result<FlowOwnerResult, PlatformError> {
|
||||
let entries = parse_netstat_flow_entries()?;
|
||||
let proc_map = load_windows_process_map();
|
||||
|
||||
let mut matched: Option<(u32, FlowOwnerConfidence)> = None;
|
||||
for entry in entries {
|
||||
if entry.proto != flow.proto {
|
||||
continue;
|
||||
}
|
||||
let local_match = entry.local.ip() == flow.src_ip && entry.local.port() == flow.src_port;
|
||||
if !local_match {
|
||||
continue;
|
||||
}
|
||||
match flow.proto {
|
||||
FlowProtocol::Tcp => {
|
||||
if let Some(remote) = entry.remote {
|
||||
if remote.ip() == flow.dst_ip && remote.port() == flow.dst_port {
|
||||
matched = Some((entry.pid, FlowOwnerConfidence::High));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
FlowProtocol::Udp => {
|
||||
matched = Some((entry.pid, FlowOwnerConfidence::Medium));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (pid, confidence) = match matched {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
return Ok(FlowOwnerResult {
|
||||
owner: None,
|
||||
confidence: FlowOwnerConfidence::None,
|
||||
failure_reason: Some("no socket match".to_string()),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let info = proc_map.get(&pid);
|
||||
let owner = Some(FlowOwner {
|
||||
pid: Some(pid),
|
||||
ppid: None,
|
||||
process_name: info.and_then(|value| value.name.clone()),
|
||||
process_path: info.and_then(|value| value.path.clone()),
|
||||
});
|
||||
|
||||
Ok(FlowOwnerResult {
|
||||
owner,
|
||||
confidence,
|
||||
failure_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||
let certs = rustls_native_certs::load_native_certs()
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
@@ -696,3 +849,10 @@ impl NeighProvider for WindowsNeighProvider {
|
||||
Ok(parse_arp_output(&text))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FlowOwnerProvider for WindowsFlowOwnerProvider {
|
||||
async fn owner_of(&self, flow: FlowTuple) -> Result<FlowOwnerResult, PlatformError> {
|
||||
resolve_flow_owner(&flow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use wtfnet_core::ErrorCode;
|
||||
|
||||
@@ -80,6 +81,46 @@ pub struct NeighborEntry {
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlowProtocol {
|
||||
Udp,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlowOwnerConfidence {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowOwner {
|
||||
pub pid: Option<u32>,
|
||||
pub ppid: Option<u32>,
|
||||
pub process_name: Option<String>,
|
||||
pub process_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowOwnerResult {
|
||||
pub owner: Option<FlowOwner>,
|
||||
pub confidence: FlowOwnerConfidence,
|
||||
pub failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowTuple {
|
||||
pub proto: FlowProtocol,
|
||||
pub src_ip: IpAddr,
|
||||
pub src_port: u16,
|
||||
pub dst_ip: IpAddr,
|
||||
pub dst_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformError {
|
||||
pub code: ErrorCode,
|
||||
@@ -123,9 +164,15 @@ pub trait NeighProvider: Send + Sync {
|
||||
async fn neighbors(&self) -> Result<Vec<NeighborEntry>, PlatformError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FlowOwnerProvider: Send + Sync {
|
||||
async fn owner_of(&self, flow: FlowTuple) -> Result<FlowOwnerResult, PlatformError>;
|
||||
}
|
||||
|
||||
pub struct Platform {
|
||||
pub sys: Arc<dyn SysProvider>,
|
||||
pub ports: Arc<dyn PortsProvider>,
|
||||
pub cert: Arc<dyn CertProvider>,
|
||||
pub neigh: Arc<dyn NeighProvider>,
|
||||
pub flow_owner: Arc<dyn FlowOwnerProvider>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user