//! Platform DNS resolver for the protocol layer. //! //! `tsclientlib`'s internal resolver uses `hickory-resolver`, which //! reads `/etc/resolv.conf`. That file does not exist on Android or //! iOS, so hostname connects fail there with a cryptic "connection //! task exited before signalling ready" error. //! //! This module bypasses that by resolving hostnames ourselves via //! `tokio::net::lookup_host`, which uses the platform's //! `getaddrinfo`. That works on every platform Chanora targets. //! //! A tiny positive-result cache (5 minute TTL) keeps reconnects //! cheap. Negative results are not cached: DNS failures are usually //! transient and the user typically retries within seconds. use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Mutex; use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use tracing::{debug, warn}; use crate::ProtocolError; /// Default TeamSpeak server UDP port. pub(crate) const DEFAULT_TS_PORT: u16 = 9987; /// Positive-result cache TTL. const CACHE_TTL: Duration = Duration::from_secs(5 * 60); struct CacheEntry { addrs: Vec, at: Instant, } static CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); /// Resolve `host_input` to a list of socket addresses, preferring /// IPv4 over IPv6 so the upstream's first connect attempt is the /// most likely to succeed on networks with brittle IPv6. /// /// `host_input` may be: /// * `hostname` (port defaults to 9987) /// * `hostname:port` /// * `ip` (port 9987) /// * `ip:port` /// * `[v6]:port` /// /// Returns at least one [`SocketAddr`] on success. Returns /// [`ProtocolError::DnsFailed`] on lookup failure or empty result. pub(crate) async fn resolve(host_input: &str) -> Result, ProtocolError> { let input = host_input.trim(); if input.is_empty() { return Err(ProtocolError::Invalid("address is empty".to_string())); } // Try literal SocketAddr first — short-circuit the cache for // numeric inputs since they cannot change. if let Ok(addr) = input.parse::() { return Ok(vec![addr]); } // Normalise to host:port for lookup_host. Accept bare hostname // (default port 9987) and bare IP literals. let lookup_key = if input.contains(':') { // Either host:port, [v6]:port, or a v6 literal without port. // The latter is a misuse — require brackets. input.to_string() } else { format!("{input}:{DEFAULT_TS_PORT}") }; // Cache lookup. if let Some(hit) = cache_get(&lookup_key) { debug!(target: "chanora_protocol", host = %lookup_key, "dns cache hit"); return Ok(hit); } // Cold lookup via the platform resolver. Collect into an owned // `Vec` inside an inner scope so the iterator's borrow on // `lookup_input` is fully released before we move the key into // either the cache or the error branch. let resolved: Vec = { let lookup_input: String = lookup_key.clone(); let collected = match tokio::net::lookup_host(lookup_input.as_str()).await { Ok(iter) => iter.collect::>(), Err(e) => { warn!(target: "chanora_protocol", host = %lookup_key, error = %e, "dns lookup failed"); return Err(ProtocolError::DnsFailed { host: lookup_key, reason: format!("{e}"), }); } }; collected }; if resolved.is_empty() { return Err(ProtocolError::DnsFailed { host: lookup_key, reason: "no addresses returned by platform resolver".to_string(), }); } // Prefer IPv4 first — keeps connect latency low on dual-stack // networks where IPv6 routing is sometimes broken. We don't // discard IPv6; it just sorts after. let mut ordered = resolved.clone(); ordered.sort_by_key(|a| match a { SocketAddr::V4(_) => 0u8, SocketAddr::V6(_) => 1u8, }); cache_put(lookup_key.clone(), ordered.clone()); debug!( target: "chanora_protocol", host = %lookup_key, count = ordered.len(), first = %ordered[0], "dns resolved" ); Ok(ordered) } fn cache_get(key: &str) -> Option> { let mut guard = CACHE.lock().ok()?; let entry = guard.get(key)?; if entry.at.elapsed() < CACHE_TTL { Some(entry.addrs.clone()) } else { guard.remove(key); None } } fn cache_put(key: String, addrs: Vec) { if let Ok(mut guard) = CACHE.lock() { guard.insert( key, CacheEntry { addrs, at: Instant::now(), }, ); } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn rejects_empty() { let r = resolve("").await; assert!(matches!(r, Err(ProtocolError::Invalid(_)))); } #[tokio::test] async fn literal_ipv4_short_circuits() { let r = resolve("127.0.0.1:9987").await.unwrap(); assert_eq!(r.len(), 1); assert_eq!(r[0].port(), 9987); assert!(r[0].is_ipv4()); } #[tokio::test] async fn literal_ipv4_default_port_path() { // Bare IPv4 with no port → looked up via lookup_host (which // works for literal IPs too) and default port applied. let r = resolve("127.0.0.1").await.unwrap(); assert_eq!(r[0].port(), DEFAULT_TS_PORT); assert!(r[0].is_ipv4()); } #[tokio::test] #[ignore = "hits the network; run with --ignored"] async fn resolves_known_hostname() { let r = resolve("cn.teamspeak.app").await.expect("dns must succeed"); assert!(!r.is_empty()); // Port should default to 9987. assert!(r.iter().any(|a| a.port() == DEFAULT_TS_PORT)); // Should have at least one IPv4 (cn.teamspeak.app currently // resolves to 175.178.125.23). assert!(r.iter().any(|a| a.is_ipv4())); } #[tokio::test] async fn unresolvable_returns_dns_failed() { let r = resolve("nonexistent-server-for-chanora-tests.invalid").await; match r { Err(ProtocolError::DnsFailed { host, .. }) => { assert!(host.contains("nonexistent-server-for-chanora-tests.invalid")); } other => panic!("expected DnsFailed, got {other:?}"), } } }