Skip to main content

rust_mc_status/core/
dns.rs

1//! Async DNS and SRV resolution with an in-process LRU cache.
2//!
3//! [`DnsResolver`] wraps a process-global `hickory` resolver and two
4//! per-client LRU caches:
5//!
6//! - **DNS cache** — maps `"host:port"` to a resolved [`SocketAddr`].
7//! - **SRV cache** — maps hostnames to `_minecraft._tcp.<host>` SRV targets.
8//!
9//! Both caches have a 5-minute TTL and use LRU eviction.  Multiple clones of
10//! `DnsResolver` share the same underlying `Arc<RwLock<LruCache>>` — there is
11//! only one cache per `McClient` regardless of how many tasks use it.
12
13use std::net::SocketAddr;
14use std::num::NonZeroUsize;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime};
17
18use hickory_resolver::{
19    config::{ResolverConfig, ResolverOpts},
20    net::runtime::TokioRuntimeProvider,
21    Resolver,
22};
23use lru::LruCache;
24use tokio::sync::{OnceCell, RwLock};
25use tokio::time::timeout;
26
27use crate::error::McError;
28use crate::models::DnsInfo;
29
30/// DNS cache TTL — entries older than this are treated as expired on next access.
31const DNS_CACHE_TTL: Duration    = Duration::from_secs(300);
32/// Default LRU capacity for both DNS and SRV caches.
33const DEFAULT_CACHE_SIZE: usize  = 1024;
34
35/// Process-global `hickory` resolver.  Initialised once on first use.
36static RESOLVER: OnceCell<Arc<Resolver<TokioRuntimeProvider>>> = OnceCell::const_new();
37
38async fn global_resolver() -> Arc<Resolver<TokioRuntimeProvider>> {
39    RESOLVER
40        .get_or_init(|| async {
41            let mut opts = ResolverOpts::default();
42            // Keep positive results cached for at least 60 s regardless of TTL.
43            opts.cache_size       = 1000;
44            opts.positive_min_ttl = Some(Duration::from_secs(60));
45            // Suppress NXDOMAIN flooding by caching negative results for 10 s.
46            opts.negative_min_ttl = Some(Duration::from_secs(10));
47            Arc::new(
48                Resolver::builder_with_config(
49                    ResolverConfig::default(),
50                    TokioRuntimeProvider::default(),
51                )
52                .with_options(opts)
53                .build()
54                .expect("failed to build DNS resolver"),
55            )
56        })
57        .await
58        .clone()
59}
60
61// ─── DnsResolver ─────────────────────────────────────────────────────────────
62
63/// Per-client DNS and SRV cache.
64///
65/// Cheaply `Clone`-able — all clones share the same underlying `Arc` caches.
66/// Constructed by [`McClientBuilder`](crate::McClientBuilder) via
67/// `DnsResolver::new()` or `DnsResolver::with_cache_size(n)`.
68#[derive(Clone)]
69pub struct DnsResolver {
70    /// `"host:port"` → `(SocketAddr, timestamp)`
71    dns_cache: Arc<RwLock<LruCache<String, (SocketAddr, SystemTime)>>>,
72    /// `"hostname"` → `((srv_host, srv_port), timestamp)`
73    srv_cache: Arc<RwLock<LruCache<String, ((String, u16), SystemTime)>>>,
74}
75
76impl DnsResolver {
77    /// Create a resolver with the default LRU capacity (1024 entries per cache).
78    pub fn new() -> Self {
79        Self::with_cache_size(DEFAULT_CACHE_SIZE)
80    }
81
82    /// Create a resolver with a custom LRU capacity.
83    ///
84    /// The same capacity is applied to both the DNS and SRV caches.
85    ///
86    /// # Panics
87    ///
88    /// Panics if `size` is 0.
89    pub fn with_cache_size(size: usize) -> Self {
90        let cap = NonZeroUsize::new(size).expect("DNS cache size must be > 0");
91        Self {
92            dns_cache: Arc::new(RwLock::new(LruCache::new(cap))),
93            srv_cache: Arc::new(RwLock::new(LruCache::new(cap))),
94        }
95    }
96
97    /// Clear both the DNS and SRV caches.
98    pub async fn clear(&self) {
99        self.dns_cache.write().await.clear();
100        self.srv_cache.write().await.clear();
101    }
102
103    /// Number of entries currently in the DNS cache (including expired ones).
104    pub async fn dns_len(&self) -> usize { self.dns_cache.read().await.len() }
105
106    /// Number of entries currently in the SRV cache (including expired ones).
107    pub async fn srv_len(&self) -> usize { self.srv_cache.read().await.len() }
108
109    /// Look up the `_minecraft._tcp.<host>` SRV record.
110    ///
111    /// Returns `Some((target_host, port))` on success, `None` on any failure
112    /// (DNS error, no SRV record, timeout) so callers can transparently fall
113    /// back to a plain A/AAAA lookup.
114    ///
115    /// Results are cached for [`DNS_CACHE_TTL`] (5 minutes).
116    pub async fn lookup_srv(
117        &self,
118        host: &str,
119        tout: Duration,
120    ) -> Option<(String, u16)> {
121        // Fast path — check SRV cache before going to the network
122        {
123            let cache = self.srv_cache.read().await;
124            if let Some(((h, p), ts)) = cache.peek(host)
125                && ts.elapsed().unwrap_or(Duration::MAX) < DNS_CACHE_TTL {
126                    return Some((h.clone(), *p));
127                }
128        }
129
130        let name     = format!("_minecraft._tcp.{}", host);
131        let resolver = global_resolver().await;
132        let res      = timeout(tout, resolver.srv_lookup(&name))
133            .await.ok()?.ok()?;
134
135        let record = res.answers().iter().find_map(|r| {
136            if let hickory_resolver::proto::rr::RData::SRV(s) = &r.data {
137                Some((
138                    s.target.to_string().trim_end_matches('.').to_string(),
139                    s.port,
140                ))
141            } else {
142                None
143            }
144        })?;
145
146        // Cache the result — skip if the key was inserted concurrently
147        let mut cache = self.srv_cache.write().await;
148        if !cache.contains(host) {
149            cache.put(host.to_string(), (record.clone(), SystemTime::now()));
150        }
151        Some(record)
152    }
153
154    /// Resolve `host:port` to a [`SocketAddr`].
155    ///
156    /// Prefers IPv4 addresses when both A and AAAA records are returned.
157    /// Results are cached for [`DNS_CACHE_TTL`] (5 minutes).
158    ///
159    /// Resolution is performed in a `spawn_blocking` task because
160    /// `std::net::ToSocketAddrs` is a blocking call.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`McError::Network(Dns)`](crate::error::NetworkError::Dns) on
165    /// resolution failure or when no addresses are returned.
166    pub async fn resolve(
167        &self,
168        host: &str,
169        port: u16,
170    ) -> Result<(SocketAddr, DnsInfo), McError> {
171        let key = format!("{}:{}", host, port);
172
173        // Fast path — check DNS cache
174        {
175            let cache = self.dns_cache.read().await;
176            if let Some((addr, ts)) = cache.peek(&key)
177                && ts.elapsed().unwrap_or(Duration::MAX) < DNS_CACHE_TTL {
178                    return Ok((*addr, make_dns_info(&[*addr])));
179                }
180        }
181
182        // Slow path — blocking DNS resolution
183        let host_port = format!("{}:{}", host, port);
184        let addrs: Vec<SocketAddr> = tokio::task::spawn_blocking(move || {
185            std::net::ToSocketAddrs::to_socket_addrs(&host_port).map(|i| i.collect())
186        })
187        .await
188        .map_err(|e| McError::dns(e.to_string()))?
189        .map_err(|e| McError::dns(e.to_string()))?;
190
191        let addr = addrs
192            .iter()
193            .find(|a| a.is_ipv4())          // prefer IPv4
194            .or_else(|| addrs.first())        // fall back to IPv6
195            .copied()
196            .ok_or_else(|| McError::dns("no addresses resolved"))?;
197
198        let info = make_dns_info(&addrs);
199
200        // Cache the result — skip if inserted concurrently
201        let mut cache = self.dns_cache.write().await;
202        if !cache.contains(&key) {
203            cache.put(key, (addr, SystemTime::now()));
204        }
205        Ok((addr, info))
206    }
207}
208
209impl Default for DnsResolver {
210    fn default() -> Self { Self::new() }
211}
212
213fn make_dns_info(addrs: &[SocketAddr]) -> DnsInfo {
214    DnsInfo {
215        a_records: addrs.iter().map(|a| a.ip().to_string()).collect(),
216        cname:     None,
217        ttl:       DNS_CACHE_TTL.as_secs() as u32,
218    }
219}