mod classify; mod policy; mod privacy; mod report; mod route; mod rules; mod sensor; use crate::classify::ClassifiedEvent; use crate::sensor::{capture_events, SensorEvent, TcpEvent}; 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, pub policy: LeakPolicy, pub privacy: PrivacyMode, pub include_events: bool, } pub async fn watch( options: LeakWatchOptions, flow_owner: Option<&dyn FlowOwnerProvider>, ) -> Result { 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(); let mut dns_cache: std::collections::HashMap = std::collections::HashMap::new(); for event in events { match event { SensorEvent::Dns(event) => { let enriched = enrich_event(event, flow_owner).await; if enriched.is_response { update_dns_cache(&mut dns_cache, &enriched); continue; } if let Some(decision) = rules::evaluate(&enriched, &options.policy) { let mut leak_event = report::LeakEvent::from_decision(enriched, decision); privacy::apply_privacy(&mut leak_event, options.privacy); leak_events.push(leak_event); } } SensorEvent::Tcp(event) => { if let Some(leak_event) = evaluate_mismatch(event, flow_owner, &mut dns_cache, options.privacy).await { leak_events.push(leak_event); } } } } let summary = LeakSummary::from_events(&leak_events); let report = LeakReport { 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 } struct DnsCacheEntry { qname: String, route_class: RouteClass, timestamp_ms: u128, } const DNS_CACHE_TTL_MS: u128 = 60_000; fn update_dns_cache(cache: &mut std::collections::HashMap, event: &report::EnrichedEvent) { let Some(qname) = event.qname.as_ref() else { return }; let now = event.timestamp_ms; prune_dns_cache(cache, now); for ip in event.answer_ips.iter() { debug!( "dns leak cache insert ip={} qname={} route={:?}", ip, qname, event.route_class ); cache.insert( *ip, DnsCacheEntry { qname: qname.clone(), route_class: event.route_class, timestamp_ms: now, }, ); } } fn prune_dns_cache( cache: &mut std::collections::HashMap, now_ms: u128, ) { cache.retain(|_, entry| now_ms.saturating_sub(entry.timestamp_ms) <= DNS_CACHE_TTL_MS); } async fn evaluate_mismatch( event: TcpEvent, flow_owner: Option<&dyn FlowOwnerProvider>, cache: &mut std::collections::HashMap, privacy: PrivacyMode, ) -> Option { prune_dns_cache(cache, event.timestamp_ms); debug!( "dns leak tcp syn dst_ip={} dst_port={} cache_size={}", event.dst_ip, event.dst_port, cache.len() ); let entry = cache.get(&event.dst_ip)?; let tcp_route = route::route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref()); if tcp_route == entry.route_class { debug!( "dns leak mismatch skip dst_ip={} tcp_route={:?} dns_route={:?}", event.dst_ip, tcp_route, entry.route_class ); return None; } let mut enriched = report::EnrichedEvent { timestamp_ms: event.timestamp_ms, proto: wtfnet_platform::FlowProtocol::Tcp, src_ip: event.src_ip, src_port: event.src_port, dst_ip: event.dst_ip, dst_port: event.dst_port, iface_name: event.iface_name.clone(), transport: LeakTransport::Unknown, qname: Some(entry.qname.clone()), qtype: None, rcode: None, is_response: false, answer_ips: Vec::new(), route_class: tcp_route, owner: None, owner_confidence: wtfnet_platform::FlowOwnerConfidence::None, owner_failure: None, }; if let Some(provider) = flow_owner { let flow = FlowTuple { proto: wtfnet_platform::FlowProtocol::Tcp, src_ip: event.src_ip, src_port: event.src_port, dst_ip: event.dst_ip, dst_port: event.dst_port, }; if let Ok(result) = provider.owner_of(flow).await { enriched.owner = result.owner; enriched.owner_confidence = result.confidence; enriched.owner_failure = result.failure_reason; } } let decision = rules::LeakDecision { leak_type: report::LeakType::D, severity: Severity::P2, policy_rule_id: "LEAK_D_MISMATCH".to_string(), }; let mut leak_event = report::LeakEvent::from_decision(enriched, decision); privacy::apply_privacy(&mut leak_event, privacy); Some(leak_event) }