diff --git a/Cargo.lock b/Cargo.lock index c5a90e9..dddd097 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,9 +201,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -307,9 +307,9 @@ checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -493,9 +493,9 @@ name = "chanora_protocol" version = "0.2.0-beta.1" dependencies = [ "async-trait", + "chanora_resolver", "futures", - "once_cell", - "reqwest", + "reqwest 0.13.3", "serde", "thiserror 2.0.18", "tokio", @@ -506,6 +506,18 @@ dependencies = [ "tsproto-types", ] +[[package]] +name = "chanora_resolver" +version = "0.1.0" +dependencies = [ + "anyhow", + "hickory-resolver", + "reqwest 0.12.28", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "chanora_state" version = "0.2.0-beta.1" @@ -1095,9 +1107,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1755,6 +1767,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2069,9 +2082,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2395,9 +2408,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3155,6 +3168,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -3302,6 +3353,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3522,9 +3574,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3553,6 +3605,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -4120,9 +4184,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -4271,7 +4335,7 @@ dependencies = [ "num-traits", "pin-utils", "rand 0.10.1", - "reqwest", + "reqwest 0.13.3", "thiserror 2.0.18", "time", "tokio", @@ -4512,9 +4576,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4525,9 +4589,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4535,9 +4599,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4545,9 +4609,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4558,9 +4622,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4601,9 +4665,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4628,6 +4692,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webrtc-vad" version = "0.4.0" @@ -5026,9 +5099,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index fc62d55..98b5235 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ # apps/chanora_flutter/ — Flutter application (separate toolchain) # core/chanora_core/ — top-level Rust API + orchestration # crates/chanora_protocol/ — tsclientlib isolation +# crates/chanora_resolver/ — TeamSpeak address resolution # crates/chanora_state/ — snapshot, deltas, reducers # crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer # crates/chanora_storage/ — bookmarks, settings, identity refs @@ -31,6 +32,7 @@ members = [ "crates/chanora_storage", "crates/chanora_diagnostics", "crates/chanora_bridge", + "crates/chanora_resolver", ] # Excludes: the PoC trees stay outside the product workspace so that diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index f30ec1a..c599943 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -13,7 +13,7 @@ publish.workspace = true thiserror.workspace = true tracing.workspace = true serde.workspace = true -once_cell = "1" +chanora_resolver = { path = "../chanora_resolver" } # 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 55bca5a..928a0c9 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -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, reply: oneshot::Sender>, }, + /// Move self to a channel without waiting for the reply. + MoveToChannelNoWait { + channel_id: u64, + password: Option, + }, /// Update own client mute state (input and/or output). SetMuted { input: Option, @@ -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>>, + /// Inbound server-activity stream from the connection task. + activity_rx: std::sync::Mutex>>, } /// One inbound voice packet from a remote client. @@ -243,6 +253,7 @@ impl ProtocolClient { let (voice_out_tx, voice_out_rx) = mpsc::channel::(64); let (voice_in_tx, voice_in_rx) = mpsc::channel::(64); let (chat_tx, chat_rx) = mpsc::channel::(64); + let (activity_tx, activity_rx) = mpsc::channel::(128); let (ready_tx, ready_rx) = oneshot::channel::>(); let (lost_tx, lost_rx) = oneshot::channel::(); @@ -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, + ) -> 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> { + 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) { + 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, voice_in_tx: mpsc::Sender, chat_tx: mpsc::Sender, + activity_tx: mpsc::Sender, ready_tx: oneshot::Sender>, lost_tx: oneshot::Sender, ) { @@ -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>, + u64, + Option>>, 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 = 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 = 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 { + 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::() + .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, + 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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. diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 9cac153..3283215 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -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 { diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index 659b4cd..9ca0f69 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -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 diff --git a/crates/chanora_protocol/src/resolver.rs b/crates/chanora_protocol/src/resolver.rs deleted file mode 100644 index a4b09d9..0000000 --- a/crates/chanora_protocol/src/resolver.rs +++ /dev/null @@ -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, - 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:?}"), - } - } -} diff --git a/crates/chanora_resolver/.gitignore b/crates/chanora_resolver/.gitignore new file mode 100644 index 0000000..f17aaef --- /dev/null +++ b/crates/chanora_resolver/.gitignore @@ -0,0 +1,11 @@ +/target/ + +# Local editor and OS files. +.DS_Store +*.swp +*.swo + +# Local environment overrides. +.env +.env.* +!.env.example diff --git a/crates/chanora_resolver/Cargo.toml b/crates/chanora_resolver/Cargo.toml new file mode 100644 index 0000000..9c7df6c --- /dev/null +++ b/crates/chanora_resolver/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "chanora_resolver" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +[dependencies] +anyhow = "1" +hickory-resolver = { version = "0.26", features = ["tokio", "system-config"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } diff --git a/crates/chanora_resolver/README.md b/crates/chanora_resolver/README.md new file mode 100644 index 0000000..f288f31 --- /dev/null +++ b/crates/chanora_resolver/README.md @@ -0,0 +1,104 @@ +# chanora_resolver + +The library accepts the same kind of raw server input a client UI receives and returns a final `host:port` address suitable for `tsclientlib::Connection::build` or Chanora's `ConnectConfig.address`. + +## Resolution Flow + +Use `ChanoraResolver::resolve_client_request` or `resolve_client_address` for app code. The resolver owns the decision tree: + +1. Normalize raw client input, including `ts3server://host?port=...`. +2. For dotless names such as `6666` or `wwb`, query the myTeamSpeak server-name endpoint first. If it returns a hostname, restart normal resolution with that hostname. If it returns `ip:port`, use that final address. +3. Start plain A/AAAA DNS as the final fallback address. +4. Prefer `_ts3._udp.` SRV. Its target host and port override the user input port, then the target is resolved to a final IP. +5. Try `_tsdns._tcp.` SRV on candidate parent/full hosts and query the returned TSDNS server over TCP. +6. Try direct TSDNS TCP on candidate parent/full hosts at port `41144`. +7. Fall back to the A/AAAA result with the user-supplied port, or default TeamSpeak port `9987`. + +The older explicit API (`Args { host, service, protocol }`) is still available for diagnostic example use. + +## DNS Setup Guide + +This chart is for choosing DNS records when operating a TeamSpeak 3 server. It is not the client lookup order; the library still implements the client-side resolver behavior above. + +```mermaid +flowchart TD + A["Which DNS type fits your TeamSpeak 3 server?"] + + A --> X["Don't do this!"] + X --> X1["Do not use simple TSDNS
without SRV TSDNS"] + + A --> B{"How many servers do you have?"} + + B -->|One virtual server| C{"Does your server use
the default port 9987?"} + + C -->|Yes| D{"Do all services under this domain
run on the same server
as the TS3 server?"} + D -->|Yes| E1["Use A/AAAA or CNAME"] + D -->|No| E2["Use SRV TS3"] + + C -->|No| F{"Are you okay with
entering the port manually?"} + F -->|No| E3["Use SRV TS3"] + F -->|Yes| G{"Do all services under this domain
run on the same server
as the TS3 server?"} + G -->|Yes| E4["Use A/AAAA or CNAME"] + G -->|No| E5["Use SRV TSDNS"] + + B -->|More than one virtual server| H{"Are you okay with port numbers?"} + + H -->|No| I1["Use subdomains with SRV TS3
for each subdomain"] + + H -->|Yes| I{"On how many servers
do you run TS3 servers?"} + I -->|Only one| J{"Do all services under this domain
run on the same server
as the TS3 server?"} + J -->|Yes| K1["Use A/AAAA or CNAME"] + J -->|No| K2["Use SRV TSDNS"] + + I -->|More than one| K3["Use subdomains with A/AAAA
or CNAME for each server"] +``` + +## Library Example + +```rust +use chanora_resolver::ChanoraResolver; + +let client = ChanoraResolver::new()?; +let address = client.resolve_client_address("voice.teamspeak.com").await?; + +// tsclientlib: +// let mut connection = tsclientlib::Connection::build(address).connect()?; + +// Chanora: +// let cfg = chanora_core::ConnectConfig { +// address, +// nickname, +// password, +// identity, +// ready_timeout, +// }; +// let snapshot = session.connect(cfg).await?; +``` + +## Run + +```bash +cargo run --example library -- voice.teamspeak.com +cargo run --example library -- 6666 +cargo run --example library -- wwb +cargo run --example library -- kr.teamspeak.app +cargo run --example library -- 'ts3server://voice.teamspeak.com?port=9987' +cargo run --example cli -- -host voice.teamspeak.com -service ts3 +``` + +`example_log` contains live sample outputs for TS3 SRV, DNS fallback, server-name lookup, and unsuccessful TSDNS attempts that must remain non-fatal. + +## Fail-Safes + +- Bare numeric names such as `6666` are treated as server names first, avoiding OS DNS coercion into numeric IPv4 addresses. +- SRV targets and TSDNS responses are resolved to final IP addresses before handoff, so Chanora can pass a concrete `SocketAddr`-style `host:port` to `tsclientlib`. +- TSDNS TCP lookups use short timeouts and are non-fatal; DNS fallback remains available when TSDNS is absent or unreachable. +- Android uses an explicit SRV resolver configuration instead of relying on system resolver initialization in a raw binary context. + +## Test + +```bash +cargo fmt --all -- --check +cargo test +cargo clippy --all-targets --all-features -- -D warnings +``` \ No newline at end of file diff --git a/crates/chanora_resolver/build.rs b/crates/chanora_resolver/build.rs new file mode 100644 index 0000000..d849ae8 --- /dev/null +++ b/crates/chanora_resolver/build.rs @@ -0,0 +1,25 @@ +use std::{env, process::Command}; + +fn main() { + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=build.rs"); + + let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "development".to_string()); + let commit = git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".into()); + let date = + git_output(&["show", "-s", "--format=%cI", "HEAD"]).unwrap_or_else(|| "unknown".into()); + + println!("cargo:rustc-env=CHANORA_RESOLVER_BUILD_VERSION={version}"); + println!("cargo:rustc-env=CHANORA_RESOLVER_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=CHANORA_RESOLVER_BUILD_DATE={date}"); +} + +fn git_output(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + Some(value.trim().to_owned()) +} diff --git a/crates/chanora_resolver/example_log b/crates/chanora_resolver/example_log new file mode 100644 index 0000000..0e638b0 --- /dev/null +++ b/crates/chanora_resolver/example_log @@ -0,0 +1,229 @@ + 1. ----------- + +2026/5/22 21:29:16 ClientUI Info Connect to server: teamspeak.app + +2026/5/22 21:29:16 ClientUI Info Trying to resolve teamspeak.app + +2026/5/22 21:29:16 TSDNS Info A/AAAA DNS resolve successful, "teamspeak.app" =(h: 13.33.183.64 p:0) + +2026/5/22 21:29:16 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "teamspeak.app" =(h: 13.33.183.4 p:0) + +2026/5/22 21:29:16 TSDNS Info SRV DNS resolve unsuccessful, "_tsdns._tcp.teamspeak.app" Domain name not found + +2026/5/22 21:29:16 TSDNS Info SRV DNS resolve successful, "_ts3._udp.teamspeak.app" =(h: global.teamspeak.app p:9987) + +2026/5/22 21:29:16 TSDNS Info A/AAAA DNS resolve successful, "global.teamspeak.app" =(h: 103.224.172.62 p:0) + +2026/5/22 21:29:16 ClientUI Info Lookup finished: ip=103.224.172.62 port=9987 query=teamspeak.app error=0 + +2026/5/22 21:29:16 ClientUI Info Resolve successful: 103.224.172.62:9987 + +2026/5/22 21:29:16 ClientUI Info Initiating connection: 103.224.172.62:9987 + +2026/5/22 21:29:16 ClientUI Info Connect status: Connecting + +2026/5/22 21:29:17 PktHandler Info server sent ciphers:1 + +2026/5/22 21:29:17 PktHandler Info Selected cipher:0 + +2026/5/22 21:29:17 PktHandler Devel Puzzle solve time: 3 + +2026/5/22 21:29:18 ClientUI Info Connect status: Connected + +2026/5/22 21:29:18 ClientUI Info Connect status: Establishing connection + +2026/5/22 21:29:18 TSDNS Info TSDNS queried unsuccessfully 13.33.183.4:41144 + +2026/5/22 21:29:18 TSDNS Info No TSDNS found + +2026/5/22 21:29:18 ClientUI Info Connect status: Connection established + + + +2. ---------- + +2026/5/22 21:30:12 ClientUI Info Connect to server: kr.teamspeak.app + +2026/5/22 21:30:12 ClientUI Info Connect status: Disconnected + +2026/5/22 21:30:12 ClientUI Info Disconnected or forced to leave, want autoreconnect = 0 + +2026/5/22 21:30:12 ClientUI Info Connecting to next server... + +2026/5/22 21:30:12 ClientUI Info Connect to server: kr.teamspeak.app + +2026/5/22 21:30:12 ClientUI Info Trying to resolve kr.teamspeak.app + +2026/5/22 21:30:12 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "teamspeak.app" =(h: 13.33.183.64 p:0) + +2026/5/22 21:30:12 TSDNS Info SRV DNS resolve unsuccessful, "_tsdns._tcp.teamspeak.app" Domain name not found + +2026/5/22 21:30:12 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "kr.teamspeak.app" =(h: 140.238.11.238 p:0) + +2026/5/22 21:30:12 TSDNS Info A/AAAA DNS resolve successful, "kr.teamspeak.app" =(h: 140.238.11.238 p:0) + +2026/5/22 21:30:12 TSDNS Info SRV DNS resolve unsuccessful, "_ts3._udp.kr.teamspeak.app" Domain name not found + +2026/5/22 21:30:14 TSDNS Info TSDNS queried unsuccessfully 13.33.183.64:41144 + +2026/5/22 21:30:14 TSDNS Info No TSDNS found + +2026/5/22 21:30:14 TSDNS Info TSDNS queried unsuccessfully 140.238.11.238:41144 + +2026/5/22 21:30:14 TSDNS Info No TSDNS found + +2026/5/22 21:30:14 ClientUI Info Lookup finished: ip=140.238.11.238 port=9987 query=kr.teamspeak.app error=0 + +2026/5/22 21:30:14 ClientUI Info Resolve successful: 140.238.11.238:9987 + +2026/5/22 21:30:14 ClientUI Info Initiating connection: 140.238.11.238:9987 + +2026/5/22 21:30:14 ClientUI Info Connect status: Connecting + +2026/5/22 21:30:14 PktHandler Info server sent ciphers:1 + +2026/5/22 21:30:14 PktHandler Info Selected cipher:0 + +2026/5/22 21:30:14 PktHandler Devel Puzzle solve time: 3 + +2026/5/22 21:30:15 ClientUI Info Connect status: Connected + +2026/5/22 21:30:15 ClientUI Info Connect status: Establishing connection + +2026/5/22 21:30:15 ClientUI Info Connect status: Connection established + + + +3. --------- + +2026/5/22 21:40:35 ClientUI Info Connect to server: 6666 + +2026/5/22 21:40:35 ClientUI Info Trying to resolve 6666 + +2026/5/22 21:40:35 TSDNS Info Trying to resolve server name: 6666 + +2026/5/22 21:40:35 Addon Info Addon up to date. + +2026/5/22 21:40:35 TSDNS Info Server name successfully resolved + +2026/5/22 21:40:35 TSDNS Info Lookup: teamspeak.app, parse result: DNS teamspeak.app 9987 + +2026/5/22 21:40:35 TSDNS Info Server name resolved to host: teamspeak.app 9987 + +2026/5/22 21:40:35 Addon Info Addon up to date. + +2026/5/22 21:40:35 TSDNS Info A/AAAA DNS resolve successful, "teamspeak.app" =(h: 13.33.183.4 p:0) + +2026/5/22 21:40:35 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "teamspeak.app" =(h: 13.33.183.4 p:0) + +2026/5/22 21:40:35 TSDNS Info SRV DNS resolve successful, "_ts3._udp.teamspeak.app" =(h: global.teamspeak.app p:9987) + +2026/5/22 21:40:36 TSDNS Info A/AAAA DNS resolve successful, "global.teamspeak.app" =(h: 103.224.172.62 p:0) + +2026/5/22 21:40:36 ClientUI Info Lookup finished: ip=103.224.172.62 port=9987 query=teamspeak.app error=0 + +2026/5/22 21:40:36 ClientUI Info Resolve successful: 103.224.172.62:9987 + +2026/5/22 21:40:36 ClientUI Info Initiating connection: 103.224.172.62:9987 + +2026/5/22 21:40:36 Windows Audio Session Devel DeviceDeleteList::wait_for_deletes - enter - DeviceDeleteList + +2026/5/22 21:40:36 Windows Audio Session Devel DeviceDeleteList::wait_for_deletes - leave - DeviceDeleteList + +2026/5/22 21:40:36 TSDNS Info SRV DNS resolve unsuccessful, "_tsdns._tcp.teamspeak.app" Domain name not found + +2026/5/22 21:40:36 Windows Audio Session Devel DeviceDeleteList::wait_for_deletes - enter - DeviceDeleteList + +2026/5/22 21:40:36 Windows Audio Session Devel DeviceDeleteList::wait_for_deletes - leave - DeviceDeleteList + +2026/5/22 21:40:36 Direct Sound Warning RenderDeviceContext::int_processData outerLoop proc (3) time: 41 msecs - {E4AE96A7-FDC2-4AF6-8C44-22ADDD8A821B} + +2026/5/22 21:40:36 ClientUI Info Connect status: Connecting + +2026/5/22 21:40:36 Addon Info Addon up to date. + +2026/5/22 21:40:36 PktHandler Info server sent ciphers:1 + +2026/5/22 21:40:36 PktHandler Info Selected cipher:0 + +2026/5/22 21:40:36 PktHandler Devel Puzzle solve time: 2 + +2026/5/22 21:40:36 Addon Info Addon up to date. + +2026/5/22 21:40:37 Info connected to push system. + +2026/5/22 21:40:37 Addon Info Addon up to date. + +2026/5/22 21:40:37 ClientUI Info Connect status: Connected + +2026/5/22 21:40:37 ClientUI Info Connect status: Establishing connection + +2026/5/22 21:40:37 Addon Info Addon up to date. + +2026/5/22 21:40:37 TSDNS Info TSDNS queried unsuccessfully 13.33.183.4:41144 + +2026/5/22 21:40:37 TSDNS Info No TSDNS found + +2026/5/22 21:40:37 ClientUI Info Connect status: Connection established + + + +4. -------- + +2026/5/22 21:41:46 ClientUI Info Connect to server: voice.teamspeak.com + +2026/5/22 21:41:46 ClientUI Info Connect status: Disconnected + +2026/5/22 21:41:46 ClientUI Info Disconnected or forced to leave, want autoreconnect = 0 + +2026/5/22 21:41:46 ClientUI Info Connecting to next server... + +2026/5/22 21:41:46 ClientUI Info Connect to server: voice.teamspeak.com + +2026/5/22 21:41:46 ClientUI Info Trying to resolve voice.teamspeak.com + +2026/5/22 21:41:46 TSDNS Info A/AAAA DNS resolve successful, "voice.teamspeak.com" =(h: 148.113.198.70 p:0) + +2026/5/22 21:41:46 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "teamspeak.com" =(h: 104.18.4.167 p:0) + +2026/5/22 21:41:46 TSDNS Info A/AAAA DNS resolve for possible TSDNS successful, "voice.teamspeak.com" =(h: 148.113.198.70 p:0) + +2026/5/22 21:41:46 TSDNS Info SRV DNS resolve unsuccessful, "_tsdns._tcp.teamspeak.com" Domain name not found + +2026/5/22 21:41:46 TSDNS Info SRV DNS resolve successful, "_ts3._udp.voice.teamspeak.com" =(h: voice.teamspeak.com p:9987) + +2026/5/22 21:41:46 TSDNS Info A/AAAA DNS resolve successful, "voice.teamspeak.com" =(h: 148.113.198.70 p:0) + +2026/5/22 21:41:46 ClientUI Info Lookup finished: ip=148.113.198.70 port=9987 query=voice.teamspeak.com error=0 + +2026/5/22 21:41:46 ClientUI Info Resolve successful: 148.113.198.70:9987 + +2026/5/22 21:41:46 ClientUI Info Initiating connection: 148.113.198.70:9987 + +2026/5/22 21:41:46 ClientUI Info Connect status: Connecting + +2026/5/22 21:41:46 PktHandler Info server sent ciphers:1 + +2026/5/22 21:41:46 PktHandler Info server sent ciphers:1 + +2026/5/22 21:41:46 PktHandler Info server sent ciphers:1 + +2026/5/22 21:41:47 PktHandler Info server sent ciphers:1 + +2026/5/22 21:41:47 PktHandler Info Selected cipher:0 + +2026/5/22 21:41:47 PktHandler Devel Puzzle solve time: 2 + +2026/5/22 21:41:48 TSDNS Info TSDNS queried unsuccessfully 104.18.4.167:41144 + +2026/5/22 21:41:48 TSDNS Info No TSDNS found + +2026/5/22 21:41:48 TSDNS Info TSDNS queried unsuccessfully 148.113.198.70:41144 + +2026/5/22 21:41:48 TSDNS Info No TSDNS found + +2026/5/22 21:41:48 ClientUI Info Connect status: Connected + +2026/5/22 21:41:48 ClientUI Info Connect status: Establishing connection + +2026/5/22 21:41:49 ClientUI Info Connect status: Connection established diff --git a/crates/chanora_resolver/examples/cli.rs b/crates/chanora_resolver/examples/cli.rs new file mode 100644 index 0000000..901093f --- /dev/null +++ b/crates/chanora_resolver/examples/cli.rs @@ -0,0 +1,102 @@ +use anyhow::{anyhow, bail, Result}; +use chanora_resolver::{run, setup_log, Args}; + +const DEFAULT_SERVICE: &str = "ts3"; + +#[tokio::main] +async fn main() { + setup_log(); + + if let Err(err) = execute(std::env::args().skip(1)).await { + tracing::error!(err = %err, "lookup failed"); + std::process::exit(1); + } +} + +async fn execute(args: I) -> Result<()> +where + I: IntoIterator, + S: Into, +{ + let parsed = parse_args(args)?; + run(parsed).await +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut host = String::new(); + let mut service = DEFAULT_SERVICE.to_string(); + let mut protocol = String::new(); + + let mut iter = args.into_iter().map(Into::into).peekable(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + "-host" | "--host" => { + host = next_value(&mut iter, "host")?; + } + "-service" | "--service" => { + service = next_value(&mut iter, "service")?; + } + "-protocol" | "--protocol" => { + protocol = next_value(&mut iter, "protocol")?; + } + value if value.starts_with('-') => { + bail!("unknown flag: {value}"); + } + value => { + bail!("unexpected positional argument: {value}"); + } + } + } + + Ok(Args { + host, + service, + protocol, + }) +} + +fn next_value(iter: &mut std::iter::Peekable, flag: &str) -> Result +where + I: Iterator, +{ + iter.next() + .ok_or_else(|| anyhow!("missing value for {flag} flag")) +} + +fn print_help() { + println!("chanora-resolver -host -service -protocol "); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_supports_flag_pairs() { + let args = parse_args([ + "-host", + "Example.com", + "-service", + "dns", + "-protocol", + "udp", + ]) + .unwrap(); + assert_eq!( + args, + Args { + host: "Example.com".into(), + service: "dns".into(), + protocol: "udp".into(), + } + ); + } +} diff --git a/crates/chanora_resolver/examples/library.rs b/crates/chanora_resolver/examples/library.rs new file mode 100644 index 0000000..34459b2 --- /dev/null +++ b/crates/chanora_resolver/examples/library.rs @@ -0,0 +1,47 @@ +use chanora_resolver::{setup_log, ChanoraResolver}; + +#[tokio::main] +async fn main() { + setup_log(); + + let input = std::env::args() + .nth(1) + .unwrap_or_else(|| "voice.teamspeak.com".into()); + + let client = match ChanoraResolver::new() { + Ok(client) => client, + Err(err) => { + tracing::error!(err = %err, "failed to initialize client"); + std::process::exit(1); + } + }; + + match client.resolve_client_request(&input).await { + Ok(resolution) => { + let connect_addr = resolution.address; + tracing::info!( + input = %resolution.input, + method = ?resolution.method, + addr = %connect_addr, + "client connection target" + ); + + // Direct tsclientlib handoff: + // let mut connection = tsclientlib::Connection::build(connect_addr).connect()?; + // + // Chanora handoff: + // let cfg = chanora_core::ConnectConfig { + // address: connect_addr, + // nickname, + // password, + // identity, + // ready_timeout, + // }; + // let snapshot = session.connect(cfg).await?; + } + Err(err) => { + tracing::error!(err = %err, "example lookup failed"); + std::process::exit(1); + } + } +} diff --git a/crates/chanora_resolver/src/lib.rs b/crates/chanora_resolver/src/lib.rs new file mode 100644 index 0000000..b3184b1 --- /dev/null +++ b/crates/chanora_resolver/src/lib.rs @@ -0,0 +1,1316 @@ +use anyhow::{bail, Context, Result}; +use hickory_resolver::net::runtime::TokioRuntimeProvider; +use hickory_resolver::proto::rr::rdata::SRV; +use hickory_resolver::TokioResolver; +use reqwest::Client; +use std::{fmt, net::IpAddr}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + time::{timeout, Duration}, +}; +use tracing::{debug, info, warn}; +use tracing_subscriber::filter::LevelFilter; + +const DEFAULT_TS3_PROTO: &str = "udp"; +const DEFAULT_TSDNS_PROTO: &str = "tcp"; +const NICK_RESOLVE_URL: &str = "https://named.myteamspeak.com/lookup"; +const TSDNS_PORT: u16 = 41144; +const TSDNS_TERMINATOR: &[u8] = b"\n\r\r\r\n"; +const TSDNS_TIMEOUT: Duration = Duration::from_secs(3); +pub const DEFAULT_TEAMSPEAK_PORT: u16 = 9987; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Args { + pub host: String, + pub service: String, + pub protocol: String, +} + +#[derive(Debug, Clone)] +pub struct BuildInfo { + pub version: String, + pub commit: String, + pub date: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SrvRecord { + pub priority: u16, + pub weight: u16, + pub port: u16, + pub target: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientResolutionMethod { + Direct, + Ts3Srv, + TsdnsSrv, + TsdnsTcp, + Dns, + Nick, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientResolution { + pub input: String, + pub address: String, + pub method: ClientResolutionMethod, + pub resolution: Option, +} + +impl SrvRecord { + pub fn resolved(&self) -> String { + format_host_port(self.target.trim_end_matches('.'), self.port) + } +} + +impl Resolution { + pub fn connection_address(&self) -> String { + match self { + Resolution::Dns { selected, .. } => { + format_host_port(&selected.to_string(), DEFAULT_TEAMSPEAK_PORT) + } + Resolution::Srv { selected, .. } | Resolution::Nick { selected, .. } => { + selected.resolved() + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Resolution { + Dns { + host: String, + addresses: Vec, + selected: IpAddr, + }, + Srv { + service: String, + host: String, + protocol: String, + records: Vec, + selected: SrvRecord, + }, + Nick { + nick: String, + address: String, + protocol: String, + records: Vec, + selected: SrvRecord, + }, +} + +#[derive(Debug, Clone)] +pub struct ChanoraResolver { + resolver: Option, + http: Client, +} + +impl fmt::Display for BuildInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{{version: {}, commit: {}, date: {}}}", + self.version, self.commit, self.date + ) + } +} + +pub fn build_info() -> BuildInfo { + BuildInfo { + version: option_env!("CHANORA_RESOLVER_BUILD_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string(), + commit: option_env!("CHANORA_RESOLVER_BUILD_COMMIT") + .unwrap_or("unknown") + .to_string(), + date: option_env!("CHANORA_RESOLVER_BUILD_DATE") + .unwrap_or("unknown") + .to_string(), + } +} + +pub fn setup_log() { + let level = std::env::var("CHANORA_RESOLVER_LOG_LEVEL").unwrap_or_else(|_| "info".to_string()); + let filter = match level.to_lowercase().as_str() { + "debug" => LevelFilter::DEBUG, + "info" => LevelFilter::INFO, + "warn" => LevelFilter::WARN, + "error" => LevelFilter::ERROR, + _ => LevelFilter::INFO, + }; + + let _ = tracing_subscriber::fmt() + .with_max_level(filter) + .with_ansi(false) + .with_target(false) + .without_time() + .with_writer(std::io::stdout) + .try_init(); +} + +impl ChanoraResolver { + pub fn new() -> Result { + let resolver = match srv_resolver_builder() { + Ok(builder) => match builder.build() { + Ok(resolver) => Some(resolver), + Err(err) => { + warn!(err = %err, "srv resolver unavailable"); + None + } + }, + Err(err) => { + warn!(err = %err, "srv resolver unavailable"); + None + } + }; + + Ok(Self { + resolver, + http: Client::new(), + }) + } + + pub async fn resolve(&self, args: &Args) -> Result { + let normalized = normalize_args(args.clone()); + validate_args(&normalized)?; + + debug!(build = %build_info(), "app start"); + debug!( + host = %normalized.host, + service = %normalized.service, + protocol = %normalized.protocol, + "normalized flags" + ); + + self.resolve_normalized(&normalized).await + } + + pub async fn resolve_connection_address(&self, args: &Args) -> Result { + Ok(self.resolve(args).await?.connection_address()) + } + + pub async fn resolve_client_address(&self, input: &str) -> Result { + Ok(self.resolve_client_request(input).await?.address) + } + + pub async fn resolve_client_request(&self, input: &str) -> Result { + let request = normalize_client_input(input)?; + let host = request.host; + info!(input = %input, host = %host, "client request resolution started"); + + if request.port.is_none() && should_try_server_name(&host) { + match self.lookup_server_name(&host).await { + Ok(Some(alias)) => { + info!( + input = %input, + alias_host = %alias.host, + alias_port = ?alias.port, + "server name resolved" + ); + return self + .resolve_client_host( + input, + alias.host, + match alias.port { + PortResolution::Explicit(port) => Some(port), + PortResolution::KeepInput => None, + }, + true, + ) + .await; + } + Ok(None) if is_bare_numeric_name(&host) => { + bail!("could not resolve TeamSpeak server name '{host}': not found"); + } + Ok(None) => {} + Err(err) if is_bare_numeric_name(&host) => { + bail!("could not resolve TeamSpeak server name '{host}': {err}"); + } + Err(err) => { + debug!(err = %err, "server name lookup failed, continuing with host resolution"); + } + } + } + + self.resolve_client_host(input, host, request.port, false) + .await + } + + async fn resolve_client_host( + &self, + input: &str, + host: String, + port: Option, + from_server_name: bool, + ) -> Result { + let mut errors = Vec::new(); + let fallback_port = port.unwrap_or(DEFAULT_TEAMSPEAK_PORT); + + if host.parse::().is_ok() { + return Ok(client_resolution_with_optional_resolution( + input, + if from_server_name { + ClientResolutionMethod::Nick + } else { + ClientResolutionMethod::Direct + }, + format_host_port(&host, fallback_port), + None, + )); + } + + let direct = if is_bare_numeric_name(&host) { + errors.push("dns: skipped bare numeric server name".to_string()); + None + } else { + match self.resolve_dns(&host).await { + Ok(resolution) => Some(resolution), + Err(err) => { + errors.push(format!("dns: {err}")); + None + } + } + }; + + match self.resolve_ts3(&host, "").await { + Ok(resolution) => { + let selected = match &resolution { + Resolution::Srv { selected, .. } => selected.clone(), + _ => unreachable!("ts3 resolution must be an srv result"), + }; + return self + .client_srv_resolution( + input, + ClientResolutionMethod::Ts3Srv, + &selected, + resolution, + ) + .await; + } + Err(err) => errors.push(format!("ts3 srv: {err}")), + } + + match self + .resolve_tsdns_srv_candidates(input, &host, fallback_port) + .await + { + Ok(resolution) => return Ok(resolution), + Err(err) => errors.push(format!("tsdns srv: {err}")), + } + + match self + .resolve_tsdns_tcp_candidates(input, &host, fallback_port) + .await + { + Ok(resolution) => return Ok(resolution), + Err(err) => errors.push(format!("tsdns tcp: {err}")), + } + + if let Some(resolution) = direct { + let selected = match &resolution { + Resolution::Dns { selected, .. } => *selected, + _ => unreachable!("direct fallback must be a dns resolution"), + }; + return Ok(client_resolution_with_address( + input, + if from_server_name { + ClientResolutionMethod::Nick + } else { + ClientResolutionMethod::Dns + }, + format_host_port(&selected.to_string(), fallback_port), + resolution, + )); + } + + bail!( + "could not resolve TeamSpeak address '{host}': {}", + errors.join("; ") + ) + } + + async fn client_srv_resolution( + &self, + input: &str, + method: ClientResolutionMethod, + selected: &SrvRecord, + resolution: Resolution, + ) -> Result { + let target = selected.target.trim_end_matches('.'); + let addresses = self + .lookup_ips(target, selected.port) + .await + .with_context(|| format!("srv target dns lookup failed for {target}"))?; + if addresses.is_empty() { + bail!("no addresses found for srv target: {target}"); + } + + Ok(client_resolution_with_address( + input, + method, + format_host_port(&addresses[0].to_string(), selected.port), + resolution, + )) + } + + async fn resolve_tsdns_srv_candidates( + &self, + input: &str, + query_host: &str, + fallback_port: u16, + ) -> Result { + let mut errors = Vec::new(); + for srv_host in tsdns_candidate_hosts(query_host) { + match self.resolve_tsdns(&srv_host, "").await { + Ok(resolution) => { + return self + .client_tsdns_srv_resolution(input, query_host, fallback_port, resolution) + .await; + } + Err(err) => errors.push(format!("{srv_host}: {err}")), + } + } + + bail!("{}", errors.join("; ")) + } + + async fn client_tsdns_srv_resolution( + &self, + input: &str, + query_host: &str, + fallback_port: u16, + resolution: Resolution, + ) -> Result { + let selected = match &resolution { + Resolution::Srv { selected, .. } => selected, + _ => bail!("tsdns srv resolution must be an srv result"), + }; + let server = selected.target.trim_end_matches('.'); + let endpoint = self + .query_tsdns_server(server, selected.port, query_host) + .await?; + self.client_resolved_tsdns_endpoint(input, endpoint, fallback_port, Some(resolution)) + .await + } + + async fn resolve_tsdns_tcp_candidates( + &self, + input: &str, + query_host: &str, + fallback_port: u16, + ) -> Result { + let mut errors = Vec::new(); + for server in tsdns_candidate_hosts(query_host) { + match self + .query_tsdns_server(&server, TSDNS_PORT, query_host) + .await + { + Ok(endpoint) => { + return self + .client_resolved_tsdns_endpoint(input, endpoint, fallback_port, None) + .await; + } + Err(err) => errors.push(format!("{server}: {err}")), + } + } + + bail!("{}", errors.join("; ")) + } + + async fn client_resolved_tsdns_endpoint( + &self, + input: &str, + endpoint: ServerNameResolution, + fallback_port: u16, + resolution: Option, + ) -> Result { + let method = if resolution.is_some() { + ClientResolutionMethod::TsdnsSrv + } else { + ClientResolutionMethod::TsdnsTcp + }; + let port = endpoint.port.resolve(fallback_port); + let address = if endpoint.host.parse::().is_ok() { + format_host_port(&endpoint.host, port) + } else { + let addresses = self + .lookup_ips(&endpoint.host, port) + .await + .with_context(|| format!("dns lookup failed for tsdns result {}", endpoint.host))?; + if addresses.is_empty() { + bail!("no addresses found for tsdns result: {}", endpoint.host); + } + format_host_port(&addresses[0].to_string(), port) + }; + + Ok(client_resolution_with_optional_resolution( + input, method, address, resolution, + )) + } + + async fn query_tsdns_server( + &self, + server: &str, + port: u16, + query_host: &str, + ) -> Result { + let addresses = self.lookup_ips(server, port).await?; + let mut errors = Vec::new(); + + for addr in addresses { + let socket = std::net::SocketAddr::new(addr, port); + match self.query_tsdns_socket(socket, query_host).await { + Ok(endpoint) => return Ok(endpoint), + Err(err) => errors.push(format!("{socket}: {err}")), + } + } + + bail!("{}", errors.join("; ")) + } + + async fn query_tsdns_socket( + &self, + socket: std::net::SocketAddr, + query_host: &str, + ) -> Result { + let mut stream = timeout(TSDNS_TIMEOUT, TcpStream::connect(socket)) + .await + .context("tsdns connect timed out")? + .context("tsdns connect failed")?; + + let mut request = query_host.trim().to_lowercase().into_bytes(); + request.extend_from_slice(TSDNS_TERMINATOR); + timeout(TSDNS_TIMEOUT, stream.write_all(&request)) + .await + .context("tsdns write timed out")? + .context("tsdns write failed")?; + + let mut buffer = vec![0; 512]; + let size = timeout(TSDNS_TIMEOUT, stream.read(&mut buffer)) + .await + .context("tsdns read timed out")? + .context("tsdns read failed")?; + buffer.truncate(size); + + let value = String::from_utf8(buffer).context("tsdns response returned invalid utf-8")?; + let value = value.trim(); + if value.is_empty() { + bail!("empty tsdns response"); + } + if value == "404" { + bail!("tsdns name not found"); + } + + parse_resolved_server_name(value) + } + + async fn lookup_server_name(&self, name: &str) -> Result> { + if name.is_empty() { + bail!("host cannot be empty"); + } + + info!(name = %name, "trying to resolve server name"); + let value = lookup_named_value(&self.http, name, true, "server name") + .await? + .ok_or_else(|| anyhow::anyhow!("server name lookup returned no data"))?; + let endpoint = parse_resolved_server_name(value.trim())?; + info!( + name = %name, + host = %endpoint.host, + port = ?endpoint.port, + "server name successfully resolved" + ); + Ok(Some(endpoint)) + } + + async fn resolve_normalized(&self, args: &Args) -> Result { + match args.service.as_str() { + "dns" => self.resolve_dns(&args.host).await, + "ts3" => self.resolve_ts3(&args.host, &args.protocol).await, + "tsdns" => self.resolve_tsdns(&args.host, &args.protocol).await, + "nick" => self.resolve_nick(&args.host, &args.protocol).await, + other => bail!("unsupported service: {other}"), + } + } + + pub async fn resolve_dns(&self, host: &str) -> Result { + info!(host = %host, service = "dns", "dns lookup started"); + let lookup = tokio::net::lookup_host((host, DEFAULT_TEAMSPEAK_PORT)) + .await + .context("dns lookup failed")?; + let addrs = collect_ips(lookup); + + if addrs.is_empty() { + bail!("no addresses found for host: {host}"); + } + + debug!(host = %host, addresses = ?addrs, "dns lookup successful"); + info!(host = %host, addr = %addrs[0], "dns lookup result"); + Ok(Resolution::Dns { + host: host.to_string(), + selected: addrs[0], + addresses: addrs, + }) + } + + pub async fn resolve_ts3(&self, host: &str, proto: &str) -> Result { + self.resolve_srv("ts3", DEFAULT_TS3_PROTO, host, proto) + .await + } + + pub async fn resolve_tsdns(&self, host: &str, proto: &str) -> Result { + self.resolve_srv("tsdns", DEFAULT_TSDNS_PROTO, host, proto) + .await + } + + pub async fn resolve_nick(&self, nick: &str, proto: &str) -> Result { + let proto = if proto.is_empty() { + DEFAULT_TS3_PROTO + } else { + proto + }; + if proto == DEFAULT_TS3_PROTO { + debug!(protocol = %proto, "using default protocol for nick srv lookup"); + } + + info!(nick = %nick, service = "nick", "nick lookup started"); + let addr = self.lookup_nick(nick).await?; + if addr.is_empty() { + bail!("no address found for nick: {nick}"); + } + + debug!(nick = %nick, address = %addr, "nick lookup successful"); + let records = self + .lookup_srv("ts3", proto, &addr) + .await + .context("ts3 srv lookup for nick failed")?; + if records.is_empty() { + bail!("no srv records found for nick: {nick}"); + } + + debug!(nick = %nick, srv_records = ?records, "ts3 srv lookup for nick successful"); + info!( + nick = %nick, + resolved = %records[0].resolved(), + "nick lookup result" + ); + Ok(Resolution::Nick { + nick: nick.to_string(), + address: addr, + protocol: proto.to_string(), + selected: records[0].clone(), + records, + }) + } + + async fn resolve_srv( + &self, + service: &str, + default_proto: &str, + host: &str, + proto: &str, + ) -> Result { + let proto = if proto.is_empty() { + default_proto + } else { + proto + }; + if proto == default_proto { + debug!(protocol = %proto, "using default protocol for srv lookup"); + } + + info!( + host = %host, + service = %service, + protocol = %proto, + "{} srv lookup started", + service + ); + let records = self.lookup_srv(service, proto, host).await?; + if records.is_empty() { + if service == "ts3" { + bail!("no SRV records found for host: {host}"); + } + bail!("no srv records found for host: {host}"); + } + + debug!( + host = %host, + srv_records = ?records, + "{} srv lookup successful", + service + ); + info!( + host = %host, + resolved = %records[0].resolved(), + "{} srv lookup result", + service + ); + Ok(Resolution::Srv { + service: service.to_string(), + host: host.to_string(), + protocol: proto.to_string(), + selected: records[0].clone(), + records, + }) + } + + async fn lookup_srv(&self, service: &str, proto: &str, host: &str) -> Result> { + if host.is_empty() { + bail!("host cannot be empty"); + } + + if service == "ts3" && proto == "tcp" { + bail!("srv lookup for ts3 over tcp is not supported"); + } + + let query = format!("_{service}._{proto}.{host}"); + let resolver = self.resolver.as_ref().context("srv resolver unavailable")?; + let result = resolver + .srv_lookup(query) + .await + .context("srv lookup failed")?; + let mut records = result + .answers() + .iter() + .filter_map(|record| { + record.try_borrow::().map(|record| { + let data = record.data(); + SrvRecord { + priority: data.priority, + weight: data.weight, + port: data.port, + target: data.target.to_string(), + } + }) + }) + .collect::>(); + sort_srv_records(&mut records); + Ok(records) + } + + async fn lookup_ips(&self, host: &str, port: u16) -> Result> { + let lookup = tokio::net::lookup_host((host, port)) + .await + .context("dns lookup failed")?; + Ok(collect_ips(lookup)) + } + + async fn lookup_nick(&self, nick: &str) -> Result { + if nick.is_empty() { + bail!("host cannot be empty"); + } + + let value = lookup_named_value(&self.http, nick, false, "nick") + .await? + .ok_or_else(|| anyhow::anyhow!("no data returned for nick: {nick}"))?; + + if value.trim().is_empty() { + bail!("no data returned for nick: {nick}"); + } + + Ok(value) + } +} + +#[cfg(target_os = "android")] +fn srv_resolver_builder() -> Result> { + use hickory_resolver::config::{ResolverConfig, CLOUDFLARE}; + + Ok(TokioResolver::builder_with_config( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + TokioRuntimeProvider::default(), + )) +} + +#[cfg(not(target_os = "android"))] +fn srv_resolver_builder() -> Result> { + TokioResolver::builder_tokio().context("failed to initialize DNS resolver") +} + +pub fn normalize_args(mut args: Args) -> Args { + args.host = args.host.trim().to_lowercase(); + args.service = args.service.trim().to_lowercase(); + args.protocol = args.protocol.trim().to_lowercase(); + args +} + +pub fn validate_args(args: &Args) -> Result<()> { + if args.host.is_empty() { + bail!("host cannot be empty"); + } + + match args.service.as_str() { + "dns" | "ts3" | "tsdns" | "nick" => {} + "" => bail!("service cannot be empty"), + other => bail!("unsupported service: {other}"), + } + + match args.protocol.as_str() { + "" | "udp" | "tcp" => {} + other => bail!("unsupported protocol: {other}"), + } + + Ok(()) +} + +pub async fn run(args: Args) -> Result<()> { + let client = ChanoraResolver::new()?; + let normalized = normalize_args(args); + validate_args(&normalized)?; + + debug!(build = %build_info(), "app start"); + debug!( + host = %normalized.host, + service = %normalized.service, + protocol = %normalized.protocol, + "normalized flags" + ); + + client.resolve_normalized(&normalized).await.map(|_| ()) +} + +fn collect_ips(lookup: impl Iterator) -> Vec { + let mut addrs = lookup.map(|addr| addr.ip()).collect::>(); + addrs.sort_by_key(|addr| match addr { + IpAddr::V4(_) => 0u8, + IpAddr::V6(_) => 1u8, + }); + addrs.dedup(); + addrs +} + +fn sort_srv_records(records: &mut [SrvRecord]) { + records.sort_by(|left, right| { + left.priority + .cmp(&right.priority) + .then_with(|| right.weight.cmp(&left.weight)) + .then_with(|| left.target.cmp(&right.target)) + .then_with(|| left.port.cmp(&right.port)) + }); +} + +fn format_host_port(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +fn client_resolution_with_address( + input: &str, + method: ClientResolutionMethod, + address: String, + resolution: Resolution, +) -> ClientResolution { + client_resolution_with_optional_resolution(input, method, address, Some(resolution)) +} + +fn client_resolution_with_optional_resolution( + input: &str, + method: ClientResolutionMethod, + address: String, + resolution: Option, +) -> ClientResolution { + info!( + input = %input, + method = ?method, + addr = %address, + "client request resolution result" + ); + ClientResolution { + input: input.to_string(), + address, + method, + resolution, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClientRequest { + host: String, + port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServerNameResolution { + host: String, + port: PortResolution, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PortResolution { + Explicit(u16), + KeepInput, +} + +impl PortResolution { + fn resolve(&self, fallback: u16) -> u16 { + match self { + PortResolution::Explicit(port) => *port, + PortResolution::KeepInput => fallback, + } + } +} + +fn parse_resolved_server_name(value: &str) -> Result { + let value = value.trim().trim_matches('/'); + if value.is_empty() { + bail!("resolved server name cannot be empty"); + } + + let value = strip_ts3server_scheme(value); + let (authority, query_port) = split_authority_and_port(value)?; + let authority = authority.trim_matches('/'); + if authority.is_empty() { + bail!("resolved server name cannot be empty"); + } + + let authority = authority.trim().to_lowercase(); + let (host, explicit_port) = match parse_host_port_components(&authority, true)? { + Some((host, port)) => (host, port), + None => ( + authority, + query_port + .map(PortResolution::Explicit) + .unwrap_or(PortResolution::KeepInput), + ), + }; + + Ok(ServerNameResolution { + host, + port: match explicit_port { + PortResolution::KeepInput => query_port + .map(PortResolution::Explicit) + .unwrap_or(PortResolution::KeepInput), + explicit => explicit, + }, + }) +} + +fn should_try_server_name(host: &str) -> bool { + !host.contains('.') && host.parse::().is_err() +} + +fn tsdns_candidate_hosts(host: &str) -> Vec { + let labels = host + .trim_end_matches('.') + .split('.') + .filter(|label| !label.is_empty()) + .collect::>(); + if labels.is_empty() { + return Vec::new(); + } + + let mut candidates = Vec::new(); + if labels.len() >= 2 { + candidates.push(labels[labels.len() - 2..].join(".")); + } + + let full = labels.join("."); + if !candidates.iter().any(|candidate| candidate == &full) { + candidates.push(full); + } + candidates +} + +async fn lookup_named_value( + http: &Client, + name: &str, + allow_not_found: bool, + label: &str, +) -> Result> { + let response = http + .get(NICK_RESOLVE_URL) + .query(&[("name", name)]) + .send() + .await + .with_context(|| format!("{label} lookup failed"))?; + read_lookup_body(response, allow_not_found, label).await +} + +async fn read_lookup_body( + response: reqwest::Response, + allow_not_found: bool, + label: &str, +) -> Result> { + if allow_not_found && response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + bail!("unexpected status code: {}", response.status()); + } + + let bytes = response + .bytes() + .await + .with_context(|| format!("{label} lookup failed"))?; + if bytes.is_empty() { + return Ok(None); + } + + let value = String::from_utf8(bytes.to_vec()) + .with_context(|| format!("{label} lookup returned invalid utf-8"))?; + Ok(Some(value)) +} + +fn normalize_client_input(input: &str) -> Result { + let value = input.trim(); + if value.is_empty() { + bail!("host cannot be empty"); + } + + let value = strip_ts3server_scheme(value); + let (authority, query_port) = split_authority_and_port(value)?; + let authority = authority.trim_matches('/'); + if authority.is_empty() { + bail!("host cannot be empty"); + } + + let authority = authority.trim().to_lowercase(); + let (host, explicit_port) = parse_host_port_components(&authority, false)? + .unwrap_or((authority, PortResolution::KeepInput)); + + Ok(ClientRequest { + host, + port: match explicit_port { + PortResolution::Explicit(port) => Some(port), + PortResolution::KeepInput => query_port, + }, + }) +} + +fn strip_ts3server_scheme(value: &str) -> &str { + value + .get(..12) + .filter(|prefix| prefix.eq_ignore_ascii_case("ts3server://")) + .map(|_| &value[12..]) + .unwrap_or(value) +} + +fn split_authority_and_port(value: &str) -> Result<(&str, Option)> { + let Some((authority, query)) = value.split_once('?') else { + return Ok((value, None)); + }; + Ok((authority, query_port(query)?)) +} + +fn query_port(query: &str) -> Result> { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + if key.eq_ignore_ascii_case("port") { + return Ok(Some(parse_port(value)?)); + } + } + Ok(None) +} + +fn parse_host_port_components( + input: &str, + allow_keep_input: bool, +) -> Result> { + if let Some(rest) = input.strip_prefix('[') { + let Some(end) = rest.find(']') else { + bail!("invalid bracketed ipv6 address"); + }; + let host = &rest[..end]; + let suffix = &rest[end + 1..]; + if suffix.is_empty() { + return Ok(Some((host.to_string(), PortResolution::KeepInput))); + } + let Some(port) = suffix.strip_prefix(':') else { + bail!("invalid bracketed ipv6 address"); + }; + return Ok(Some(( + host.to_string(), + parse_resolved_port(port, allow_keep_input)?, + ))); + } + + let Some((host, port)) = input.rsplit_once(':') else { + return Ok(None); + }; + + if host.contains(':') { + return Ok(None); + } + + Ok(Some(( + host.to_string(), + parse_resolved_port(port, allow_keep_input)?, + ))) +} + +fn is_bare_numeric_name(host: &str) -> bool { + !host.is_empty() && host.chars().all(|value| value.is_ascii_digit()) +} + +fn parse_port(value: &str) -> Result { + if value.is_empty() { + bail!("port cannot be empty"); + } + value + .parse::() + .with_context(|| format!("invalid port: {value}")) +} + +fn parse_resolved_port(value: &str, allow_keep_input: bool) -> Result { + if value.eq_ignore_ascii_case("$PORT") { + if !allow_keep_input { + bail!("port placeholder is not allowed in client input"); + } + return Ok(PortResolution::KeepInput); + } + Ok(PortResolution::Explicit(parse_port(value)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_args_trims_and_lowercases() { + let args = normalize_args(Args { + host: " Example.COM ".into(), + service: " DNS ".into(), + protocol: " TCP ".into(), + }); + + assert_eq!( + args, + Args { + host: "example.com".into(), + service: "dns".into(), + protocol: "tcp".into(), + } + ); + } + + #[test] + fn validate_args_rejects_bad_service_and_proto() { + let err = validate_args(&Args { + host: "example.com".into(), + service: "bad".into(), + protocol: "".into(), + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported service")); + + let err = validate_args(&Args { + host: "example.com".into(), + service: "dns".into(), + protocol: "bad".into(), + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported protocol")); + } + + #[test] + fn srv_records_sort_by_preferred_order() { + let mut records = vec![ + SrvRecord { + priority: 20, + weight: 10, + port: 9987, + target: "late.example.com.".into(), + }, + SrvRecord { + priority: 10, + weight: 5, + port: 9987, + target: "fallback.example.com.".into(), + }, + SrvRecord { + priority: 10, + weight: 20, + port: 9987, + target: "voice.example.com.".into(), + }, + ]; + + sort_srv_records(&mut records); + + assert_eq!(records[0].target, "voice.example.com."); + assert_eq!(records[1].target, "fallback.example.com."); + assert_eq!(records[2].target, "late.example.com."); + } + + #[test] + fn resolution_exposes_connection_address() { + let dns = Resolution::Dns { + host: "localhost".into(), + addresses: vec!["::1".parse().unwrap()], + selected: "::1".parse().unwrap(), + }; + assert_eq!(dns.connection_address(), "[::1]:9987"); + + let srv = Resolution::Srv { + service: "ts3".into(), + host: "example.com".into(), + protocol: "udp".into(), + records: vec![SrvRecord { + priority: 0, + weight: 0, + port: 9988, + target: "voice.example.com.".into(), + }], + selected: SrvRecord { + priority: 0, + weight: 0, + port: 9988, + target: "voice.example.com.".into(), + }, + }; + assert_eq!(srv.connection_address(), "voice.example.com:9988"); + } + + #[tokio::test] + async fn tsdns_endpoint_preserves_srv_method_metadata() { + let resolver = ChanoraResolver { + resolver: None, + http: Client::new(), + }; + let endpoint = ServerNameResolution { + host: "127.0.0.1".into(), + port: PortResolution::Explicit(9988), + }; + let srv_resolution = Resolution::Srv { + service: "tsdns".into(), + host: "example.com".into(), + protocol: "tcp".into(), + records: vec![SrvRecord { + priority: 0, + weight: 0, + port: TSDNS_PORT, + target: "tsdns.example.com.".into(), + }], + selected: SrvRecord { + priority: 0, + weight: 0, + port: TSDNS_PORT, + target: "tsdns.example.com.".into(), + }, + }; + + let via_srv = resolver + .client_resolved_tsdns_endpoint( + "example.com", + endpoint.clone(), + DEFAULT_TEAMSPEAK_PORT, + Some(srv_resolution), + ) + .await + .unwrap(); + assert_eq!(via_srv.method, ClientResolutionMethod::TsdnsSrv); + + let via_tcp = resolver + .client_resolved_tsdns_endpoint("example.com", endpoint, DEFAULT_TEAMSPEAK_PORT, None) + .await + .unwrap(); + assert_eq!(via_tcp.method, ClientResolutionMethod::TsdnsTcp); + } + + #[test] + fn normalize_client_input_handles_chanora_request_shapes() { + assert_eq!( + normalize_client_input(" Voice.Example.com ").unwrap(), + ClientRequest { + host: "voice.example.com".into(), + port: None, + } + ); + assert_eq!( + normalize_client_input("voice.example.com:9988").unwrap(), + ClientRequest { + host: "voice.example.com".into(), + port: Some(9988), + } + ); + assert_eq!( + normalize_client_input("ts3server://voice.example.com?port=9989").unwrap(), + ClientRequest { + host: "voice.example.com".into(), + port: Some(9989), + } + ); + assert_eq!( + normalize_client_input("[::1]:9987").unwrap(), + ClientRequest { + host: "::1".into(), + port: Some(9987), + } + ); + assert_eq!( + normalize_client_input("[::1]").unwrap(), + ClientRequest { + host: "::1".into(), + port: None, + } + ); + } + + #[test] + fn bare_numeric_names_are_not_dns_hosts() { + assert!(is_bare_numeric_name("6666")); + assert!(!is_bare_numeric_name("127.0.0.1")); + assert!(!is_bare_numeric_name("voice6666")); + } + + #[test] + fn parse_resolved_server_name_accepts_host_and_port_shapes() { + assert_eq!( + parse_resolved_server_name("teamspeak.app").unwrap(), + ServerNameResolution { + host: "teamspeak.app".into(), + port: PortResolution::KeepInput, + } + ); + assert_eq!( + parse_resolved_server_name("185.250.249.77:10075").unwrap(), + ServerNameResolution { + host: "185.250.249.77".into(), + port: PortResolution::Explicit(10075), + } + ); + assert_eq!( + parse_resolved_server_name("ts3server://Voice.TeamSpeak.com?port=9989").unwrap(), + ServerNameResolution { + host: "voice.teamspeak.com".into(), + port: PortResolution::Explicit(9989), + } + ); + assert_eq!( + parse_resolved_server_name("voice.teamspeak.com:$PORT").unwrap(), + ServerNameResolution { + host: "voice.teamspeak.com".into(), + port: PortResolution::KeepInput, + } + ); + } + + #[test] + fn server_name_lookup_is_only_for_dotless_hosts() { + assert!(should_try_server_name("6666")); + assert!(should_try_server_name("wwb")); + assert!(!should_try_server_name("voice.teamspeak.com")); + assert!(!should_try_server_name("127.0.0.1")); + } + + #[test] + fn tsdns_candidates_include_parent_then_full_host() { + assert_eq!( + tsdns_candidate_hosts("voice.teamspeak.com"), + vec!["teamspeak.com", "voice.teamspeak.com"] + ); + assert_eq!( + tsdns_candidate_hosts("teamspeak.app"), + vec!["teamspeak.app"] + ); + } +}