feat: promote linux native audio path
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target/
|
||||
+3453
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "tsclientlib-channel-query-spike"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
description = "Chanora PoC: inspect live TeamSpeak channel metadata over the full client protocol."
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
time = "0.3"
|
||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
||||
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", package = "tsproto-packets" }
|
||||
@@ -0,0 +1,45 @@
|
||||
# tsclientlib Channel Query Spike
|
||||
|
||||
Chanora proof-of-concept. **Not product code.**
|
||||
|
||||
## Purpose
|
||||
|
||||
Prove what channel metadata can be observed from a normal TeamSpeak client
|
||||
connection to `kr.teamspeak.app` without ServerQuery credentials.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Connects to a TeamSpeak server with `tsclientlib`.
|
||||
2. Subscribes to the server tree and pumps events so the live channel snapshot
|
||||
is populated.
|
||||
3. Prints the visible channel table.
|
||||
4. Resolves a target channel by ID or exact name, or falls back to the current
|
||||
channel.
|
||||
5. Requests the channel description with `channelgetdescription`.
|
||||
6. Prints channel metadata, flags, codec settings, capacity settings, child
|
||||
channels, and current occupants from the live connection state.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run --offline -- \
|
||||
--address kr.teamspeak.app \
|
||||
--nickname ChanoraPoC-ChannelInspector \
|
||||
--target-channel-id 1
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--target-channel-id <cid>`
|
||||
- `--target-channel-name <name>`
|
||||
- `--identity <identity-string>`
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- No ServerQuery login or administrative credentials.
|
||||
- No attempt to bypass permission-gated data.
|
||||
- No persistence of the generated identity.
|
||||
|
||||
## Verification
|
||||
|
||||
See `VERIFICATION.md`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Verification record — `tsclientlib-channel-query-spike`
|
||||
|
||||
## Result
|
||||
|
||||
PASS. On 2026-05-25, the spike connected to `kr.teamspeak.app`, listed the
|
||||
visible channels, resolved channel `1`, requested its description, and printed
|
||||
live channel metadata plus current occupants from a normal TeamSpeak client
|
||||
session.
|
||||
|
||||
## Environment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Date | 2026-05-25 |
|
||||
| Host OS | Linux (x86_64) |
|
||||
| Build profile | dev |
|
||||
| Target server | `kr.teamspeak.app` |
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
cargo run --offline -- \
|
||||
--address kr.teamspeak.app \
|
||||
--nickname ChanoraPoC-ChannelInspector \
|
||||
--target-channel-id 1
|
||||
```
|
||||
|
||||
## What was observed
|
||||
|
||||
- The visible channel table populated successfully from the live client state.
|
||||
- Channel `1` resolved as `Default Channel`.
|
||||
- `channelgetdescription` succeeded and populated the channel description in
|
||||
`tsclientlib` state.
|
||||
- Printed channel data for `Default Channel`:
|
||||
- `cid=1`
|
||||
- parent: `Root (0)`
|
||||
- topic: `Default Channel`
|
||||
- type: `Permanent`
|
||||
- default channel: `Yes`
|
||||
- password-protected: `No`
|
||||
- codec: `OpusMusic`
|
||||
- codec quality: `6`
|
||||
- unencrypted: `No`
|
||||
- max clients: `Unlimited`
|
||||
- max family clients: `Unlimited`
|
||||
- needed talk power: `-1`
|
||||
- GUID: `76789d5c-8aac-4740-9d03-e66f09317153`
|
||||
- icon id: `399174223`
|
||||
- Channel description was visible and included bilingual contact text pointing
|
||||
users to `me@edison.network`.
|
||||
- Current occupancy for channel `1` during the run:
|
||||
- `ChanoraPoC-ChannelInspector`
|
||||
- `EdisonJwa`
|
||||
- `observer`
|
||||
|
||||
## Conclusion
|
||||
|
||||
This spike proves that a normal TeamSpeak client connection can inspect visible
|
||||
channel metadata, request channel descriptions, and enumerate current occupants
|
||||
without ServerQuery credentials.
|
||||
@@ -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