use hickory_resolver::config::{ NameServerConfig, NameServerConfigGroup, Protocol, ResolverConfig, ResolverOpts, }; use hickory_resolver::error::ResolveErrorKind; use hickory_resolver::proto::rr::{RData, RecordType}; use hickory_resolver::TokioAsyncResolver; use hickory_resolver::system_conf::read_system_conf; use hickory_proto::op::{Message, MessageType, Query}; use hickory_proto::rr::Name; use reqwest::Proxy; use rustls::{Certificate, ClientConfig, RootCertStore, ServerName}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use thiserror::Error; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_rustls::TlsConnector; use tokio_socks::tcp::Socks5Stream; use tracing::debug; use url::Url; #[cfg(feature = "pcap")] use pnet::datalink::{self, Channel, Config as DatalinkConfig, NetworkInterface}; #[cfg(feature = "pcap")] use pnet::packet::ethernet::{EtherTypes, EthernetPacket}; #[cfg(feature = "pcap")] use pnet::packet::ip::IpNextHeaderProtocols; #[cfg(feature = "pcap")] use pnet::packet::ipv4::Ipv4Packet; #[cfg(feature = "pcap")] use pnet::packet::ipv6::Ipv6Packet; #[cfg(feature = "pcap")] use pnet::packet::udp::UdpPacket; #[cfg(feature = "pcap")] use pnet::packet::Packet; #[derive(Debug, Error)] pub enum DnsError { #[error("invalid record type: {0}")] RecordType(String), #[error("resolver error: {0}")] Resolver(String), #[error("io error: {0}")] Io(String), #[error("missing tls server name for {0}")] MissingTlsName(String), #[error("missing server for transport {0}")] MissingServer(String), #[error("proxy only supported for {0}")] ProxyUnsupported(String), #[error("proxy error: {0}")] Proxy(String), #[error("not supported: {0}")] NotSupported(String), } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DnsTransport { Udp, Tcp, Dot, Doh, } impl std::fmt::Display for DnsTransport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let value = match self { DnsTransport::Udp => "udp", DnsTransport::Tcp => "tcp", DnsTransport::Dot => "dot", DnsTransport::Doh => "doh", }; f.write_str(value) } } impl FromStr for DnsTransport { type Err = DnsError; fn from_str(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "udp" => Ok(DnsTransport::Udp), "tcp" => Ok(DnsTransport::Tcp), "dot" | "tls" => Ok(DnsTransport::Dot), "doh" | "https" => Ok(DnsTransport::Doh), _ => Err(DnsError::Resolver(format!( "invalid transport: {value}" ))), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsServerTarget { pub addr: SocketAddr, pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsAnswer { pub name: String, pub record_type: String, pub ttl: u32, pub data: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsQueryReport { pub domain: String, pub record_type: String, pub transport: String, pub server: Option, pub server_name: Option, pub proxy: Option, pub rcode: String, pub answers: Vec, pub duration_ms: u128, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsDetectEvidence { pub code: String, pub message: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsDetectResult { pub verdict: String, pub evidence: Vec, pub results: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsWatchOptions { pub iface: Option, pub duration_ms: u64, pub filter: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsWatchEvent { pub timestamp_ms: u128, pub src: String, pub dst: String, pub query_name: String, pub query_type: String, pub rcode: String, pub answers: Vec, pub is_response: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsWatchReport { pub iface: Option, pub duration_ms: u64, pub filter: Option, pub events: Vec, } pub async fn query( domain: &str, record_type: &str, server: Option, transport: DnsTransport, proxy: Option, timeout_ms: u64, ) -> Result { let record_type = parse_record_type(record_type)?; debug!( domain, record_type = %record_type, transport = %transport, server = ?server.as_ref().map(|value| value.addr), proxy = ?proxy.as_deref(), timeout_ms, "dns query start" ); if let Some(proxy) = proxy { let server = server.ok_or_else(|| DnsError::MissingServer(transport.to_string()))?; return match transport { DnsTransport::Doh => { doh_query_via_proxy(domain, record_type, server, timeout_ms, proxy).await } DnsTransport::Dot => { dot_query_via_proxy(domain, record_type, server, timeout_ms, proxy).await } _ => Err(DnsError::ProxyUnsupported(transport.to_string())), }; } let resolver = build_resolver(server.clone(), transport, timeout_ms)?; let start = Instant::now(); let response = resolver.lookup(domain, record_type).await; let duration_ms = start.elapsed().as_millis(); match response { Ok(lookup) => { let mut answers = Vec::new(); for record in lookup.record_iter() { let ttl = record.ttl(); let name = record.name().to_string(); let record_type = record.record_type().to_string(); if let Some(data) = record.data() { if let Some(data) = format_rdata(data) { answers.push(DnsAnswer { name, record_type, ttl, data, }); } } } Ok(DnsQueryReport { domain: domain.to_string(), record_type: record_type.to_string(), transport: transport.to_string(), server: server.as_ref().map(|value| value.addr.to_string()), server_name: server.as_ref().and_then(|value| value.name.clone()), proxy: None, rcode: "NOERROR".to_string(), answers, duration_ms, }) } Err(err) => { if let ResolveErrorKind::NoRecordsFound { response_code, .. } = err.kind() { Ok(DnsQueryReport { domain: domain.to_string(), record_type: record_type.to_string(), transport: transport.to_string(), server: server.as_ref().map(|value| value.addr.to_string()), server_name: server.as_ref().and_then(|value| value.name.clone()), proxy: None, rcode: response_code.to_string(), answers: Vec::new(), duration_ms, }) } else { Err(DnsError::Resolver(err.to_string())) } } } } pub async fn detect( domain: &str, servers: &[DnsServerTarget], transport: DnsTransport, proxy: Option, repeat: u32, timeout_ms: u64, ) -> Result { debug!( domain, transport = %transport, servers = servers.len(), proxy = ?proxy.as_deref(), repeat, timeout_ms, "dns detect start" ); let mut results = Vec::new(); for server in servers { for _ in 0..repeat.max(1) { let report = query( domain, "A", Some(server.clone()), transport, proxy.clone(), timeout_ms, ) .await?; results.push(report); } } let mut evidence = Vec::new(); let verdict = evaluate_detect(domain, &results, &mut evidence); Ok(DnsDetectResult { verdict, evidence, results, }) } pub fn default_detect_servers(transport: DnsTransport) -> Vec { let (port, names) = match transport { DnsTransport::Udp | DnsTransport::Tcp => (53, [None, None, None]), DnsTransport::Dot => ( 853, [ Some("cloudflare-dns.com"), Some("dns.google"), Some("dns.quad9.net"), ], ), DnsTransport::Doh => ( 443, [ Some("cloudflare-dns.com"), Some("dns.google"), Some("dns.quad9.net"), ], ), }; let ips = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]; ips.iter() .zip(names.iter()) .map(|(ip, name)| DnsServerTarget { addr: SocketAddr::new(ip.parse().unwrap(), port), name: name.map(|value| value.to_string()), }) .collect() } #[cfg(not(feature = "pcap"))] pub async fn watch(_options: DnsWatchOptions) -> Result { Err(DnsError::NotSupported( "dns watch requires pcap feature".to_string(), )) } #[cfg(feature = "pcap")] pub async fn watch(options: DnsWatchOptions) -> Result { debug!( iface = ?options.iface, duration_ms = options.duration_ms, filter = ?options.filter, "dns watch start" ); let iface = match select_interface(options.iface.as_deref()) { Some(value) => value, None => { return Err(DnsError::Resolver( "no suitable interface found".to_string(), )) } }; let mut config = DatalinkConfig::default(); config.read_timeout = Some(Duration::from_millis(500)); let (_, mut rx) = match datalink::channel(&iface, config) { Ok(Channel::Ethernet(tx, rx)) => (tx, rx), Ok(_) => { return Err(DnsError::Resolver( "unsupported datalink channel".to_string(), )) } Err(err) => return Err(DnsError::Resolver(err.to_string())), }; let start = Instant::now(); let deadline = start + Duration::from_millis(options.duration_ms); let filter = options.filter.as_ref().map(|value| value.to_ascii_lowercase()); let mut events = Vec::new(); while Instant::now() < deadline { match rx.next() { Ok(frame) => { if let Some(event) = parse_dns_frame(frame, start, &filter) { debug!( src = %event.src, dst = %event.dst, query_name = %event.query_name, query_type = %event.query_type, rcode = %event.rcode, is_response = event.is_response, "dns watch event" ); events.push(event); } } Err(_) => continue, } } Ok(DnsWatchReport { iface: Some(iface.name), duration_ms: options.duration_ms, filter: options.filter, events, }) } fn build_resolver( server: Option, transport: DnsTransport, timeout_ms: u64, ) -> Result { let mut opts = ResolverOpts::default(); opts.timeout = Duration::from_millis(timeout_ms); if let Some(server) = server { let protocol = match transport { DnsTransport::Udp => Protocol::Udp, DnsTransport::Tcp => Protocol::Tcp, DnsTransport::Dot => Protocol::Tls, DnsTransport::Doh => Protocol::Https, }; let tls_dns_name = match transport { DnsTransport::Dot | DnsTransport::Doh => server .name .clone() .ok_or_else(|| DnsError::MissingTlsName(transport.to_string()))?, _ => String::new(), }; let mut group = NameServerConfigGroup::new(); group.push(NameServerConfig { socket_addr: server.addr, protocol, tls_dns_name: match transport { DnsTransport::Dot | DnsTransport::Doh => Some(tls_dns_name), _ => None, }, trust_negative_responses: true, tls_config: None, bind_addr: None, }); let config = ResolverConfig::from_parts(None, vec![], group); Ok(TokioAsyncResolver::tokio(config, opts)) } else { match transport { DnsTransport::Udp => { let (config, mut sys_opts) = read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?; sys_opts.timeout = opts.timeout; Ok(TokioAsyncResolver::tokio(config, sys_opts)) } DnsTransport::Tcp => { let (config, mut sys_opts) = read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?; sys_opts.timeout = opts.timeout; let mut group = NameServerConfigGroup::new(); for entry in config.name_servers() { group.push(NameServerConfig { socket_addr: entry.socket_addr, protocol: Protocol::Tcp, tls_dns_name: None, trust_negative_responses: entry.trust_negative_responses, tls_config: None, bind_addr: entry.bind_addr, }); } let config = ResolverConfig::from_parts( config.domain().cloned(), config.search().to_vec(), group, ); Ok(TokioAsyncResolver::tokio(config, sys_opts)) } DnsTransport::Dot | DnsTransport::Doh => { Err(DnsError::MissingServer(transport.to_string())) } } } } async fn doh_query_via_proxy( domain: &str, record_type: RecordType, server: DnsServerTarget, timeout_ms: u64, proxy: String, ) -> Result { debug!( domain, record_type = %record_type, server = %server.addr, proxy = %proxy, "dns doh via proxy" ); let tls_name = server .name .clone() .ok_or_else(|| DnsError::MissingTlsName("doh".to_string()))?; let name = Name::from_ascii(domain) .map_err(|err| DnsError::Resolver(format!("invalid domain: {err}")))?; let mut message = Message::new(); message .set_id(0) .set_message_type(MessageType::Query) .set_recursion_desired(true) .add_query(Query::query(name, record_type)); let body = message .to_vec() .map_err(|err| DnsError::Resolver(err.to_string()))?; let url = format!("https://{tls_name}/dns-query"); let proxy_value = proxy.clone(); let proxy = Proxy::all(&proxy).map_err(|err| DnsError::Proxy(err.to_string()))?; let mut builder = reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) .proxy(proxy); let server_addr = SocketAddr::new(server.addr.ip(), server.addr.port()); builder = builder.resolve(&tls_name, server_addr); let client = builder .build() .map_err(|err| DnsError::Resolver(err.to_string()))?; let start = Instant::now(); let response = client .post(url) .header("content-type", "application/dns-message") .header("accept", "application/dns-message") .body(body) .send() .await .map_err(|err| DnsError::Resolver(err.to_string()))?; let status = response.status(); if !status.is_success() { return Err(DnsError::Resolver(format!( "doh status: {}", status.as_u16() ))); } let bytes = response .bytes() .await .map_err(|err| DnsError::Resolver(err.to_string()))?; let response = Message::from_vec(&bytes).map_err(|err| DnsError::Resolver(err.to_string()))?; let duration_ms = start.elapsed().as_millis(); let mut answers = Vec::new(); for record in response.answers() { let ttl = record.ttl(); let name = record.name().to_string(); let record_type = record.record_type().to_string(); if let Some(data) = record.data() { if let Some(data) = format_rdata(data) { answers.push(DnsAnswer { name, record_type, ttl, data, }); } } } Ok(DnsQueryReport { domain: domain.to_string(), record_type: record_type.to_string(), transport: DnsTransport::Doh.to_string(), server: Some(server.addr.to_string()), server_name: Some(tls_name), proxy: Some(proxy_value), rcode: response.response_code().to_string(), answers, duration_ms, }) } async fn dot_query_via_proxy( domain: &str, record_type: RecordType, server: DnsServerTarget, timeout_ms: u64, proxy: String, ) -> Result { debug!( domain, record_type = %record_type, server = %server.addr, proxy = %proxy, "dns dot via proxy" ); let tls_name = server .name .clone() .ok_or_else(|| DnsError::MissingTlsName("dot".to_string()))?; let name = Name::from_ascii(domain) .map_err(|err| DnsError::Resolver(format!("invalid domain: {err}")))?; let mut message = Message::new(); message .set_id(0) .set_message_type(MessageType::Query) .set_recursion_desired(true) .add_query(Query::query(name, record_type)); let body = message .to_vec() .map_err(|err| DnsError::Resolver(err.to_string()))?; if body.len() > u16::MAX as usize { return Err(DnsError::Resolver("dns message too large".to_string())); } let connector = build_tls_connector()?; let proxy_config = parse_socks5_proxy(&proxy)?; let target = if proxy_config.remote_dns { (tls_name.clone(), server.addr.port()) } else { (server.addr.ip().to_string(), server.addr.port()) }; let timeout = Duration::from_millis(timeout_ms); let tcp = tokio::time::timeout( timeout, Socks5Stream::connect(proxy_config.addr.as_str(), target), ) .await .map_err(|_| DnsError::Resolver("timeout".to_string()))? .map_err(|err| DnsError::Proxy(err.to_string()))? .into_inner(); let server_name = ServerName::try_from(tls_name.as_str()) .map_err(|_| DnsError::MissingTlsName(tls_name.clone()))?; let mut stream = tokio::time::timeout(timeout, connector.connect(server_name, tcp)) .await .map_err(|_| DnsError::Resolver("timeout".to_string()))? .map_err(|err| DnsError::Resolver(err.to_string()))?; let start = Instant::now(); let response_bytes = tokio::time::timeout(timeout, async { let length = (body.len() as u16).to_be_bytes(); stream.write_all(&length).await?; stream.write_all(&body).await?; stream.flush().await?; let mut len_buf = [0u8; 2]; stream.read_exact(&mut len_buf).await?; let response_len = u16::from_be_bytes(len_buf) as usize; let mut response = vec![0u8; response_len]; stream.read_exact(&mut response).await?; Ok::, std::io::Error>(response) }) .await .map_err(|_| DnsError::Resolver("timeout".to_string()))? .map_err(|err| DnsError::Resolver(err.to_string()))?; let response = Message::from_vec(&response_bytes).map_err(|err| DnsError::Resolver(err.to_string()))?; let duration_ms = start.elapsed().as_millis(); let mut answers = Vec::new(); for record in response.answers() { let ttl = record.ttl(); let name = record.name().to_string(); let record_type = record.record_type().to_string(); if let Some(data) = record.data() { if let Some(data) = format_rdata(data) { answers.push(DnsAnswer { name, record_type, ttl, data, }); } } } Ok(DnsQueryReport { domain: domain.to_string(), record_type: record_type.to_string(), transport: DnsTransport::Dot.to_string(), server: Some(server.addr.to_string()), server_name: Some(tls_name), proxy: Some(proxy), rcode: response.response_code().to_string(), answers, duration_ms, }) } fn build_tls_connector() -> Result { let mut roots = RootCertStore::empty(); let store = rustls_native_certs::load_native_certs() .map_err(|err| DnsError::Io(err.to_string()))?; for cert in store { roots .add(&Certificate(cert.0)) .map_err(|err| DnsError::Resolver(err.to_string()))?; } let config = ClientConfig::builder() .with_safe_defaults() .with_root_certificates(roots) .with_no_client_auth(); Ok(TlsConnector::from(Arc::new(config))) } struct Socks5Proxy { addr: String, remote_dns: bool, } fn parse_socks5_proxy(value: &str) -> Result { let url = Url::parse(value).map_err(|_| DnsError::Proxy(value.to_string()))?; let scheme = url.scheme(); let remote_dns = match scheme { "socks5" => false, "socks5h" => true, _ => return Err(DnsError::ProxyUnsupported(scheme.to_string())), }; if !url.username().is_empty() || url.password().is_some() { return Err(DnsError::Proxy("proxy auth not supported".to_string())); } let host = url .host_str() .ok_or_else(|| DnsError::Proxy(value.to_string()))?; let port = url .port_or_known_default() .ok_or_else(|| DnsError::Proxy(value.to_string()))?; Ok(Socks5Proxy { addr: format!("{host}:{port}"), remote_dns, }) } #[cfg(feature = "pcap")] fn select_interface(name: Option<&str>) -> Option { let interfaces = datalink::interfaces(); if let Some(name) = name { return interfaces.into_iter().find(|iface| iface.name == name); } interfaces .into_iter() .find(|iface| iface.is_up() && !iface.is_loopback()) } #[cfg(feature = "pcap")] fn parse_dns_frame( frame: &[u8], start: Instant, filter: &Option, ) -> Option { let ethernet = EthernetPacket::new(frame)?; match ethernet.get_ethertype() { EtherTypes::Ipv4 => parse_ipv4(ethernet.payload(), start, filter), EtherTypes::Ipv6 => parse_ipv6(ethernet.payload(), start, filter), _ => None, } } #[cfg(feature = "pcap")] fn parse_ipv4( payload: &[u8], start: Instant, filter: &Option, ) -> Option { let ipv4 = Ipv4Packet::new(payload)?; if ipv4.get_next_level_protocol() != IpNextHeaderProtocols::Udp { return None; } let udp = UdpPacket::new(ipv4.payload())?; parse_dns_packet( ipv4.get_source().into(), ipv4.get_destination().into(), &udp, start, filter, ) } #[cfg(feature = "pcap")] fn parse_ipv6( payload: &[u8], start: Instant, filter: &Option, ) -> Option { let ipv6 = Ipv6Packet::new(payload)?; if ipv6.get_next_header() != IpNextHeaderProtocols::Udp { return None; } let udp = UdpPacket::new(ipv6.payload())?; parse_dns_packet( ipv6.get_source().into(), ipv6.get_destination().into(), &udp, start, filter, ) } #[cfg(feature = "pcap")] fn parse_dns_packet( src: IpAddr, dst: IpAddr, udp: &UdpPacket<'_>, start: Instant, filter: &Option, ) -> Option { let src_port = udp.get_source(); let dst_port = udp.get_destination(); if src_port != 53 && dst_port != 53 { return None; } let message = Message::from_vec(udp.payload()).ok()?; let query = message.queries().first()?; let query_name = query.name().to_utf8(); if let Some(filter) = filter.as_ref() { if !query_name.to_ascii_lowercase().contains(filter) { return None; } } let query_type = query.query_type().to_string(); let rcode = message.response_code().to_string(); let answers = message .answers() .iter() .filter_map(|record| record.data().and_then(format_rdata)) .collect::>(); Some(DnsWatchEvent { timestamp_ms: start.elapsed().as_millis(), src: src.to_string(), dst: dst.to_string(), query_name, query_type, rcode, answers, is_response: message.message_type() == MessageType::Response, }) } fn parse_record_type(value: &str) -> Result { value .parse::() .map_err(|_| DnsError::RecordType(value.to_string())) } fn format_rdata(data: &RData) -> Option { match data { RData::A(addr) => Some(addr.to_string()), RData::AAAA(addr) => Some(addr.to_string()), RData::CNAME(name) => Some(name.to_string()), RData::NS(name) => Some(name.to_string()), RData::MX(mx) => Some(format!("{} {}", mx.preference(), mx.exchange())), RData::TXT(txt) => Some( txt.txt_data() .iter() .map(|part| String::from_utf8_lossy(part).to_string()) .collect::>() .join(" "), ), _ => None, } } fn evaluate_detect( domain: &str, results: &[DnsQueryReport], evidence: &mut Vec, ) -> String { if results.is_empty() { evidence.push(DnsDetectEvidence { code: "NO_RESULTS".to_string(), message: "no dns results returned".to_string(), }); return "inconclusive".to_string(); } let mut rcodes = BTreeSet::new(); let mut answer_sets = BTreeSet::new(); let mut ttl_values = Vec::new(); let mut private_hits = Vec::new(); for report in results { rcodes.insert(report.rcode.clone()); let mut answers = BTreeSet::new(); for answer in &report.answers { answers.insert(answer.data.clone()); if let Ok(ip) = answer.data.parse::() { if is_private_or_reserved(ip) { private_hits.push(ip.to_string()); } } ttl_values.push(answer.ttl); } answer_sets.insert(answers); } if rcodes.len() > 1 { evidence.push(DnsDetectEvidence { code: "RCODE_DIVERGENCE".to_string(), message: format!("rcodes differed for {}", domain), }); } if answer_sets.len() > 1 { evidence.push(DnsDetectEvidence { code: "ANSWER_DIVERGENCE".to_string(), message: format!("answers diverged for {}", domain), }); } if !private_hits.is_empty() { evidence.push(DnsDetectEvidence { code: "PRIVATE_RESULT".to_string(), message: format!("private/reserved answers: {}", private_hits.join(", ")), }); } let ttl_span = ttl_span(&ttl_values); if ttl_span > 3600 { evidence.push(DnsDetectEvidence { code: "TTL_VARIANCE".to_string(), message: format!("ttl variance high: {ttl_span}s"), }); } if evidence.is_empty() { "clean".to_string() } else if evidence.len() >= 2 { "suspicious".to_string() } else { "inconclusive".to_string() } } fn ttl_span(values: &[u32]) -> u32 { let min = values.iter().min().copied().unwrap_or(0); let max = values.iter().max().copied().unwrap_or(0); max.saturating_sub(min) } fn is_private_or_reserved(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => { v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_broadcast() || v4.is_documentation() } IpAddr::V6(v6) => { v6.is_loopback() || v6.is_unique_local() || v6.is_unspecified() } } }