Skip to main content

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}