diff --git a/Cargo.lock b/Cargo.lock index 7a51ca3..200830b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ version = "0.0.1-pre" dependencies = [ "async-trait", "futures", + "once_cell", "serde", "thiserror 2.0.18", "tokio", diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index bd61757..f031290 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -47,6 +47,15 @@ pub enum BridgeError { /// The caller submitted a malformed command DTO. #[error("invalid command: {0}")] InvalidCommand(String), + /// Hostname resolution failed. Distinct from `Connection` so the + /// UI can show a meaningful "Server not found" message. + #[error("dns: could not resolve '{host}': {reason}")] + DnsFailed { + /// The hostname (or `host:port`) the caller submitted. + host: String, + /// Reason from the platform resolver. + reason: String, + }, /// Connection layer failure (typed-mapped from CoreError). #[error("connection: {0}")] Connection(String), @@ -72,6 +81,10 @@ impl From for BridgeError { chanora_core::CoreError::AudioNotStarted => { BridgeError::InvalidCommand("audio not started".to_string()) } + chanora_core::CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed { + host, + reason, + }) => BridgeError::DnsFailed { host, reason }, chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")), chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")), other => BridgeError::Unmapped(format!("{other}")), diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index 7fffef7..114c4a5 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -13,6 +13,7 @@ publish.workspace = true thiserror.workspace = true tracing.workspace = true serde.workspace = true +once_cell = "1" # tsclientlib is git-only and not on crates.io. The "audio" feature # pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index fc5b957..8a68de4 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -165,7 +165,32 @@ async fn connection_task( voice_in_tx: mpsc::Sender, ready_tx: oneshot::Sender>, ) { - let mut builder = Connection::build(cfg.address.clone()).name(cfg.nickname.clone()); + // Resolve the hostname OURSELVES using the platform resolver. + // tsclientlib's built-in hickory-resolver reads /etc/resolv.conf, + // which does not exist on Android or iOS — by side-stepping it + // here we get hostname connects working on every platform. + let addrs = match crate::resolver::resolve(&cfg.address).await { + Ok(a) => a, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + // Pick the first address (IPv4 preferred by the resolver's + // ordering). Future retry logic could fall back to subsequent + // addresses; one is enough for the Beta connect flow. + let resolved = addrs[0]; + info!( + target: "chanora_protocol", + input = %cfg.address, + resolved = %resolved, + "dns resolved" + ); + + // Pass the resolved SocketAddr directly to tsclientlib so it + // skips its own resolver entirely (tsclientlib accepts + // SocketAddr via the From for ServerAddress impl). + let mut builder = Connection::build(resolved).name(cfg.nickname.clone()); let identity = match cfg.identity.as_deref() { Some(s) => match Identity::new_from_str(s) { diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index f978333..52c49bd 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -20,12 +20,25 @@ //! //! Promoted from `poc/tsclientlib-connect-spike` on 2026-05-14 //! as part of the Alpha build. +//! +//! ## Hostname resolution (A.1) +//! +//! Upstream `tsclientlib` uses `hickory-resolver` which reads +//! `/etc/resolv.conf`. That file does not exist on Android or iOS, +//! and the Beta UI surfaced the resulting cryptic "connection task +//! exited before signalling ready" errors. We side-step the issue by +//! resolving hostnames ourselves with `tokio::net::lookup_host`, +//! which uses platform `getaddrinfo` (works correctly on every +//! supported platform), and feeding the resulting `SocketAddr` +//! directly to `tsclientlib::Connection::build`. A small in-process +//! positive-result cache keeps reconnects fast. #![forbid(unsafe_code)] #![warn(missing_docs)] mod adapter; mod dto; +mod resolver; pub use adapter::{ConnectConfig, InboundVoice, ProtocolClient}; pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot}; @@ -51,6 +64,17 @@ pub enum ProtocolError { #[error("invalid protocol configuration: {0}")] Invalid(String), + /// Hostname resolution failed. Distinct from [`Self::Connect`] + /// so the UI can show a meaningful "Server not found" message + /// instead of a generic connection error. + #[error("dns lookup failed for '{host}': {reason}")] + DnsFailed { + /// The hostname (or `host:port`) the caller submitted. + host: String, + /// Reason from the platform resolver. + reason: String, + }, + /// Failed to dial / handshake with the server. #[error("connect failed: {0}")] Connect(String), diff --git a/crates/chanora_protocol/src/resolver.rs b/crates/chanora_protocol/src/resolver.rs new file mode 100644 index 0000000..352484a --- /dev/null +++ b/crates/chanora_protocol/src/resolver.rs @@ -0,0 +1,201 @@ +//! 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:?}"), + } + } +}