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