Finish verion 0.1.0

This commit is contained in:
DaZuo0122
2026-01-16 13:27:07 +08:00
parent 240107e00f
commit b63bcd405b
17 changed files with 4788 additions and 26 deletions

View File

@@ -0,0 +1,16 @@
[package]
name = "wtfnet-dns"
version = "0.1.0"
edition = "2024"
[dependencies]
hickory-resolver = { version = "0.24", features = ["dns-over-tls", "dns-over-https", "dns-over-https-rustls", "dns-over-rustls", "native-certs"] }
hickory-proto = "0.24"
reqwest = { version = "0.11", features = ["rustls-tls", "socks"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["time"] }
pnet = { version = "0.34", optional = true }
[features]
pcap = ["dep:pnet"]

View File

@@ -0,0 +1,736 @@
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 serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::time::{Duration, Instant};
use thiserror::Error;
#[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)?;
if let Some(proxy) = proxy {
if transport != DnsTransport::Doh {
return Err(DnsError::ProxyUnsupported(transport.to_string()));
}
let server = server.ok_or_else(|| DnsError::MissingServer(transport.to_string()))?;
return doh_query_via_proxy(domain, record_type, server, timeout_ms, proxy).await;
}
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> {
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> {
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) {
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> {
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,
})
}
#[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()
}
}
}