200 lines
6.3 KiB
Rust
200 lines
6.3 KiB
Rust
//! 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<SocketAddr>,
|
|
at: Instant,
|
|
}
|
|
|
|
static CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> = 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<Vec<SocketAddr>, 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::<SocketAddr>() {
|
|
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<SocketAddr> = {
|
|
let lookup_input: String = lookup_key.clone();
|
|
let collected = match tokio::net::lookup_host(lookup_input.as_str()).await {
|
|
Ok(iter) => iter.collect::<Vec<SocketAddr>>(),
|
|
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<Vec<SocketAddr>> {
|
|
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<SocketAddr>) {
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|