feat: add TeamSpeak address resolver

This commit is contained in:
Edison Jwa
2026-05-25 01:12:50 +09:00
parent d7556cd39f
commit eb9014cd81
15 changed files with 2360 additions and 314 deletions
+391 -71
View File
@@ -17,8 +17,10 @@
//! * Disconnect is requested via a `oneshot`; the task drains
//! `tsclientlib`'s outbound events and exits.
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use chanora_resolver::ChanoraResolver;
use futures::prelude::*;
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
@@ -34,7 +36,8 @@ use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_types::ClientType;
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
};
use crate::ProtocolError;
@@ -135,6 +138,11 @@ enum Request {
password: Option<String>,
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Move self to a channel without waiting for the reply.
MoveToChannelNoWait {
channel_id: u64,
password: Option<String>,
},
/// Update own client mute state (input and/or output).
SetMuted {
input: Option<bool>,
@@ -184,6 +192,8 @@ pub struct ProtocolClient {
/// Inbound chat message stream from the connection task. The
/// receiver is taken by the supervisor and forwarded to UI.
chat_rx: std::sync::Mutex<Option<mpsc::Receiver<ChatMessage>>>,
/// Inbound server-activity stream from the connection task.
activity_rx: std::sync::Mutex<Option<mpsc::Receiver<ServerActivity>>>,
}
/// One inbound voice packet from a remote client.
@@ -243,6 +253,7 @@ impl ProtocolClient {
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
let (chat_tx, chat_rx) = mpsc::channel::<ChatMessage>(64);
let (activity_tx, activity_rx) = mpsc::channel::<ServerActivity>(128);
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
@@ -252,6 +263,7 @@ impl ProtocolClient {
voice_out_rx,
voice_in_tx,
chat_tx,
activity_tx,
ready_tx,
lost_tx,
));
@@ -263,6 +275,7 @@ impl ProtocolClient {
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
}),
Ok(Ok(Err(e))) => Err(e),
Ok(Err(_)) => Err(ProtocolError::Backend(
@@ -311,6 +324,23 @@ impl ProtocolClient {
.map_err(|_| ProtocolError::Lost("move_to_channel reply dropped".to_string()))?
}
/// Queue a move command and return once it has been accepted by
/// the protocol task. The server-side result is still observed
/// asynchronously by the task for logging and reconciliation.
pub async fn queue_move_to_channel(
&self,
channel_id: u64,
password: Option<String>,
) -> Result<(), ProtocolError> {
self.tx
.send(Request::MoveToChannelNoWait {
channel_id,
password,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))
}
/// Update mute state on our own client. Pass `Some(_)` for the
/// fields you want to change, `None` to leave a field as-is.
pub async fn set_muted(
@@ -393,6 +423,21 @@ impl ProtocolClient {
}
}
/// Take the inbound server-activity receiver. Returns `None` if it has
/// already been taken; only one consumer is allowed.
pub fn take_activity_rx(&self) -> Option<mpsc::Receiver<ServerActivity>> {
self.activity_rx.lock().ok().and_then(|mut g| g.take())
}
/// Put a previously-taken activity receiver back.
pub fn put_activity_rx(&self, rx: mpsc::Receiver<ServerActivity>) {
if let Ok(mut g) = self.activity_rx.lock() {
if g.is_none() {
*g = Some(rx);
}
}
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
@@ -419,6 +464,7 @@ async fn connection_task(
mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>,
chat_tx: mpsc::Sender<ChatMessage>,
activity_tx: mpsc::Sender<ServerActivity>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>,
) {
@@ -433,28 +479,28 @@ async fn connection_task(
return;
}};
}
macro_rules! fail_ready {
($err:expr) => {{
let err = $err;
let reason = DisconnectReason::Error(format!("{err}"));
let _ = ready_tx.send(Err(err));
exit!(reason);
}};
($err:expr, $reason:expr) => {{
let _ = ready_tx.send(Err($err));
exit!($reason);
}};
}
// 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 msg = format!("{e}");
let _ = ready_tx.send(Err(e));
exit!(DisconnectReason::Error(msg));
}
let resolved = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
};
// 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"
"server address resolved"
);
// Pass the resolved SocketAddr directly to tsclientlib so it
@@ -471,12 +517,14 @@ async fn connection_task(
.version(client_version);
let identity = match cfg.identity.as_deref() {
Some(s) => match Identity::new_from_str(s) {
Ok(id) => id,
Err(e) => {
let msg = format!("{e}");
let _ = ready_tx.send(Err(ProtocolError::Identity(msg.clone())));
exit!(DisconnectReason::Error(format!("identity: {msg}")));
Some(value) => match Identity::new_from_str(value) {
Ok(identity) => identity,
Err(err) => {
let msg = err.to_string();
fail_ready!(
ProtocolError::Identity(msg.clone()),
DisconnectReason::Error(format!("identity: {msg}"))
);
}
},
None => Identity::create(),
@@ -491,8 +539,10 @@ async fn connection_task(
Ok(c) => c,
Err(e) => {
let msg = format!("{e}");
let _ = ready_tx.send(Err(ProtocolError::Connect(msg.clone())));
exit!(DisconnectReason::Error(format!("connect: {msg}")));
fail_ready!(
ProtocolError::Connect(msg.clone()),
DisconnectReason::Error(format!("connect: {msg}"))
);
}
};
@@ -508,15 +558,17 @@ async fn connection_task(
}
Some(Err(e)) => {
let msg = format!("{e}");
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
exit!(DisconnectReason::Error(format!(
"disconnected early: {msg}"
)));
fail_ready!(
ProtocolError::DisconnectedEarly(msg.clone()),
DisconnectReason::Error(format!("disconnected early: {msg}"))
);
}
None => {
let msg = "event stream ended before snapshot".to_string();
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
exit!(DisconnectReason::Error(msg));
fail_ready!(
ProtocolError::DisconnectedEarly(msg.clone()),
DisconnectReason::Error(msg)
);
}
}
@@ -541,8 +593,10 @@ async fn connection_task(
}
Ok(None) => {
let msg = "stream closed during settle".to_string();
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
exit!(DisconnectReason::StreamEnded);
fail_ready!(
ProtocolError::DisconnectedEarly(msg),
DisconnectReason::StreamEnded
);
}
Err(_) => { /* no event available right now; keep waiting */ }
}
@@ -559,7 +613,8 @@ async fn connection_task(
let mut pending_moves: HashMap<
MessageHandle,
(
oneshot::Sender<Result<(), ProtocolError>>,
u64,
Option<oneshot::Sender<Result<(), ProtocolError>>>,
std::time::Instant,
),
> = HashMap::new();
@@ -599,6 +654,53 @@ async fn connection_task(
}
StreamItem::BookEvents(events) => {
for ev in events {
if let tsclientlib::events::Event::PropertyChanged {
id: ts_bookkeeping::events::PropertyId::ClientChannel(client_id),
..
} = &ev
{
let own_client = con
.get_state()
.ok()
.map(|state| state.own_client);
if own_client == Some(*client_id) {
let current_channel = con
.get_state()
.ok()
.and_then(|state| {
state.clients.get(client_id).map(|client| client.channel.0)
});
if let Some(current_channel) = current_channel {
let matched: Vec<MessageHandle> = pending_moves
.iter()
.filter_map(|(handle, (target_channel, _, _))| {
if *target_channel == current_channel {
Some(*handle)
} else {
None
}
})
.collect();
for handle in matched {
if let Some((_, reply, _)) = pending_moves.remove(&handle) {
info!(
target: "chanora_protocol",
channel_id = current_channel,
"client_move resolved by authoritative self channel change"
);
if let Some(reply) = reply {
let _ = reply.send(Ok(()));
}
}
}
}
}
}
if let Some(activity) = format_server_activity(&con, &ev) {
let _ = activity_tx.try_send(ServerActivity { message: activity });
}
if let tsclientlib::events::Event::Message {
target,
invoker,
@@ -625,7 +727,7 @@ async fn connection_task(
}
}
StreamItem::MessageResult(handle, result) => {
if let Some((reply, _deadline)) = pending_moves.remove(&handle) {
if let Some((_target_channel, reply, _deadline)) = pending_moves.remove(&handle) {
let mapped = match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
@@ -648,7 +750,15 @@ async fn connection_task(
Err(ProtocolError::ServerRejected { code, message })
}
};
let _ = reply.send(mapped);
if let Some(reply) = reply {
let _ = reply.send(mapped);
} else if let Err(err) = mapped {
info!(
target: "chanora_protocol",
error = %err,
"client_move completed in background with error"
);
}
}
}
_ => { /* book / message / other events: ignore */ }
@@ -677,7 +787,7 @@ async fn connection_task(
let now = std::time::Instant::now();
let expired: Vec<MessageHandle> = pending_moves
.iter()
.filter_map(|(handle, (_, deadline))| {
.filter_map(|(handle, (_, _, deadline))| {
if now >= *deadline {
Some(*handle)
} else {
@@ -686,8 +796,10 @@ async fn connection_task(
})
.collect();
for handle in expired {
if let Some((reply, _)) = pending_moves.remove(&handle) {
let _ = reply.send(Ok(()));
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) {
if let Some(reply) = reply {
let _ = reply.send(Ok(()));
}
}
}
}
@@ -706,15 +818,32 @@ async fn connection_task(
match move_self_to(&mut con, channel_id, password.as_deref()) {
Ok(handle) => {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_moves.insert(handle, (reply, deadline));
pending_moves.insert(handle, (channel_id, Some(reply), deadline));
}
Err(e) => {
// Couldn't even send the command; report
// immediately.
let _ = reply.send(Err(e));
}
}
}
Ok(Request::MoveToChannelNoWait {
channel_id,
password,
}) => {
match move_self_to(&mut con, channel_id, password.as_deref()) {
Ok(handle) => {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_moves.insert(handle, (channel_id, None, deadline));
}
Err(e) => {
warn!(
target: "chanora_protocol",
error = %e,
channel_id,
"fire-and-forget client_move could not be queued"
);
}
}
}
Ok(Request::SetMuted {
input,
output,
@@ -749,6 +878,26 @@ async fn connection_task(
}
}
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
host: address.to_string(),
reason: format!("resolver initialization failed: {err}"),
})?;
let resolved_address = resolver
.resolve_client_address(address)
.await
.map_err(|err| ProtocolError::DnsFailed {
host: address.to_string(),
reason: err.to_string(),
})?;
resolved_address
.parse::<SocketAddr>()
.map_err(|err| ProtocolError::DnsFailed {
host: address.to_string(),
reason: format!("resolver returned invalid socket address '{resolved_address}': {err}"),
})
}
/// Move our own client into `channel_id` with an optional password.
/// Looks up our `own_client` in the current state and dispatches the
/// generated `client_move` command via `send_with_result`. The
@@ -815,19 +964,10 @@ fn send_text_message(
message: &str,
target: MessageTarget,
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Server,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(server): {e}")))?;
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
@@ -835,25 +975,13 @@ fn send_text_message(
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Channel,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(channel): {e}")))?;
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
client
.send_textmessage(message)
.send(con)
@@ -863,11 +991,7 @@ fn send_text_message(
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
client
.poke(message)
.send(con)
@@ -878,6 +1002,33 @@ fn send_text_message(
Ok(())
}
fn send_text_to_mode(
con: &mut Connection,
message: &str,
target: tsproto_types::TextMessageTargetMode,
label: &str,
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(c2s::OutSendTextMessagePart {
target,
target_client_id: None,
message: message.into(),
}))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
}
fn find_client_by_id<'a>(
clients: impl IntoIterator<Item = &'a Client>,
client_id: u64,
) -> Result<&'a Client, ProtocolError> {
clients
.into_iter()
.find(|client| client.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))
}
/// Extract the originating `client_id` from an inbound voice packet.
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
use tsproto_packets::packets::AudioData;
@@ -1073,6 +1224,175 @@ fn sanitize(s: &str) -> String {
.collect()
}
fn quoted(s: &str) -> String {
format!("\"{}\"", sanitize(s))
}
fn activity_channel_name(con: &Connection, id: tsclientlib::ChannelId) -> Option<String> {
con.get_state()
.ok()
.and_then(|state| state.channels.get(&id))
.map(|channel| quoted(&channel.name))
}
fn activity_client_name(con: &Connection, id: tsclientlib::ClientId) -> Option<String> {
con.get_state()
.ok()
.and_then(|state| state.clients.get(&id))
.filter(|client| !is_server_query_client_type(&client.client_type))
.map(|client| quoted(&client.name))
}
fn activity_client(con: &Connection, id: tsclientlib::ClientId) -> Option<Client> {
con.get_state()
.ok()
.and_then(|state| state.clients.get(&id))
.filter(|client| !is_server_query_client_type(&client.client_type))
.cloned()
}
fn activity_channel_group_name(
con: &Connection,
id: tsclientlib::ChannelGroupId,
) -> Option<String> {
con.get_state()
.ok()
.and_then(|state| state.channel_groups.get(&id))
.map(|group| quoted(&group.name))
}
fn activity_server_group_name(
con: &Connection,
id: tsclientlib::ServerGroupId,
) -> Option<String> {
con.get_state()
.ok()
.and_then(|state| state.server_groups.get(&id))
.map(|group| quoted(&group.name))
}
fn activity_invoker_name(invoker: Option<&tsclientlib::Invoker>) -> String {
invoker
.map(|invoker| quoted(&invoker.name))
.unwrap_or_else(|| "\"Server\"".to_string())
}
fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> Option<String> {
use ts_bookkeeping::events::{Event, PropertyId, PropertyValue};
use tsproto_types::Reason;
match ev {
Event::PropertyAdded { id: PropertyId::Client(client_id), extra, .. } => {
if extra.reason.is_none() {
return None;
}
let client = activity_client(con, *client_id)?;
let channel = activity_channel_name(con, client.channel)?;
Some(format!(
"{} connected to channel {}",
quoted(&client.name),
channel
))
}
Event::PropertyRemoved { id: PropertyId::Client(_), old, extra, .. } => {
let PropertyValue::Client(client) = old else {
return None;
};
if is_server_query_client_type(&client.client_type) {
return None;
}
match extra.reason {
Some(Reason::Clientdisconnect) => {
Some(format!("{} disconnected (Leaving)", quoted(&client.name)))
}
Some(Reason::ClientdisconnectServerShutdown) | Some(Reason::Serverstop) => {
Some(format!(
"{} disconnected (server shutdown)",
quoted(&client.name)
))
}
_ => Some(format!(
"{} dropped (connection lost)",
quoted(&client.name)
)),
}
}
Event::PropertyChanged {
id: PropertyId::ClientChannel(client_id),
old,
invoker,
..
} => {
let PropertyValue::ChannelId(from_channel_id) = old else {
return None;
};
let client = activity_client(con, *client_id)?;
let from = activity_channel_name(con, *from_channel_id)?;
let to = activity_channel_name(con, client.channel)?;
if invoker.as_ref().map(|invoker| invoker.id) == Some(*client_id) || invoker.is_none()
{
Some(format!(
"{} switched from channel {} to {}",
quoted(&client.name),
from,
to
))
} else {
Some(format!(
"{} was moved from channel {} to {} by {}",
quoted(&client.name),
from,
to,
activity_invoker_name(invoker.as_ref())
))
}
}
Event::PropertyChanged {
id: PropertyId::ClientChannelGroup(client_id),
invoker,
..
} => {
let client = activity_client(con, *client_id)?;
let group = activity_channel_group_name(con, client.channel_group)?;
Some(format!(
"Channel group {} was assigned to {} by {}.",
group,
quoted(&client.name),
activity_invoker_name(invoker.as_ref())
))
}
Event::PropertyAdded {
id: PropertyId::ClientServerGroup(client_id, group_id),
invoker,
..
} => {
let client = activity_client_name(con, *client_id)?;
let group = activity_server_group_name(con, *group_id)?;
Some(format!(
"Server group {} was assigned to {} by {}.",
group,
client,
activity_invoker_name(invoker.as_ref())
))
}
Event::PropertyRemoved {
id: PropertyId::ClientServerGroup(client_id, group_id),
invoker,
..
} => {
let client = activity_client_name(con, *client_id)?;
let group = activity_server_group_name(con, *group_id)?;
Some(format!(
"Server group {} was removed from {} by {}.",
group,
client,
activity_invoker_name(invoker.as_ref())
))
}
_ => None,
}
}
const _: () = {
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
// also considers the root.
+7
View File
@@ -56,6 +56,13 @@ pub struct ChatMessage {
pub target: MessageTarget,
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerActivity {
/// Human-readable activity line.
pub message: String,
}
/// One connected client on the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
+8 -12
View File
@@ -21,28 +21,24 @@
//! Promoted from `poc/tsclientlib-connect-spike` on 2026-05-14
//! as part of the Alpha build.
//!
//! ## Hostname resolution (A.1)
//! ## Server address 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.
//! `chanora_resolver` owns TeamSpeak client address resolution:
//! server-name aliases, `_ts3._udp` SRV, TSDNS SRV/TCP, and DNS
//! fallback. This crate asks it for a final IP `host:port` and feeds
//! the resulting `SocketAddr` directly to `tsclientlib::Connection::build`
//! so tsclientlib's own resolver is not used.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod adapter;
mod dto;
mod resolver;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
-199
View File
@@ -1,199 +0,0 @@
//! 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:?}"),
}
}
}