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
Generated
+104 -31
View File
@@ -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",
]
+2
View File
@@ -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
+1 -1
View File
@@ -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
+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:?}"),
}
}
}
+11
View File
@@ -0,0 +1,11 @@
/target/
# Local editor and OS files.
.DS_Store
*.swp
*.swo
# Local environment overrides.
.env
.env.*
!.env.example
+13
View File
@@ -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"] }
+104
View File
@@ -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.<host>` SRV. Its target host and port override the user input port, then the target is resolved to a final IP.
5. Try `_tsdns._tcp.<candidate>` 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<br/>without SRV TSDNS"]
A --> B{"How many servers do you have?"}
B -->|One virtual server| C{"Does your server use<br/>the default port 9987?"}
C -->|Yes| D{"Do all services under this domain<br/>run on the same server<br/>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<br/>entering the port manually?"}
F -->|No| E3["Use SRV TS3"]
F -->|Yes| G{"Do all services under this domain<br/>run on the same server<br/>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<br/>for each subdomain"]
H -->|Yes| I{"On how many servers<br/>do you run TS3 servers?"}
I -->|Only one| J{"Do all services under this domain<br/>run on the same server<br/>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<br/>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
```
+25
View File
@@ -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<String> {
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())
}
+229
View File
@@ -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
+102
View File
@@ -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<I, S>(args: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let parsed = parse_args(args)?;
run(parsed).await
}
fn parse_args<I, S>(args: I) -> Result<Args>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<I>(iter: &mut std::iter::Peekable<I>, flag: &str) -> Result<String>
where
I: Iterator<Item = String>,
{
iter.next()
.ok_or_else(|| anyhow!("missing value for {flag} flag"))
}
fn print_help() {
println!("chanora-resolver -host <host> -service <dns|ts3|tsdns|nick> -protocol <udp|tcp>");
}
#[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(),
}
);
}
}
@@ -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);
}
}
}
File diff suppressed because it is too large Load Diff