941 lines
29 KiB
Rust
941 lines
29 KiB
Rust
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<Self, Self::Err> {
|
|
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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub server_name: Option<String>,
|
|
pub proxy: Option<String>,
|
|
pub rcode: String,
|
|
pub answers: Vec<DnsAnswer>,
|
|
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<DnsDetectEvidence>,
|
|
pub results: Vec<DnsQueryReport>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DnsWatchOptions {
|
|
pub iface: Option<String>,
|
|
pub duration_ms: u64,
|
|
pub filter: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub is_response: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DnsWatchReport {
|
|
pub iface: Option<String>,
|
|
pub duration_ms: u64,
|
|
pub filter: Option<String>,
|
|
pub events: Vec<DnsWatchEvent>,
|
|
}
|
|
|
|
pub async fn query(
|
|
domain: &str,
|
|
record_type: &str,
|
|
server: Option<DnsServerTarget>,
|
|
transport: DnsTransport,
|
|
proxy: Option<String>,
|
|
timeout_ms: u64,
|
|
) -> Result<DnsQueryReport, DnsError> {
|
|
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<String>,
|
|
repeat: u32,
|
|
timeout_ms: u64,
|
|
) -> Result<DnsDetectResult, DnsError> {
|
|
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<DnsServerTarget> {
|
|
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<DnsWatchReport, DnsError> {
|
|
Err(DnsError::NotSupported(
|
|
"dns watch requires pcap feature".to_string(),
|
|
))
|
|
}
|
|
|
|
#[cfg(feature = "pcap")]
|
|
pub async fn watch(options: DnsWatchOptions) -> Result<DnsWatchReport, DnsError> {
|
|
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<DnsServerTarget>,
|
|
transport: DnsTransport,
|
|
timeout_ms: u64,
|
|
) -> Result<TokioAsyncResolver, DnsError> {
|
|
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<DnsQueryReport, DnsError> {
|
|
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<DnsQueryReport, DnsError> {
|
|
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::<Vec<u8>, 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<TlsConnector, DnsError> {
|
|
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<Socks5Proxy, DnsError> {
|
|
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<NetworkInterface> {
|
|
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<String>,
|
|
) -> Option<DnsWatchEvent> {
|
|
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<String>,
|
|
) -> Option<DnsWatchEvent> {
|
|
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<String>,
|
|
) -> Option<DnsWatchEvent> {
|
|
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<String>,
|
|
) -> Option<DnsWatchEvent> {
|
|
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::<Vec<_>>();
|
|
|
|
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<RecordType, DnsError> {
|
|
value
|
|
.parse::<RecordType>()
|
|
.map_err(|_| DnsError::RecordType(value.to_string()))
|
|
}
|
|
|
|
fn format_rdata(data: &RData) -> Option<String> {
|
|
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::<Vec<_>>()
|
|
.join(" "),
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn evaluate_detect(
|
|
domain: &str,
|
|
results: &[DnsQueryReport],
|
|
evidence: &mut Vec<DnsDetectEvidence>,
|
|
) -> 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::<IpAddr>() {
|
|
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()
|
|
}
|
|
}
|
|
}
|