rust-mc-status 3.0.0

High-performance asynchronous Rust library for querying Minecraft server status (Java & Bedrock)
Documentation
//! Async DNS and SRV resolution with an in-process LRU cache.
//!
//! [`DnsResolver`] wraps a process-global `hickory` resolver and two
//! per-client LRU caches:
//!
//! - **DNS cache** — maps `"host:port"` to a resolved [`SocketAddr`].
//! - **SRV cache** — maps hostnames to `_minecraft._tcp.<host>` SRV targets.
//!
//! Both caches have a 5-minute TTL and use LRU eviction.  Multiple clones of
//! `DnsResolver` share the same underlying `Arc<RwLock<LruCache>>` — there is
//! only one cache per `McClient` regardless of how many tasks use it.

use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use hickory_resolver::{
    config::{ResolverConfig, ResolverOpts},
    net::runtime::TokioRuntimeProvider,
    Resolver,
};
use lru::LruCache;
use tokio::sync::{OnceCell, RwLock};
use tokio::time::timeout;

use crate::error::McError;
use crate::models::DnsInfo;

/// DNS cache TTL — entries older than this are treated as expired on next access.
const DNS_CACHE_TTL: Duration    = Duration::from_secs(300);
/// Default LRU capacity for both DNS and SRV caches.
const DEFAULT_CACHE_SIZE: usize  = 1024;

/// Process-global `hickory` resolver.  Initialised once on first use.
static RESOLVER: OnceCell<Arc<Resolver<TokioRuntimeProvider>>> = OnceCell::const_new();

async fn global_resolver() -> Arc<Resolver<TokioRuntimeProvider>> {
    RESOLVER
        .get_or_init(|| async {
            let mut opts = ResolverOpts::default();
            // Keep positive results cached for at least 60 s regardless of TTL.
            opts.cache_size       = 1000;
            opts.positive_min_ttl = Some(Duration::from_secs(60));
            // Suppress NXDOMAIN flooding by caching negative results for 10 s.
            opts.negative_min_ttl = Some(Duration::from_secs(10));
            Arc::new(
                Resolver::builder_with_config(
                    ResolverConfig::default(),
                    TokioRuntimeProvider::default(),
                )
                .with_options(opts)
                .build()
                .expect("failed to build DNS resolver"),
            )
        })
        .await
        .clone()
}

// ─── DnsResolver ─────────────────────────────────────────────────────────────

/// Per-client DNS and SRV cache.
///
/// Cheaply `Clone`-able — all clones share the same underlying `Arc` caches.
/// Constructed by [`McClientBuilder`](crate::McClientBuilder) via
/// `DnsResolver::new()` or `DnsResolver::with_cache_size(n)`.
#[derive(Clone)]
pub struct DnsResolver {
    /// `"host:port"` → `(SocketAddr, timestamp)`
    dns_cache: Arc<RwLock<LruCache<String, (SocketAddr, SystemTime)>>>,
    /// `"hostname"` → `((srv_host, srv_port), timestamp)`
    srv_cache: Arc<RwLock<LruCache<String, ((String, u16), SystemTime)>>>,
}

impl DnsResolver {
    /// Create a resolver with the default LRU capacity (1024 entries per cache).
    pub fn new() -> Self {
        Self::with_cache_size(DEFAULT_CACHE_SIZE)
    }

    /// Create a resolver with a custom LRU capacity.
    ///
    /// The same capacity is applied to both the DNS and SRV caches.
    ///
    /// # Panics
    ///
    /// Panics if `size` is 0.
    pub fn with_cache_size(size: usize) -> Self {
        let cap = NonZeroUsize::new(size).expect("DNS cache size must be > 0");
        Self {
            dns_cache: Arc::new(RwLock::new(LruCache::new(cap))),
            srv_cache: Arc::new(RwLock::new(LruCache::new(cap))),
        }
    }

    /// Clear both the DNS and SRV caches.
    pub async fn clear(&self) {
        self.dns_cache.write().await.clear();
        self.srv_cache.write().await.clear();
    }

    /// Number of entries currently in the DNS cache (including expired ones).
    pub async fn dns_len(&self) -> usize { self.dns_cache.read().await.len() }

    /// Number of entries currently in the SRV cache (including expired ones).
    pub async fn srv_len(&self) -> usize { self.srv_cache.read().await.len() }

    /// Look up the `_minecraft._tcp.<host>` SRV record.
    ///
    /// Returns `Some((target_host, port))` on success, `None` on any failure
    /// (DNS error, no SRV record, timeout) so callers can transparently fall
    /// back to a plain A/AAAA lookup.
    ///
    /// Results are cached for [`DNS_CACHE_TTL`] (5 minutes).
    pub async fn lookup_srv(
        &self,
        host: &str,
        tout: Duration,
    ) -> Option<(String, u16)> {
        // Fast path — check SRV cache before going to the network
        {
            let cache = self.srv_cache.read().await;
            if let Some(((h, p), ts)) = cache.peek(host)
                && ts.elapsed().unwrap_or(Duration::MAX) < DNS_CACHE_TTL {
                    return Some((h.clone(), *p));
                }
        }

        let name     = format!("_minecraft._tcp.{}", host);
        let resolver = global_resolver().await;
        let res      = timeout(tout, resolver.srv_lookup(&name))
            .await.ok()?.ok()?;

        let record = res.answers().iter().find_map(|r| {
            if let hickory_resolver::proto::rr::RData::SRV(s) = &r.data {
                Some((
                    s.target.to_string().trim_end_matches('.').to_string(),
                    s.port,
                ))
            } else {
                None
            }
        })?;

        // Cache the result — skip if the key was inserted concurrently
        let mut cache = self.srv_cache.write().await;
        if !cache.contains(host) {
            cache.put(host.to_string(), (record.clone(), SystemTime::now()));
        }
        Some(record)
    }

    /// Resolve `host:port` to a [`SocketAddr`].
    ///
    /// Prefers IPv4 addresses when both A and AAAA records are returned.
    /// Results are cached for [`DNS_CACHE_TTL`] (5 minutes).
    ///
    /// Resolution is performed in a `spawn_blocking` task because
    /// `std::net::ToSocketAddrs` is a blocking call.
    ///
    /// # Errors
    ///
    /// Returns [`McError::Network(Dns)`](crate::error::NetworkError::Dns) on
    /// resolution failure or when no addresses are returned.
    pub async fn resolve(
        &self,
        host: &str,
        port: u16,
    ) -> Result<(SocketAddr, DnsInfo), McError> {
        let key = format!("{}:{}", host, port);

        // Fast path — check DNS cache
        {
            let cache = self.dns_cache.read().await;
            if let Some((addr, ts)) = cache.peek(&key)
                && ts.elapsed().unwrap_or(Duration::MAX) < DNS_CACHE_TTL {
                    return Ok((*addr, make_dns_info(&[*addr])));
                }
        }

        // Slow path — blocking DNS resolution
        let host_port = format!("{}:{}", host, port);
        let addrs: Vec<SocketAddr> = tokio::task::spawn_blocking(move || {
            std::net::ToSocketAddrs::to_socket_addrs(&host_port).map(|i| i.collect())
        })
        .await
        .map_err(|e| McError::dns(e.to_string()))?
        .map_err(|e| McError::dns(e.to_string()))?;

        let addr = addrs
            .iter()
            .find(|a| a.is_ipv4())          // prefer IPv4
            .or_else(|| addrs.first())        // fall back to IPv6
            .copied()
            .ok_or_else(|| McError::dns("no addresses resolved"))?;

        let info = make_dns_info(&addrs);

        // Cache the result — skip if inserted concurrently
        let mut cache = self.dns_cache.write().await;
        if !cache.contains(&key) {
            cache.put(key, (addr, SystemTime::now()));
        }
        Ok((addr, info))
    }
}

impl Default for DnsResolver {
    fn default() -> Self { Self::new() }
}

fn make_dns_info(addrs: &[SocketAddr]) -> DnsInfo {
    DnsInfo {
        a_records: addrs.iter().map(|a| a.ip().to_string()).collect(),
        cname:     None,
        ttl:       DNS_CACHE_TTL.as_secs() as u32,
    }
}