feat: add TeamSpeak address resolver
This commit is contained in:
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user