feat: add TeamSpeak address resolver
This commit is contained in:
Generated
+104
-31
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/target/
|
||||
|
||||
# Local editor and OS files.
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Local environment overrides.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user