Files
chanora/crates/chanora_protocol/src/resolver.rs
T
EdisonJwa bc0da50cdb feat(protocol): A.1 — fix hostname resolution on Android and iOS
Resolves the Beta-blocking issue surfaced during Android v0.2.0-beta.1
verification: hostnames could not be used, only literal IPs.

Root cause:
  tsclientlib's built-in resolver uses hickory-resolver, which reads
  /etc/resolv.conf. That file does not exist on Android or iOS, so
  any connect by hostname exited the connection task before
  signalling ready and surfaced the cryptic error
    BridgeError.connection(field0: protocol backend:
      connection task exited before signalling ready)

Fix:
  crates/chanora_protocol/src/resolver.rs (new):
    Resolves hostnames via tokio::net::lookup_host, which uses the
    platform's getaddrinfo. Works on every platform Chanora targets.
    Tiny in-process positive-result cache (5 min TTL) keeps
    reconnects cheap. IPv4 sorted ahead of IPv6 in the returned list
    to favour the more reliable path on dual-stack networks.

  crates/chanora_protocol/src/adapter.rs:
    connection_task now resolves the hostname itself and passes the
    resulting SocketAddr (not the hostname String) to
    tsclientlib::Connection::build. tsclientlib's ServerAddress enum
    accepts SocketAddr via its From impl, so the upstream resolver
    is skipped entirely.

  crates/chanora_protocol/src/lib.rs:
    New typed error arm ProtocolError::DnsFailed { host, reason }
    so the UI can distinguish 'server not found' from 'server
    refused our packets'.

  crates/chanora_bridge/src/lib.rs:
    Matching BridgeError::DnsFailed { host, reason } DTO surfaced
    to Dart, with explicit From<CoreError::Protocol(DnsFailed)>
    mapping so the UI gets the structured fields rather than a
    stringified mess.

Tests added (crates/chanora_protocol/src/resolver.rs::tests):
  - rejects_empty
  - literal_ipv4_short_circuits
  - literal_ipv4_default_port_path
  - unresolvable_returns_dns_failed
  - resolves_known_hostname  (#[ignore], --ignored to run; hits net)

Empirical verification (2026-05-14):
  Workspace: cargo check + cargo test --workspace clean.
  Live resolver test: cn.teamspeak.app → 175.178.125.23:9987 (passes).
  cargo test -p chanora_core --test alpha_smoke -- --ignored:
    server='Vigorous Pro' channels=42 clients=20 (passes by hostname).
  flutter test: alpha_e2e_test + beta_e2e_test both green.
  Physical Moto G Stylus 5G (Android 14 arm64-v8a):
    APK rebuilt (48.9 MB). adb install + launch.
    Connect form left at default 'cn.teamspeak.app'.
    logcat shows:
      chanora_protocol: dns resolved input=cn.teamspeak.app
                                    resolved=175.178.125.23:9987
      tsclientlib: starting connection to 175.178.125.23:9987
      tsproto::resend: Connecting → Connected
      chanora_protocol: initial state snapshot received
    UI shows 'Connected to Vigorous Pro' / '42 channels • 20 online'.

This is the first item in Category A (post-Beta polish bundle).
Pause point: review before A.6 (full reconnect).
2026-05-15 00:17:13 +08:00

202 lines
6.4 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:?}"),
}
}
}