use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use hickory_resolver::TokioResolver;
use hickory_resolver::config::{
ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig, ResolverOpts,
};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::proto::rr::rdata::{NAPTR, SRV};
use hickory_resolver::proto::rr::{RData, RecordType};
use tokio::sync::Mutex;
use crate::resolve::{Naptr, Resolver, Srv};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer<T> {
Records(Vec<T>),
Unavailable,
}
impl<T> Answer<T> {
pub fn or_empty(self) -> Vec<T> {
match self {
Self::Records(records) => records,
Self::Unavailable => Vec::new(),
}
}
}
#[derive(Debug, Clone)]
struct Cached<T> {
records: Vec<T>,
expires: Instant,
}
#[derive(Debug)]
pub struct DnsResolver {
inner: TokioResolver,
naptr: Mutex<std::collections::HashMap<String, Cached<Naptr>>>,
srv: Mutex<std::collections::HashMap<String, Cached<Srv>>>,
addresses: Mutex<std::collections::HashMap<String, Cached<IpAddr>>>,
addresses_v6: Mutex<std::collections::HashMap<String, Cached<IpAddr>>>,
max_ttl: Duration,
}
impl DnsResolver {
pub fn from_system() -> std::io::Result<Self> {
let builder = TokioResolver::builder_tokio()
.map_err(|error| std::io::Error::other(error.to_string()))?;
let resolver = builder
.build()
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(Self::with(resolver))
}
pub fn with_config(config: ResolverConfig, options: ResolverOpts) -> std::io::Result<Self> {
let mut builder =
TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
*builder.options_mut() = options;
let resolver = builder
.build()
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(Self::with(resolver))
}
fn with(inner: TokioResolver) -> Self {
Self {
inner,
naptr: Mutex::new(std::collections::HashMap::new()),
srv: Mutex::new(std::collections::HashMap::new()),
addresses: Mutex::new(std::collections::HashMap::new()),
addresses_v6: Mutex::new(std::collections::HashMap::new()),
max_ttl: Duration::from_secs(3600),
}
}
pub fn for_nameserver(server: SocketAddr, timeout: Duration) -> std::io::Result<Self> {
let mut connection = ConnectionConfig::new(ProtocolConfig::Udp);
connection.port = server.port();
let name_server = NameServerConfig::new(server.ip(), true, vec![connection]);
let config = ResolverConfig::from_parts(None, Vec::new(), vec![name_server]);
let mut options = ResolverOpts::default();
options.timeout = timeout;
options.use_hosts_file = hickory_resolver::config::ResolveHosts::Never;
options.cache_size = 0;
Self::with_config(config, options)
}
#[must_use]
pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
self.max_ttl = max_ttl;
self
}
pub async fn naptr(&self, domain: &str) -> Answer<Naptr> {
self.lookup(
&self.naptr,
domain,
RecordType::NAPTR,
|record| match &record.data {
RData::NAPTR(naptr) => Some(convert_naptr(naptr)),
_ => None,
},
)
.await
}
pub async fn srv(&self, name: &str) -> Answer<Srv> {
self.lookup(&self.srv, name, RecordType::SRV, |record| {
match &record.data {
RData::SRV(srv) => Some(convert_srv(srv)),
_ => None,
}
})
.await
}
pub async fn addresses(&self, host: &str) -> Answer<IpAddr> {
let v4 = self
.lookup(
&self.addresses,
host,
RecordType::A,
|record| match &record.data {
RData::A(a) => Some(IpAddr::V4(a.0)),
_ => None,
},
)
.await;
match v4 {
Answer::Records(records) if !records.is_empty() => Answer::Records(records),
other => {
let v6 = self
.lookup(
&self.addresses_v6,
host,
RecordType::AAAA,
|record| match &record.data {
RData::AAAA(aaaa) => Some(IpAddr::V6(aaaa.0)),
_ => None,
},
)
.await;
match (other, v6) {
(Answer::Records(_), Answer::Records(records)) => Answer::Records(records),
(_, Answer::Records(records)) if !records.is_empty() => {
Answer::Records(records)
}
_ => Answer::Unavailable,
}
}
}
}
async fn lookup<T: Clone>(
&self,
cache: &Mutex<std::collections::HashMap<String, Cached<T>>>,
name: &str,
record_type: RecordType,
extract: impl Fn(&hickory_resolver::proto::rr::Record) -> Option<T>,
) -> Answer<T> {
if let Some(records) = cached(cache, name).await {
return Answer::Records(records);
}
let lookup = match self.inner.lookup(name, record_type).await {
Ok(lookup) => lookup,
Err(error) => {
let answer = classify(&error);
if matches!(answer, Answer::Records(_)) {
store(cache, name, &[], negative_ttl(&error, self.max_ttl)).await;
}
return answer;
}
};
let answers = lookup.answers();
let ttl = shortest_ttl(answers.iter().map(|record| record.ttl), self.max_ttl);
let records: Vec<T> = answers.iter().filter_map(&extract).collect();
store(cache, name, &records, ttl).await;
Answer::Records(records)
}
}
fn classify<T>(error: &hickory_resolver::net::NetError) -> Answer<T> {
use hickory_resolver::net::{DnsError, NetError};
if let NetError::Dns(DnsError::NoRecordsFound(no_records)) = error {
let answered = no_records.soa.is_some() || no_records.negative_ttl.is_some();
return if answered {
Answer::Records(Vec::new())
} else {
Answer::Unavailable
};
}
Answer::Unavailable
}
fn negative_ttl(error: &hickory_resolver::net::NetError, max: Duration) -> Duration {
use hickory_resolver::net::{DnsError, NetError};
if let NetError::Dns(DnsError::NoRecordsFound(no_records)) = error
&& let Some(ttl) = no_records.negative_ttl
{
return Duration::from_secs(u64::from(ttl)).min(max);
}
Duration::from_secs(30).min(max)
}
fn shortest_ttl(ttls: impl Iterator<Item = u32>, max: Duration) -> Duration {
ttls.min()
.map_or(max, |ttl| Duration::from_secs(u64::from(ttl)).min(max))
}
async fn cached<T: Clone>(
map: &Mutex<std::collections::HashMap<String, Cached<T>>>,
key: &str,
) -> Option<Vec<T>> {
let guard = map.lock().await;
let entry = guard.get(key)?;
(entry.expires > Instant::now()).then(|| entry.records.clone())
}
async fn store<T: Clone>(
map: &Mutex<std::collections::HashMap<String, Cached<T>>>,
key: &str,
records: &[T],
ttl: Duration,
) {
map.lock().await.insert(
key.to_owned(),
Cached {
records: records.to_vec(),
expires: Instant::now() + ttl,
},
);
}
fn convert_naptr(naptr: &NAPTR) -> Naptr {
Naptr {
order: naptr.order,
preference: naptr.preference,
service: String::from_utf8_lossy(&naptr.services).into_owned(),
replacement: strip_root(&naptr.replacement.to_string()),
}
}
fn convert_srv(srv: &SRV) -> Srv {
Srv {
priority: srv.priority,
weight: srv.weight,
port: srv.port,
target: strip_root(&srv.target.to_string()),
}
}
fn strip_root(name: &str) -> String {
name.strip_suffix('.').unwrap_or(name).to_owned()
}
#[derive(Debug, Clone)]
pub struct Prefetched {
naptr: Vec<Naptr>,
srv: std::collections::HashMap<String, Vec<Srv>>,
addresses: std::collections::HashMap<String, Vec<IpAddr>>,
}
impl Prefetched {
pub async fn for_domain(resolver: &Arc<DnsResolver>, domain: &str) -> Self {
let naptr = resolver.naptr(domain).await.or_empty();
let mut srv_names: Vec<String> = naptr.iter().map(|n| n.replacement.clone()).collect();
for prefix in [
"_sip._udp.",
"_sip._tcp.",
"_sips._tcp.",
"_sip._ws.",
"_sips._wss.",
] {
srv_names.push(format!("{prefix}{domain}"));
}
srv_names.sort_unstable();
srv_names.dedup();
let mut srv = std::collections::HashMap::new();
let mut hosts = vec![domain.to_owned()];
for name in srv_names {
let records = resolver.srv(&name).await.or_empty();
hosts.extend(records.iter().map(|r| r.target.clone()));
srv.insert(name, records);
}
hosts.sort_unstable();
hosts.dedup();
let mut addresses = std::collections::HashMap::new();
for host in hosts {
let found = resolver.addresses(&host).await.or_empty();
addresses.insert(host, found);
}
Self {
naptr,
srv,
addresses,
}
}
}
pub async fn resolve_uri<G: crate::resolve::Rng + ?Sized>(
uri: &sipx_sip::Uri,
resolver: &Arc<DnsResolver>,
rng: &mut G,
) -> Vec<crate::Target> {
let Some(domain) = uri.host().map(ToString::to_string) else {
return Vec::new();
};
let prefetched = Prefetched::for_domain(resolver, &domain).await;
crate::resolve::resolve(uri, &prefetched, rng)
}
impl Resolver for Prefetched {
fn naptr(&self, _domain: &str) -> Vec<Naptr> {
self.naptr.clone()
}
fn srv(&self, name: &str) -> Vec<Srv> {
self.srv.get(name).cloned().unwrap_or_default()
}
fn addresses(&self, host: &str) -> Vec<IpAddr> {
self.addresses.get(host).cloned().unwrap_or_default()
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn the_root_dot_is_stripped_from_dns_names() {
assert_eq!(
strip_root("_sip._udp.example.com."),
"_sip._udp.example.com"
);
assert_eq!(strip_root("example.com"), "example.com");
assert_eq!(strip_root(""), "");
}
#[test]
fn the_shortest_ttl_governs_the_set() {
let max = Duration::from_secs(3600);
assert_eq!(
shortest_ttl([300u32, 60, 900].into_iter(), max),
Duration::from_secs(60)
);
assert_eq!(
shortest_ttl([7200u32].into_iter(), max),
max,
"a generous TTL is still capped"
);
assert_eq!(
shortest_ttl(std::iter::empty(), max),
max,
"no records means the cap"
);
}
#[test]
fn an_unavailable_server_is_not_an_empty_answer() {
let empty: Answer<Srv> = Answer::Records(Vec::new());
let down: Answer<Srv> = Answer::Unavailable;
assert_ne!(empty, down);
assert!(
down.or_empty().is_empty(),
"collapsing is possible, but named"
);
}
#[tokio::test]
async fn a_resolver_that_cannot_reach_a_server_reports_unavailable_not_empty() {
let resolver = DnsResolver::for_nameserver(
"127.0.0.1:9".parse().expect("valid"),
Duration::from_millis(200),
)
.expect("builds");
assert_eq!(
resolver.srv("_sip._udp.example.invalid").await,
Answer::Unavailable,
"a dead nameserver must not look like 'no such record'"
);
}
#[tokio::test]
async fn an_expired_entry_is_not_returned() {
let map: Mutex<std::collections::HashMap<String, Cached<Srv>>> =
Mutex::new(std::collections::HashMap::new());
let record = Srv {
priority: 1,
weight: 0,
port: 5060,
target: "host.example".to_owned(),
};
store(
&map,
"name",
std::slice::from_ref(&record),
Duration::from_secs(60),
)
.await;
assert_eq!(cached(&map, "name").await, Some(vec![record.clone()]));
store(
&map,
"name",
std::slice::from_ref(&record),
Duration::from_millis(50),
)
.await;
let deadline = Instant::now() + Duration::from_secs(10);
while cached(&map, "name").await.is_some() {
assert!(
Instant::now() < deadline,
"an entry with a 50 ms TTL never expired"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(
cached(&map, "name").await,
None,
"an expired entry must be re-asked, not served"
);
}
#[tokio::test]
async fn a_fresh_entry_is_served_from_cache() {
let map: Mutex<std::collections::HashMap<String, Cached<IpAddr>>> =
Mutex::new(std::collections::HashMap::new());
let address: IpAddr = "192.0.2.1".parse().expect("valid");
store(&map, "host", &[address], Duration::from_secs(60)).await;
assert_eq!(cached(&map, "host").await, Some(vec![address]));
}
}