sipx_transport/dns.rs
1//! A real DNS client behind the RFC 3263 [`Resolver`] trait.
2//!
3//! [`crate::resolve()`] implements every selection rule and knows nothing about DNS; this is the
4//! part that actually asks. The split is what lets the selection logic — where the bugs are —
5//! be tested against fixtures with no network.
6//!
7//! Two things here are not just plumbing.
8//!
9//! **A failure is not an empty answer.** "There is no such record" is a final answer and the
10//! next candidate should be tried; "the resolver did not respond" is a transient condition and
11//! retrying later is right. Conflating them turns a thirty-second DNS blip into a permanent
12//! routing failure, because the negative gets cached and nothing ever asks again.
13//!
14//! **Nothing here may block the endpoint loop.** A lookup can take seconds; the loop it would
15//! block owns every transaction timer.
16
17use std::net::{IpAddr, SocketAddr};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use hickory_resolver::TokioResolver;
22use hickory_resolver::config::{
23 ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig, ResolverOpts,
24};
25use hickory_resolver::net::runtime::TokioRuntimeProvider;
26use hickory_resolver::proto::rr::rdata::{NAPTR, SRV};
27use hickory_resolver::proto::rr::{RData, RecordType};
28use tokio::sync::Mutex;
29
30use crate::resolve::{Naptr, Resolver, Srv};
31
32/// What a lookup produced.
33///
34/// The distinction between "no records" and "could not ask" is the whole reason this is not
35/// just `Vec<T>`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Answer<T> {
38 /// The server answered, with these records — possibly none.
39 Records(Vec<T>),
40 /// The question could not be asked or answered. Not a statement about the name.
41 Unavailable,
42}
43
44impl<T> Answer<T> {
45 /// The records, treating an unavailable server as empty.
46 ///
47 /// Used at the boundary where the [`Resolver`] trait cannot express the difference. Named
48 /// rather than implicit, so the loss of information is visible at the call site.
49 pub fn or_empty(self) -> Vec<T> {
50 match self {
51 Self::Records(records) => records,
52 Self::Unavailable => Vec::new(),
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
58struct Cached<T> {
59 records: Vec<T>,
60 expires: Instant,
61}
62
63/// A DNS-backed resolver with a TTL-respecting cache.
64#[derive(Debug)]
65pub struct DnsResolver {
66 inner: TokioResolver,
67 naptr: Mutex<std::collections::HashMap<String, Cached<Naptr>>>,
68 srv: Mutex<std::collections::HashMap<String, Cached<Srv>>>,
69 addresses: Mutex<std::collections::HashMap<String, Cached<IpAddr>>>,
70 addresses_v6: Mutex<std::collections::HashMap<String, Cached<IpAddr>>>,
71 /// Never cache for longer than this, however generous the TTL.
72 max_ttl: Duration,
73}
74
75impl DnsResolver {
76 /// A resolver using the system's configured nameservers.
77 pub fn from_system() -> std::io::Result<Self> {
78 let builder = TokioResolver::builder_tokio()
79 .map_err(|error| std::io::Error::other(error.to_string()))?;
80 let resolver = builder
81 .build()
82 .map_err(|error| std::io::Error::other(error.to_string()))?;
83 Ok(Self::with(resolver))
84 }
85
86 /// A resolver pointed at specific nameservers.
87 ///
88 /// This is what makes the tests possible: they run a fixture DNS server on localhost and
89 /// point sipx at it, rather than asking the public internet and hoping.
90 pub fn with_config(config: ResolverConfig, options: ResolverOpts) -> std::io::Result<Self> {
91 let mut builder =
92 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
93 *builder.options_mut() = options;
94 let resolver = builder
95 .build()
96 .map_err(|error| std::io::Error::other(error.to_string()))?;
97 Ok(Self::with(resolver))
98 }
99
100 fn with(inner: TokioResolver) -> Self {
101 Self {
102 inner,
103 naptr: Mutex::new(std::collections::HashMap::new()),
104 srv: Mutex::new(std::collections::HashMap::new()),
105 addresses: Mutex::new(std::collections::HashMap::new()),
106 addresses_v6: Mutex::new(std::collections::HashMap::new()),
107 max_ttl: Duration::from_secs(3600),
108 }
109 }
110
111 /// A resolver pointed at one nameserver.
112 ///
113 /// Wraps the DNS client's own configuration types so they appear in exactly one place. They
114 /// change shape between releases — this file has been through one such change already —
115 /// and a caller of sipx should not have to track that to point a resolver somewhere.
116 pub fn for_nameserver(server: SocketAddr, timeout: Duration) -> std::io::Result<Self> {
117 let mut connection = ConnectionConfig::new(ProtocolConfig::Udp);
118 connection.port = server.port();
119
120 let name_server = NameServerConfig::new(server.ip(), true, vec![connection]);
121 let config = ResolverConfig::from_parts(None, Vec::new(), vec![name_server]);
122
123 let mut options = ResolverOpts::default();
124 options.timeout = timeout;
125 // The fixture zone in the tests is authoritative for names that do not exist anywhere
126 // else, and a hosts file entry would silently win over it.
127 options.use_hosts_file = hickory_resolver::config::ResolveHosts::Never;
128 // The client has a response cache of its own. Two caches with different TTL policies
129 // is a source of confusion rather than of speed — sipx's exists to cap TTLs and to
130 // distinguish "no such record" from "could not ask", and neither of those survives a
131 // layer underneath doing its own thing. One cache, in one place.
132 options.cache_size = 0;
133
134 Self::with_config(config, options)
135 }
136
137 /// Cap how long any record is held, however long its TTL claims.
138 #[must_use]
139 pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
140 self.max_ttl = max_ttl;
141 self
142 }
143
144 /// NAPTR records for a domain.
145 pub async fn naptr(&self, domain: &str) -> Answer<Naptr> {
146 self.lookup(
147 &self.naptr,
148 domain,
149 RecordType::NAPTR,
150 |record| match &record.data {
151 RData::NAPTR(naptr) => Some(convert_naptr(naptr)),
152 _ => None,
153 },
154 )
155 .await
156 }
157
158 /// SRV records for a name.
159 pub async fn srv(&self, name: &str) -> Answer<Srv> {
160 self.lookup(&self.srv, name, RecordType::SRV, |record| {
161 match &record.data {
162 RData::SRV(srv) => Some(convert_srv(srv)),
163 _ => None,
164 }
165 })
166 .await
167 }
168
169 /// Addresses for a host.
170 ///
171 /// Both families, because RFC 3263 does not distinguish them and a host with only an AAAA
172 /// record is reachable.
173 pub async fn addresses(&self, host: &str) -> Answer<IpAddr> {
174 let v4 = self
175 .lookup(
176 &self.addresses,
177 host,
178 RecordType::A,
179 |record| match &record.data {
180 RData::A(a) => Some(IpAddr::V4(a.0)),
181 _ => None,
182 },
183 )
184 .await;
185
186 // A host with no A record may still have AAAA; asking only for A would make it
187 // unreachable for a reason nothing reports.
188 match v4 {
189 Answer::Records(records) if !records.is_empty() => Answer::Records(records),
190 other => {
191 let v6 = self
192 .lookup(
193 &self.addresses_v6,
194 host,
195 RecordType::AAAA,
196 |record| match &record.data {
197 RData::AAAA(aaaa) => Some(IpAddr::V6(aaaa.0)),
198 _ => None,
199 },
200 )
201 .await;
202 match (other, v6) {
203 // Only report "the server answered, with nothing" if both did.
204 (Answer::Records(_), Answer::Records(records)) => Answer::Records(records),
205 (_, Answer::Records(records)) if !records.is_empty() => {
206 Answer::Records(records)
207 }
208 _ => Answer::Unavailable,
209 }
210 }
211 }
212 }
213
214 /// One cached lookup, shared by all three record types.
215 ///
216 /// **Concurrent callers asking for the same name make one query**, which is what a forwarding
217 /// element needs: it resolves for every call it forwards, and a burst to one domain arrives
218 /// together, so without coalescing one cache miss becomes one query per concurrent call — every
219 /// one of them missing the cache because none has finished yet.
220 ///
221 /// That coalescing is **not implemented here**. `hickory-resolver` already does it, and a
222 /// single-flight layer written on top of it was measured to change nothing: eight concurrent
223 /// lookups reach the fixture nameserver exactly once with or without it, so the layer was
224 /// removed rather than kept as decoration. The property is load-bearing all the same, so
225 /// `two_concurrent_resolutions_of_one_name_make_one_query` pins it — if the client is ever
226 /// swapped or configured differently, that test is what notices.
227 async fn lookup<T: Clone>(
228 &self,
229 cache: &Mutex<std::collections::HashMap<String, Cached<T>>>,
230 name: &str,
231 record_type: RecordType,
232 extract: impl Fn(&hickory_resolver::proto::rr::Record) -> Option<T>,
233 ) -> Answer<T> {
234 if let Some(records) = cached(cache, name).await {
235 return Answer::Records(records);
236 }
237
238 let lookup = match self.inner.lookup(name, record_type).await {
239 Ok(lookup) => lookup,
240 Err(error) => {
241 let answer = classify(&error);
242 // RFC 2308: a *genuine* negative answer is cacheable, for the TTL the zone's SOA
243 // states. Caching it is the difference between one query per absent name and one
244 // per call to it — a domain with no `_sips._tcp` record is asked about on every
245 // single call otherwise, which is what a forwarding element does thousands of
246 // times a minute.
247 //
248 // `Unavailable` is deliberately *not* cached. It means nothing answered, and
249 // remembering a network blip as a routing decision would keep a domain
250 // unreachable long after it came back.
251 if matches!(answer, Answer::Records(_)) {
252 store(cache, name, &[], negative_ttl(&error, self.max_ttl)).await;
253 }
254 return answer;
255 }
256 };
257
258 let answers = lookup.answers();
259 let ttl = shortest_ttl(answers.iter().map(|record| record.ttl), self.max_ttl);
260 let records: Vec<T> = answers.iter().filter_map(&extract).collect();
261 store(cache, name, &records, ttl).await;
262 Answer::Records(records)
263 }
264}
265
266/// Whether an error means "no such record" or "could not ask".
267///
268/// Harder than it should be. The client reports an unreachable nameserver as `NoRecordsFound`
269/// with response code `NXDomain` — the same shape as a real negative answer — so the error kind
270/// alone cannot tell them apart.
271///
272/// The signal that does distinguish them is RFC 2308's: a genuine negative answer carries the
273/// zone's SOA record, because that is what tells a resolver how long to cache the negative.
274/// A synthesised one has no SOA and no negative TTL, because no server ever said anything.
275///
276/// This errs toward `Unavailable`: a real negative answer from a server that omits the SOA is
277/// treated as "could not ask", so sipx retries instead of falling through. That is the milder
278/// error — retrying a name that genuinely does not exist costs a lookup, while caching a
279/// network blip as a routing decision costs every call to that domain until something evicts
280/// it.
281fn classify<T>(error: &hickory_resolver::net::NetError) -> Answer<T> {
282 use hickory_resolver::net::{DnsError, NetError};
283
284 if let NetError::Dns(DnsError::NoRecordsFound(no_records)) = error {
285 let answered = no_records.soa.is_some() || no_records.negative_ttl.is_some();
286 return if answered {
287 Answer::Records(Vec::new())
288 } else {
289 Answer::Unavailable
290 };
291 }
292 Answer::Unavailable
293}
294
295/// How long a negative answer may be remembered (RFC 2308 §5).
296///
297/// The SOA's TTL is what the zone says about its own absences. Capped the same way a positive
298/// answer is, and floored at nothing — a zone that says zero means "ask every time", and obeying
299/// that is cheaper than arguing with it.
300fn negative_ttl(error: &hickory_resolver::net::NetError, max: Duration) -> Duration {
301 use hickory_resolver::net::{DnsError, NetError};
302 if let NetError::Dns(DnsError::NoRecordsFound(no_records)) = error
303 && let Some(ttl) = no_records.negative_ttl
304 {
305 return Duration::from_secs(u64::from(ttl)).min(max);
306 }
307 // A negative answer whose SOA carried no explicit TTL. Short, because guessing long about an
308 // absence is how a record that has just been created stays invisible.
309 Duration::from_secs(30).min(max)
310}
311
312/// The shortest TTL in a set, which is how long the whole set may be held.
313///
314/// The shortest rather than the longest: holding a record past its TTL is the failure mode
315/// that matters, because it points traffic at a server that has moved.
316fn shortest_ttl(ttls: impl Iterator<Item = u32>, max: Duration) -> Duration {
317 ttls.min()
318 .map_or(max, |ttl| Duration::from_secs(u64::from(ttl)).min(max))
319}
320
321async fn cached<T: Clone>(
322 map: &Mutex<std::collections::HashMap<String, Cached<T>>>,
323 key: &str,
324) -> Option<Vec<T>> {
325 let guard = map.lock().await;
326 let entry = guard.get(key)?;
327 // An expired entry is not returned, and is not preferred over asking again.
328 (entry.expires > Instant::now()).then(|| entry.records.clone())
329}
330
331async fn store<T: Clone>(
332 map: &Mutex<std::collections::HashMap<String, Cached<T>>>,
333 key: &str,
334 records: &[T],
335 ttl: Duration,
336) {
337 map.lock().await.insert(
338 key.to_owned(),
339 Cached {
340 records: records.to_vec(),
341 expires: Instant::now() + ttl,
342 },
343 );
344}
345
346fn convert_naptr(naptr: &NAPTR) -> Naptr {
347 Naptr {
348 order: naptr.order,
349 preference: naptr.preference,
350 service: String::from_utf8_lossy(&naptr.services).into_owned(),
351 replacement: strip_root(&naptr.replacement.to_string()),
352 }
353}
354
355fn convert_srv(srv: &SRV) -> Srv {
356 Srv {
357 priority: srv.priority,
358 weight: srv.weight,
359 port: srv.port,
360 target: strip_root(&srv.target.to_string()),
361 }
362}
363
364/// DNS names are absolute and end in a dot; the rest of sipx works in the relative form the
365/// URI used. Leaving the dot on turns `_sip._udp.example.com.` into a name no fixture matches.
366fn strip_root(name: &str) -> String {
367 name.strip_suffix('.').unwrap_or(name).to_owned()
368}
369
370/// Blocking adapter so [`DnsResolver`] can satisfy the synchronous [`Resolver`] trait.
371///
372/// The trait is synchronous because RFC 3263 selection is pure computation over records. Doing
373/// the lookups up front and handing over the results keeps it that way — and keeps every
374/// await off the endpoint loop, where a slow resolver would stop the transaction timers.
375#[derive(Debug, Clone)]
376pub struct Prefetched {
377 naptr: Vec<Naptr>,
378 srv: std::collections::HashMap<String, Vec<Srv>>,
379 addresses: std::collections::HashMap<String, Vec<IpAddr>>,
380}
381
382impl Prefetched {
383 /// Ask for everything RFC 3263 could need for one URI, then hand the answers to the
384 /// selection logic.
385 pub async fn for_domain(resolver: &Arc<DnsResolver>, domain: &str) -> Self {
386 let naptr = resolver.naptr(domain).await.or_empty();
387
388 // Every SRV name the NAPTR records point at, plus the conventional ones, since a
389 // domain with no NAPTR may still have SRV.
390 let mut srv_names: Vec<String> = naptr.iter().map(|n| n.replacement.clone()).collect();
391 // Every prefix RFC 3263 §4.1 and RFC 7118 §6 define, not only the three a phone uses. A
392 // WebSocket destination whose prefix is missing here is not unreachable — it just pays a
393 // serial lookup later, one round trip at a time, which is exactly what prefetching exists
394 // to avoid.
395 for prefix in [
396 "_sip._udp.",
397 "_sip._tcp.",
398 "_sips._tcp.",
399 "_sip._ws.",
400 "_sips._wss.",
401 ] {
402 srv_names.push(format!("{prefix}{domain}"));
403 }
404 srv_names.sort_unstable();
405 srv_names.dedup();
406
407 let mut srv = std::collections::HashMap::new();
408 let mut hosts = vec![domain.to_owned()];
409 for name in srv_names {
410 let records = resolver.srv(&name).await.or_empty();
411 hosts.extend(records.iter().map(|r| r.target.clone()));
412 srv.insert(name, records);
413 }
414
415 hosts.sort_unstable();
416 hosts.dedup();
417 let mut addresses = std::collections::HashMap::new();
418 for host in hosts {
419 let found = resolver.addresses(&host).await.or_empty();
420 addresses.insert(host, found);
421 }
422
423 Self {
424 naptr,
425 srv,
426 addresses,
427 }
428 }
429}
430
431/// Resolve a URI to an ordered candidate list, in one await (RFC 3263).
432///
433/// The two-step form — prefetch, then select — is what the endpoint uses, because it keeps every
434/// await off the loop where a slow nameserver would stop the transaction timers. This is the same
435/// thing for a caller that is not the loop: a forwarding element deciding where to send one
436/// request, which wants a list and does not want to know that resolution has two halves.
437///
438/// The selection is unchanged and still pure. Only the waiting is here.
439pub async fn resolve_uri<G: crate::resolve::Rng + ?Sized>(
440 uri: &sipx_sip::Uri,
441 resolver: &Arc<DnsResolver>,
442 rng: &mut G,
443) -> Vec<crate::Target> {
444 let Some(domain) = uri.host().map(ToString::to_string) else {
445 return Vec::new();
446 };
447 let prefetched = Prefetched::for_domain(resolver, &domain).await;
448 crate::resolve::resolve(uri, &prefetched, rng)
449}
450
451impl Resolver for Prefetched {
452 fn naptr(&self, _domain: &str) -> Vec<Naptr> {
453 self.naptr.clone()
454 }
455
456 fn srv(&self, name: &str) -> Vec<Srv> {
457 self.srv.get(name).cloned().unwrap_or_default()
458 }
459
460 fn addresses(&self, host: &str) -> Vec<IpAddr> {
461 self.addresses.get(host).cloned().unwrap_or_default()
462 }
463}
464
465#[cfg(test)]
466#[allow(
467 clippy::unwrap_used,
468 clippy::expect_used,
469 clippy::panic,
470 clippy::indexing_slicing
471)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn the_root_dot_is_stripped_from_dns_names() {
477 assert_eq!(
478 strip_root("_sip._udp.example.com."),
479 "_sip._udp.example.com"
480 );
481 assert_eq!(strip_root("example.com"), "example.com");
482 assert_eq!(strip_root(""), "");
483 }
484
485 /// The shortest TTL governs the set: holding a record past its TTL points traffic at a
486 /// server that has moved, which is the failure that matters.
487 #[test]
488 fn the_shortest_ttl_governs_the_set() {
489 let max = Duration::from_secs(3600);
490 assert_eq!(
491 shortest_ttl([300u32, 60, 900].into_iter(), max),
492 Duration::from_secs(60)
493 );
494 assert_eq!(
495 shortest_ttl([7200u32].into_iter(), max),
496 max,
497 "a generous TTL is still capped"
498 );
499 assert_eq!(
500 shortest_ttl(std::iter::empty(), max),
501 max,
502 "no records means the cap"
503 );
504 }
505
506 /// The distinction this module exists for, at the type level: an unavailable server is not
507 /// an empty answer, and collapsing the two has to be explicit.
508 #[test]
509 fn an_unavailable_server_is_not_an_empty_answer() {
510 let empty: Answer<Srv> = Answer::Records(Vec::new());
511 let down: Answer<Srv> = Answer::Unavailable;
512 assert_ne!(empty, down);
513 assert!(
514 down.or_empty().is_empty(),
515 "collapsing is possible, but named"
516 );
517 }
518
519 #[tokio::test]
520 async fn a_resolver_that_cannot_reach_a_server_reports_unavailable_not_empty() {
521 // A nameserver on a port nothing listens on, with a short timeout.
522 let resolver = DnsResolver::for_nameserver(
523 "127.0.0.1:9".parse().expect("valid"),
524 Duration::from_millis(200),
525 )
526 .expect("builds");
527 assert_eq!(
528 resolver.srv("_sip._udp.example.invalid").await,
529 Answer::Unavailable,
530 "a dead nameserver must not look like 'no such record'"
531 );
532 }
533
534 /// A cached entry that has expired is not preferred over asking again.
535 #[tokio::test]
536 async fn an_expired_entry_is_not_returned() {
537 let map: Mutex<std::collections::HashMap<String, Cached<Srv>>> =
538 Mutex::new(std::collections::HashMap::new());
539 let record = Srv {
540 priority: 1,
541 weight: 0,
542 port: 5060,
543 target: "host.example".to_owned(),
544 };
545
546 // Two stores, because the two halves of this test want opposite things from the clock
547 // (`X-29`). The read below is a *precondition* — it proves the entry was stored at all,
548 // so the expiry half cannot pass by having stored nothing — and it must not race the
549 // TTL. On 2026-07-29 it did: with a 50 ms TTL and three worktrees compiling, the entry
550 // expired before this immediate read, and a gate for a diff that had never opened this
551 // crate came back red. A minute is a bound on failure, not a window to measure in.
552 store(
553 &map,
554 "name",
555 std::slice::from_ref(&record),
556 Duration::from_secs(60),
557 )
558 .await;
559 assert_eq!(cached(&map, "name").await, Some(vec![record.clone()]));
560
561 // The expiry is then a real one — the same `Instant` comparison against a TTL that
562 // genuinely elapses — but it is waited *for* rather than slept past. Load can only
563 // lengthen the wait, and the deadline turns "never expires" into a failure that says so
564 // rather than into a flake.
565 store(
566 &map,
567 "name",
568 std::slice::from_ref(&record),
569 Duration::from_millis(50),
570 )
571 .await;
572 let deadline = Instant::now() + Duration::from_secs(10);
573 while cached(&map, "name").await.is_some() {
574 assert!(
575 Instant::now() < deadline,
576 "an entry with a 50 ms TTL never expired"
577 );
578 tokio::time::sleep(Duration::from_millis(5)).await;
579 }
580
581 assert_eq!(
582 cached(&map, "name").await,
583 None,
584 "an expired entry must be re-asked, not served"
585 );
586 }
587
588 #[tokio::test]
589 async fn a_fresh_entry_is_served_from_cache() {
590 let map: Mutex<std::collections::HashMap<String, Cached<IpAddr>>> =
591 Mutex::new(std::collections::HashMap::new());
592 let address: IpAddr = "192.0.2.1".parse().expect("valid");
593 store(&map, "host", &[address], Duration::from_secs(60)).await;
594 assert_eq!(cached(&map, "host").await, Some(vec![address]));
595 }
596}