feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
@@ -0,0 +1 @@
/target/
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")
}
}