feat: promote linux native audio path
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use futures::prelude::*;
|
||||
use time::Duration;
|
||||
use tokio::time as tokio_time;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use tsclientlib::data;
|
||||
use tsclientlib::{ChannelId, Connection, DisconnectOptions, Identity, MaxClients, OutCommandExt, StreamItem};
|
||||
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "tsclientlib-channel-query-spike",
|
||||
about = "Chanora PoC: inspect TeamSpeak channel information over the full client protocol."
|
||||
)]
|
||||
struct Args {
|
||||
/// Server address (hostname[:port] or TSDNS name).
|
||||
#[arg(short, long, default_value = "kr.teamspeak.app")]
|
||||
address: String,
|
||||
|
||||
/// Nickname used on the server.
|
||||
#[arg(short, long, default_value = "ChanoraPoC-ChannelInspector")]
|
||||
nickname: String,
|
||||
|
||||
/// Optional TeamSpeak identity string. If omitted, a fresh identity is generated.
|
||||
#[arg(long)]
|
||||
identity: Option<String>,
|
||||
|
||||
/// Server password if required.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// Inspect a specific channel ID.
|
||||
#[arg(long)]
|
||||
target_channel_id: Option<u64>,
|
||||
|
||||
/// Inspect a specific channel name.
|
||||
#[arg(long)]
|
||||
target_channel_name: Option<String>,
|
||||
|
||||
/// Print the visible channel table before detailed inspection.
|
||||
#[arg(long, default_value_t = true)]
|
||||
show_channels: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TargetChannel {
|
||||
id: ChannelId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
info!(target: "channel-query-spike", address = %args.address, nickname = %args.nickname, "starting channel query spike");
|
||||
|
||||
let mut builder = Connection::build(args.address.clone()).name(args.nickname.clone());
|
||||
let identity = match &args.identity {
|
||||
Some(raw) => Identity::new_from_str(raw).context("parsing --identity")?,
|
||||
None => Identity::create(),
|
||||
};
|
||||
builder = builder.identity(identity);
|
||||
if let Some(password) = &args.password {
|
||||
builder = builder.password(password.clone());
|
||||
}
|
||||
|
||||
let mut con = builder.connect().context("dialling server")?;
|
||||
wait_for_initial_state(&mut con).await?;
|
||||
if let Ok(state) = con.get_state() {
|
||||
let _ = state.server.set_subscribed(true).send(&mut con);
|
||||
}
|
||||
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
|
||||
|
||||
let target = {
|
||||
let state = con.get_state().context("reading connection state")?;
|
||||
if args.show_channels {
|
||||
print_channels(state);
|
||||
}
|
||||
resolve_target(&args, state)?
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("=== Detailed Target ===");
|
||||
println!("Target channel id : {}", target.id.0);
|
||||
println!("Target channel name : {}", target.name);
|
||||
|
||||
if let Err(error) = request_channel_description(&mut con, target.id).await {
|
||||
warn!(target: "channel-query-spike", %error, channel_id = target.id.0, "channelgetdescription failed");
|
||||
}
|
||||
|
||||
let state = con.get_state().context("reading updated channel state")?;
|
||||
let channel = state
|
||||
.channels
|
||||
.get(&target.id)
|
||||
.ok_or_else(|| anyhow!("target channel disappeared from state"))?;
|
||||
|
||||
print_channel_summary(state, channel)?;
|
||||
|
||||
con.disconnect(DisconnectOptions::new()).ok();
|
||||
con.events().for_each(|_| futures::future::ready(())).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
|
||||
loop {
|
||||
let event = con
|
||||
.events()
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
|
||||
if matches!(event, StreamItem::BookEvents(_)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
|
||||
let deadline = tokio_time::Instant::now() + budget;
|
||||
loop {
|
||||
let now = tokio_time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = deadline - now;
|
||||
match tokio_time::timeout(remaining, con.events().next()).await {
|
||||
Ok(Some(Ok(_))) => {}
|
||||
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
|
||||
Ok(None) => bail!("event stream ended while pumping events"),
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command(name: &str, args: &[(&str, String)]) -> OutCommand {
|
||||
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
|
||||
for (key, value) in args {
|
||||
command.write_arg(key, value);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<()> {
|
||||
let handle = command.send_with_result(con).context("sending command")?;
|
||||
loop {
|
||||
let item = con
|
||||
.events()
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
|
||||
if let StreamItem::MessageResult(reply, status) = item {
|
||||
if reply == handle {
|
||||
status.map_err(|error| anyhow!("command failed: {error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_channel_description(con: &mut Connection, channel_id: ChannelId) -> Result<()> {
|
||||
request_messages(
|
||||
con,
|
||||
build_command("channelgetdescription", &[("cid", channel_id.0.to_string())]),
|
||||
)
|
||||
.await?;
|
||||
pump_events(con, std::time::Duration::from_millis(350)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_target(args: &Args, state: &data::Connection) -> Result<TargetChannel> {
|
||||
if let Some(channel_id) = args.target_channel_id {
|
||||
let id = ChannelId(channel_id);
|
||||
let channel = state
|
||||
.channels
|
||||
.get(&id)
|
||||
.ok_or_else(|| anyhow!("channel id {} not found in visible state", channel_id))?;
|
||||
return Ok(TargetChannel {
|
||||
id,
|
||||
name: channel.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(name) = &args.target_channel_name {
|
||||
let channel = state
|
||||
.channels
|
||||
.values()
|
||||
.find(|channel| channel.name == *name)
|
||||
.ok_or_else(|| anyhow!("channel name `{name}` not found in visible state"))?;
|
||||
return Ok(TargetChannel {
|
||||
id: channel.id,
|
||||
name: channel.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let own_client = state
|
||||
.clients
|
||||
.get(&state.own_client)
|
||||
.ok_or_else(|| anyhow!("own client missing from state"))?;
|
||||
let channel = state
|
||||
.channels
|
||||
.get(&own_client.channel)
|
||||
.ok_or_else(|| anyhow!("own channel missing from state"))?;
|
||||
Ok(TargetChannel {
|
||||
id: channel.id,
|
||||
name: channel.name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_channels(state: &data::Connection) {
|
||||
println!("=== Visible Channels ===");
|
||||
let mut channels = state.channels.values().collect::<Vec<_>>();
|
||||
channels.sort_by_key(|channel| channel.id.0);
|
||||
for channel in channels {
|
||||
let direct_clients = state
|
||||
.clients
|
||||
.values()
|
||||
.filter(|client| client.channel == channel.id)
|
||||
.count();
|
||||
println!(
|
||||
"- {} | cid={} parent={} type={} clients={} subscribed={} password={}",
|
||||
channel.name,
|
||||
channel.id.0,
|
||||
channel.parent.0,
|
||||
format_channel_type(channel),
|
||||
direct_clients,
|
||||
yes_no(channel.subscribed),
|
||||
option_bool(channel.has_password),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_channel_summary(state: &data::Connection, channel: &data::Channel) -> Result<()> {
|
||||
let parent_name = if channel.parent.0 == 0 {
|
||||
"Root".to_string()
|
||||
} else {
|
||||
state
|
||||
.channels
|
||||
.get(&channel.parent)
|
||||
.map(|parent| parent.name.clone())
|
||||
.unwrap_or_else(|| format!("Unknown ({})", channel.parent.0))
|
||||
};
|
||||
|
||||
let mut direct_clients = state
|
||||
.clients
|
||||
.values()
|
||||
.filter(|client| client.channel == channel.id)
|
||||
.map(|client| format!("{} ({})", client.name, client.id.0))
|
||||
.collect::<Vec<_>>();
|
||||
direct_clients.sort();
|
||||
|
||||
let mut child_channels = state
|
||||
.channels
|
||||
.values()
|
||||
.filter(|candidate| candidate.parent == channel.id)
|
||||
.map(|candidate| format!("{} ({})", candidate.name, candidate.id.0))
|
||||
.collect::<Vec<_>>();
|
||||
child_channels.sort();
|
||||
|
||||
println!();
|
||||
println!("=== Channel Profile ===");
|
||||
println!("Channel ID : {}", channel.id.0);
|
||||
println!("Name : {}", channel.name);
|
||||
println!("Parent : {} ({})", parent_name, channel.parent.0);
|
||||
println!("Topic : {}", channel.topic.as_deref().unwrap_or(""));
|
||||
println!(
|
||||
"Description : {}",
|
||||
channel
|
||||
.optional_data
|
||||
.as_ref()
|
||||
.map(|data| data.description.as_str())
|
||||
.unwrap_or("")
|
||||
);
|
||||
println!("Type : {}", format_channel_type(channel));
|
||||
println!("Order : {}", channel.order.0);
|
||||
println!("Is Default : {}", option_bool(channel.is_default));
|
||||
println!("Has Password : {}", option_bool(channel.has_password));
|
||||
println!("Is Private : {}", option_bool(channel.is_private));
|
||||
println!("Subscribed : {}", yes_no(channel.subscribed));
|
||||
println!("Codec : {:?}", channel.codec);
|
||||
println!(
|
||||
"Codec Quality : {}",
|
||||
channel
|
||||
.codec_quality
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
);
|
||||
println!(
|
||||
"Latency Factor : {}",
|
||||
channel
|
||||
.codec_latency_factor
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
);
|
||||
println!("Unencrypted : {}", option_bool(channel.is_unencrypted));
|
||||
println!("Max Clients : {}", format_max_clients(channel.max_clients));
|
||||
println!(
|
||||
"Max Family Clients : {}",
|
||||
format_max_clients(channel.max_family_clients)
|
||||
);
|
||||
println!(
|
||||
"Delete Delay : {}",
|
||||
channel
|
||||
.delete_delay
|
||||
.map(format_duration)
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
println!(
|
||||
"Needed Talk Power : {}",
|
||||
channel
|
||||
.needed_talk_power
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
);
|
||||
println!("Forced Silence : {}", yes_no(channel.forced_silence));
|
||||
println!(
|
||||
"Phonetic Name : {}",
|
||||
channel.phonetic_name.as_deref().unwrap_or("")
|
||||
);
|
||||
println!(
|
||||
"GUID : {}",
|
||||
channel.guid.as_deref().unwrap_or("Unknown")
|
||||
);
|
||||
println!(
|
||||
"Storage Quota : {}",
|
||||
channel
|
||||
.storage_quota
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
);
|
||||
println!(
|
||||
"Icon ID : {}",
|
||||
channel
|
||||
.icon
|
||||
.map(|value| value.0.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
);
|
||||
println!();
|
||||
println!("=== Occupancy ===");
|
||||
println!("Direct clients : {}", direct_clients.len());
|
||||
println!(
|
||||
"Client list : {}",
|
||||
if direct_clients.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
direct_clients.join(", ")
|
||||
}
|
||||
);
|
||||
println!("Child channels : {}", child_channels.len());
|
||||
println!(
|
||||
"Children list : {}",
|
||||
if child_channels.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
child_channels.join(", ")
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_channel_type(channel: &data::Channel) -> &'static str {
|
||||
match channel.channel_type {
|
||||
tsclientlib::ChannelType::Temporary => "Temporary",
|
||||
tsclientlib::ChannelType::SemiPermanent => "SemiPermanent",
|
||||
tsclientlib::ChannelType::Permanent => "Permanent",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_max_clients(value: Option<MaxClients>) -> String {
|
||||
match value {
|
||||
Some(MaxClients::Unlimited) => "Unlimited".to_string(),
|
||||
Some(MaxClients::Inherited) => "Inherited".to_string(),
|
||||
Some(MaxClients::Limited(limit)) => limit.to_string(),
|
||||
None => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn option_bool(value: Option<bool>) -> &'static str {
|
||||
match value {
|
||||
Some(true) => "Yes",
|
||||
Some(false) => "No",
|
||||
None => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value { "Yes" } else { "No" }
|
||||
}
|
||||
|
||||
fn format_duration(duration: Duration) -> String {
|
||||
let total_ms = duration.whole_milliseconds();
|
||||
if total_ms < 1000 {
|
||||
return format!("{total_ms} ms");
|
||||
}
|
||||
let total_seconds = duration.whole_seconds();
|
||||
let seconds = total_seconds % 60;
|
||||
let minutes = (total_seconds / 60) % 60;
|
||||
let hours = total_seconds / 3600;
|
||||
if hours > 0 {
|
||||
format!("{hours}h {minutes}m {seconds}s")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m {seconds}s")
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user