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
+6
View File
@@ -20,6 +20,12 @@ Authority: [`docs/architecture/proof-of-concept-plan.md`](../docs/architecture/p
| `diagnostics-redaction-spike` | yes | **PASS** — see `diagnostics-redaction-spike/VERIFICATION.md` |
| `audio-capture-playback-spike` (desktop half) | yes | **PASS** — see `audio-capture-playback-spike/VERIFICATION.md` |
| `audio-capture-playback-android-spike` (mobile half) | yes | **PASS** — see `audio-capture-playback-android-spike/VERIFICATION.md` |
| `linux_voice_test` | no (extra Linux backend spike) | **PASS** — see `linux_voice_test/VERIFICATION.md` |
| `ts3-manual-research` | no (extra TS3 protocol research spike) | **PASS** — see `ts3-manual-research/VERIFICATION.md` |
| `tsclientlib-query-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-query-spike/VERIFICATION.md` |
| `tsclientlib-channel-query-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-channel-query-spike/VERIFICATION.md` |
| `tsclientlib-filetransfer-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-filetransfer-spike/VERIFICATION.md` |
| `tsidentity-import-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsidentity-import-spike/VERIFICATION.md` |
## Naming note
+1293
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "linux_voice_test"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
ctrlc = "3.5"
[target.'cfg(target_os = "linux")'.dependencies]
libpulse-binding = "2.30"
libpulse-simple-binding = "2.29"
pipewire = { version = "0.10", features = ["v0_3_44"] }
[target.'cfg(target_os = "windows")'.dependencies]
cpal = "0.16"
+37
View File
@@ -0,0 +1,37 @@
# linux_voice_test
Purpose: prove a Linux-native voice loopback path that enumerates input/output
devices and can target PipeWire or PulseAudio directly instead of relying on
`cpal`'s generic ALSA path.
Scope:
- enumerate Linux capture and playback devices through PipeWire or PulseAudio
- open a selected input and output device pair
- run local microphone-to-speaker loopback at 48 kHz mono f32
Out of scope:
- Chanora product integration
- Opus encode/decode
- jitter buffer, per-user playback controls, or persistent settings
- packaging and service lifecycle
Current validation:
- `cargo check` succeeds on the Fedora-based host after isolating the PoC from
the workspace with a local `[workspace]` table
- `pkg-config --modversion libpipewire-0.3` and `pkg-config --modversion
libspa-0.2` both succeed on that host; `pipewire-0.3` is not the correct
module name
- `cargo run -- --list-devices` succeeds and lists real PipeWire devices on the
host
Reproduction:
```bash
cd poc/linux_voice_test
cargo check
cargo run -- --list-devices
cargo run -- --backend pipewire --input 0 --output 0
```
See `VERIFICATION.md` for the exact commands and observed results captured in
this repository session.
+59
View File
@@ -0,0 +1,59 @@
# linux_voice_test verification
Date: 2026-05-25
Host: Fedora-based Linux sandbox host
## Commands
```bash
pkg-config --modversion libpipewire-0.3
pkg-config --modversion libspa-0.2
cd poc/linux_voice_test
cargo check
cargo run -- --list-devices
```
## Observed results
`pkg-config` resolved the product Linux capture dependencies successfully:
```text
libpipewire-0.3 -> 1.6.5
libspa-0.2 -> 0.2
```
Note: `pipewire-0.3` is not the correct pkg-config module name for the Rust
PipeWire crates used here; the expected module is `libpipewire-0.3`.
`cargo check` completed successfully after adding an empty `[workspace]` table
to `poc/linux_voice_test/Cargo.toml` so the PoC can build independently from
the root workspace.
`cargo run -- --list-devices` completed successfully and printed:
```text
Backend: pipewire
Input devices:
[0] Brio 500 Analog Stereo [43]
[1] Built-in Audio Analog Stereo [34]
Backend: pipewire
Output devices:
[0] Built-in Audio Digital Stereo (IEC958) [46]
[1] Navi 21/23 HDMI/DP Audio Controller Digital Stereo (HDMI 4) [50]
```
## What this does validate
- PipeWire crate dependencies compile on this host
- the host exposes the exact `libpipewire-0.3` and `libspa-0.2` pkg-config
modules used by the product Linux capture backend
- PipeWire node enumeration works for both input and output
- the PoC can target real device node ids reported by the running desktop audio
graph
## What this does not validate
- PulseAudio fallback runtime on this host
- full microphone-to-speaker loopback quality over an extended session
- Chanora product audio-engine integration
- push-to-talk, VAD, Opus, jitter buffering, or per-user playback controls
+29
View File
@@ -0,0 +1,29 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
pub const SAMPLE_RATE: u32 = 48_000;
#[cfg(target_os = "linux")]
pub const CHANNELS: u32 = 1;
#[cfg(target_os = "linux")]
pub const BUFFER_FRAMES: usize = 256;
pub const RING_CAPACITY_SAMPLES: usize = 48_000 * 2;
pub type SharedBuffer = Arc<Mutex<VecDeque<f32>>>;
pub fn make_shared_buffer() -> SharedBuffer {
Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAPACITY_SAMPLES)))
}
pub fn push_sample(buffer: &SharedBuffer, sample: f32) {
let mut queue = buffer.lock().expect("audio buffer mutex poisoned");
if queue.len() >= RING_CAPACITY_SAMPLES {
let _ = queue.pop_front();
}
queue.push_back(sample);
}
pub fn pop_sample(buffer: &SharedBuffer) -> f32 {
let mut queue = buffer.lock().expect("audio buffer mutex poisoned");
queue.pop_front().unwrap_or(0.0)
}
+318
View File
@@ -0,0 +1,318 @@
use crate::audio_buffer::{
make_shared_buffer, pop_sample, push_sample, SharedBuffer, CHANNELS, SAMPLE_RATE,
};
use crate::linux_shared::{
sort_devices, wait_for_startup, LinuxAudioBackend, LinuxDevice, LinuxLoopbackSession,
ThreadedLoopbackSession,
};
use pipewire as pw;
use pw::spa::pod::Pod;
use pw::{properties::properties, spa};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{mpsc, Arc};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct PipeWireBackend;
impl LinuxAudioBackend for PipeWireBackend {
fn name(&self) -> &'static str {
"pipewire"
}
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String> {
list_nodes("Audio/Source")
}
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String> {
list_nodes("Audio/Sink")
}
fn start_loopback(
&self,
input: &LinuxDevice,
output: &LinuxDevice,
stop: Arc<AtomicBool>,
) -> Result<Box<dyn LinuxLoopbackSession>, String> {
let input_id = input
.id
.parse::<u32>()
.map_err(|_| format!("invalid PipeWire input node id `{}`", input.id))?;
let output_id = output
.id
.parse::<u32>()
.map_err(|_| format!("invalid PipeWire output node id `{}`", output.id))?;
let buffer = make_shared_buffer();
let capture = spawn_capture(buffer.clone(), input_id, stop.clone())?;
let playback = match spawn_playback(buffer, output_id, stop.clone()) {
Ok(handle) => handle,
Err(err) => {
stop.store(true, AtomicOrdering::SeqCst);
let _ = capture.join();
return Err(err);
}
};
Ok(Box::new(ThreadedLoopbackSession {
capture,
playback,
stop,
}))
}
}
fn list_nodes(class_prefix: &str) -> Result<Vec<LinuxDevice>, String> {
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let registry = core.get_registry().map_err(|err| err.to_string())?;
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let devices_for_registry = devices.clone();
let class_prefix = class_prefix.to_string();
let class_prefix_for_registry = class_prefix.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |global| {
if global.type_ != pw::types::ObjectType::Node {
return;
}
let Some(props) = global.props else {
return;
};
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
return;
};
if !media_class.starts_with(class_prefix_for_registry.as_str()) {
return;
}
let label = props
.get(*pw::keys::NODE_DESCRIPTION)
.or_else(|| props.get(*pw::keys::NODE_NICK))
.or_else(|| props.get(*pw::keys::NODE_NAME))
.unwrap_or("Unnamed PipeWire node")
.to_string();
devices_for_registry.borrow_mut().push(LinuxDevice {
id: global.id.to_string(),
label,
});
})
.register();
let pending = core.sync(0).map_err(|err| err.to_string())?;
let done_for_core = done.clone();
let loop_for_core = mainloop.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id == pw::core::PW_ID_CORE && seq == pending {
done_for_core.set(true);
loop_for_core.quit();
}
})
.error(move |_id, _seq, _res, message| {
eprintln!("PipeWire core error: {message}");
})
.register();
while !done.get() {
mainloop.run();
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| sort_devices(left, right));
if devices.is_empty() {
return Err(format!("no PipeWire nodes found for {class_prefix}"));
}
Ok(devices)
}
fn spawn_capture(
buffer: SharedBuffer,
source_id: u32,
stop: Arc<AtomicBool>,
) -> Result<JoinHandle<Result<(), String>>, String> {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let handle = thread::spawn(move || {
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = source_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "linux-voice-test-capture", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data(buffer)
.process(|stream, queue| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_count = data_ref.chunk().size() as usize;
let Some(bytes) = data_ref.data() else {
return;
};
for chunk in bytes[..byte_count].chunks_exact(4) {
let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
push_sample(queue, sample);
}
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_format_param()?;
let mut params = [Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop.load(AtomicOrdering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
wait_for_startup(ready_rx, handle, "PipeWire capture")
}
fn spawn_playback(
buffer: SharedBuffer,
sink_id: u32,
stop: Arc<AtomicBool>,
) -> Result<JoinHandle<Result<(), String>>, String> {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let handle = thread::spawn(move || {
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = sink_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "linux-voice-test-playback", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data(buffer)
.process(|stream, queue| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_len = {
let Some(bytes) = data_ref.data() else {
return;
};
for chunk in bytes.chunks_exact_mut(4) {
chunk.copy_from_slice(&pop_sample(queue).to_le_bytes());
}
bytes.len()
};
let chunk = data_ref.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = 4;
*chunk.size_mut() = byte_len as u32;
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_format_param()?;
let mut params = [Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop.load(AtomicOrdering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
wait_for_startup(ready_rx, handle, "PipeWire playback")
}
fn build_format_param() -> Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(SAMPLE_RATE);
audio_info.set_channels(CHANNELS);
let object = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(object),
)
.map_err(|err| err.to_string())
.map(|serializer| serializer.0.into_inner())
}
@@ -0,0 +1,309 @@
use crate::audio_buffer::{
make_shared_buffer, pop_sample, push_sample, SharedBuffer, BUFFER_FRAMES, CHANNELS, SAMPLE_RATE,
};
use crate::linux_shared::{
sort_devices, wait_for_startup, LinuxAudioBackend, LinuxDevice, LinuxLoopbackSession,
ThreadedLoopbackSession,
};
use libpulse_binding as pulse;
use libpulse_simple_binding as psimple;
use pulse::callbacks::ListResult;
use pulse::context::{self, Context};
use pulse::mainloop::threaded::Mainloop;
use pulse::proplist::Proplist;
use pulse::sample::{Format, Spec};
use pulse::stream::Direction;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{mpsc, Arc};
use std::thread::{self, JoinHandle};
pub struct PulseAudioBackend;
impl LinuxAudioBackend for PulseAudioBackend {
fn name(&self) -> &'static str {
"pulseaudio"
}
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String> {
with_pulse_context(|context, mainloop| {
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let done_for_cb = done.clone();
let devices_for_cb = devices.clone();
let mainloop_for_cb = mainloop.clone();
let introspector = context.borrow().introspect();
let _operation = introspector.get_source_info_list(move |result| match result {
ListResult::Item(info) => {
if info.monitor_of_sink.is_some() {
return;
}
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| sort_devices(left, right));
if devices.is_empty() {
return Err("no PulseAudio sources found".to_string());
}
Ok(devices)
})
}
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String> {
with_pulse_context(|context, mainloop| {
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let done_for_cb = done.clone();
let devices_for_cb = devices.clone();
let mainloop_for_cb = mainloop.clone();
let introspector = context.borrow().introspect();
let _operation = introspector.get_sink_info_list(move |result| match result {
ListResult::Item(info) => {
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| sort_devices(left, right));
if devices.is_empty() {
return Err("no PulseAudio sinks found".to_string());
}
Ok(devices)
})
}
fn start_loopback(
&self,
input: &LinuxDevice,
output: &LinuxDevice,
stop: Arc<AtomicBool>,
) -> Result<Box<dyn LinuxLoopbackSession>, String> {
let buffer = make_shared_buffer();
let capture = spawn_capture(buffer.clone(), input.id.clone(), stop.clone())?;
let playback = match spawn_playback(buffer, output.id.clone(), stop.clone()) {
Ok(handle) => handle,
Err(err) => {
stop.store(true, AtomicOrdering::SeqCst);
let _ = capture.join();
return Err(err);
}
};
Ok(Box::new(ThreadedLoopbackSession {
capture,
playback,
stop,
}))
}
}
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
where
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
{
let proplist = build_proplist()?;
let mainloop = Rc::new(RefCell::new(
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
));
let context = Rc::new(RefCell::new(
Context::new_with_proplist(&*mainloop.borrow(), "linux-voice-test", &proplist)
.ok_or("failed to create PulseAudio context")?,
));
let mainloop_for_cb = mainloop.clone();
let context_for_cb = context.clone();
context
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
match state {
context::State::Ready | context::State::Failed | context::State::Terminated => {
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
_ => {}
}
})));
context
.borrow_mut()
.connect(None, context::FlagSet::NOFLAGS, None)
.map_err(pa_error)?;
mainloop.borrow_mut().lock();
mainloop.borrow_mut().start().map_err(pa_error)?;
loop {
match context.borrow().get_state() {
context::State::Ready => break,
context::State::Failed | context::State::Terminated => {
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
return Err(format!(
"PulseAudio context state {:?}",
context.borrow().get_state()
));
}
_ => mainloop.borrow_mut().wait(),
}
}
context.borrow_mut().set_state_callback(None);
let result = operation(&context, &mainloop);
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
result
}
fn spawn_capture(
buffer: SharedBuffer,
source_name: String,
stop: Arc<AtomicBool>,
) -> Result<JoinHandle<Result<(), String>>, String> {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let handle = thread::spawn(move || {
let spec = pulse_spec();
let stream = psimple::Simple::new(
None,
"linux-voice-test",
Direction::Record,
Some(source_name.as_str()),
"linux-voice-test-record",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut bytes = vec![0_u8; BUFFER_FRAMES * std::mem::size_of::<f32>()];
while !stop.load(AtomicOrdering::SeqCst) {
stream.read(&mut bytes).map_err(pa_error)?;
for chunk in bytes.chunks_exact(4) {
let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
push_sample(&buffer, sample);
}
}
stream.flush().map_err(pa_error)?;
Ok(())
});
wait_for_startup(ready_rx, handle, "PulseAudio capture")
}
fn spawn_playback(
buffer: SharedBuffer,
sink_name: String,
stop: Arc<AtomicBool>,
) -> Result<JoinHandle<Result<(), String>>, String> {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let handle = thread::spawn(move || {
let spec = pulse_spec();
let stream = psimple::Simple::new(
None,
"linux-voice-test",
Direction::Playback,
Some(sink_name.as_str()),
"linux-voice-test-playback",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut bytes = vec![0_u8; BUFFER_FRAMES * std::mem::size_of::<f32>()];
while !stop.load(AtomicOrdering::SeqCst) {
for chunk in bytes.chunks_exact_mut(4) {
chunk.copy_from_slice(&pop_sample(&buffer).to_le_bytes());
}
stream.write(&bytes).map_err(pa_error)?;
}
stream.drain().map_err(pa_error)?;
Ok(())
});
wait_for_startup(ready_rx, handle, "PulseAudio playback")
}
fn build_proplist() -> Result<Proplist, String> {
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
proplist
.set_str(
pulse::proplist::properties::APPLICATION_NAME,
"linux-voice-test",
)
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
Ok(proplist)
}
fn pulse_spec() -> Spec {
let spec = Spec {
format: Format::F32le,
channels: CHANNELS as u8,
rate: SAMPLE_RATE,
};
debug_assert!(spec.is_valid());
spec
}
fn pa_error(err: pulse::error::PAErr) -> String {
err.to_string()
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
}
+384
View File
@@ -0,0 +1,384 @@
use crate::linux_pipewire::PipeWireBackend;
use crate::linux_pulseaudio::PulseAudioBackend;
use std::env;
use std::fmt;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::thread::JoinHandle;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct LinuxDevice {
pub id: String,
pub label: String,
}
impl fmt::Display for LinuxDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} [{}]", self.label, self.id)
}
}
pub trait LinuxLoopbackSession {
fn wait(self: Box<Self>) -> Result<(), String>;
}
pub trait LinuxAudioBackend {
fn name(&self) -> &'static str;
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String>;
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String>;
fn start_loopback(
&self,
input: &LinuxDevice,
output: &LinuxDevice,
stop: Arc<AtomicBool>,
) -> Result<Box<dyn LinuxLoopbackSession>, String>;
}
#[derive(Clone, Copy)]
enum LinuxBackendKind {
Auto,
PipeWire,
PulseAudio,
}
const USAGE: &str = concat!(
"usage: cargo run -- [--backend auto|pipewire|pulseaudio] [--list-devices] ",
"[--input <index|id|label>] [--output <index|id|label>]\n\n",
"Examples:\n",
" cargo run -- --list-devices\n",
" cargo run -- --backend pipewire --list-devices\n",
" cargo run -- --input 0 --output 1\n",
" cargo run -- --backend pulseaudio --input alsa_input.pci-0000_00_1f.3.analog-stereo ",
"--output alsa_output.pci-0000_00_1f.3.analog-stereo"
);
pub fn run_from_env() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
let cli = parse_cli(&args)?;
let backend = create_backend(cli.backend)?;
let inputs = backend.list_input_devices()?;
let outputs = backend.list_output_devices()?;
if cli.list_devices {
print_devices(backend.name(), "Input devices:", &inputs);
print_devices(backend.name(), "Output devices:", &outputs);
return Ok(());
}
let input = select_device("input", &inputs, cli.input.as_deref())?;
let output = select_device("output", &outputs, cli.output.as_deref())?;
let stop = Arc::new(AtomicBool::new(false));
let stop_for_handler = stop.clone();
ctrlc::set_handler(move || {
stop_for_handler.store(true, Ordering::SeqCst);
})
.map_err(|err| err.to_string())?;
println!("Backend: {}", backend.name());
println!("Input: {input}");
println!("Output: {output}");
println!("Streaming. Press Ctrl+C to stop.");
let session = backend.start_loopback(&input, &output, stop.clone())?;
while !stop.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
session.wait()
}
struct LinuxCli {
backend: LinuxBackendKind,
list_devices: bool,
input: Option<String>,
output: Option<String>,
}
fn parse_cli(args: &[String]) -> Result<LinuxCli, String> {
let mut backend = LinuxBackendKind::Auto;
let mut list_devices = false;
let mut input = None;
let mut output = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--backend" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| format!("missing value for --backend\n{USAGE}"))?;
backend = match value.as_str() {
"auto" => LinuxBackendKind::Auto,
"pipewire" => LinuxBackendKind::PipeWire,
"pulseaudio" => LinuxBackendKind::PulseAudio,
_ => return Err(format!("unsupported backend `{value}`\n{USAGE}")),
};
}
"--list-devices" => {
list_devices = true;
}
"--input" => {
index += 1;
input = Some(
args.get(index)
.ok_or_else(|| format!("missing value for --input\n{USAGE}"))?
.clone(),
);
}
"--output" => {
index += 1;
output = Some(
args.get(index)
.ok_or_else(|| format!("missing value for --output\n{USAGE}"))?
.clone(),
);
}
"--help" | "-h" => {
return Err(USAGE.to_string());
}
other => {
return Err(format!("unexpected argument `{other}`\n{USAGE}"));
}
}
index += 1;
}
Ok(LinuxCli {
backend,
list_devices,
input,
output,
})
}
fn create_backend(kind: LinuxBackendKind) -> Result<Box<dyn LinuxAudioBackend>, String> {
match kind {
LinuxBackendKind::Auto => {
let mut errors = Vec::new();
match create_backend(LinuxBackendKind::PipeWire) {
Ok(backend) => return Ok(backend),
Err(err) => errors.push(format!("pipewire: {err}")),
}
match create_backend(LinuxBackendKind::PulseAudio) {
Ok(backend) => return Ok(backend),
Err(err) => errors.push(format!("pulseaudio: {err}")),
}
Err(format!(
"failed to initialize a Linux audio backend\n{}",
errors.join("\n")
))
}
LinuxBackendKind::PipeWire => {
pipewire::init();
Ok(Box::new(PipeWireBackend))
}
LinuxBackendKind::PulseAudio => Ok(Box::new(PulseAudioBackend)),
}
}
fn select_device(
kind: &str,
items: &[LinuxDevice],
selector: Option<&str>,
) -> Result<LinuxDevice, String> {
match selector {
Some(selector) => resolve_device_selector(kind, items, selector),
None => prompt_choice(&format!("{kind} devices:"), items),
}
}
fn resolve_device_selector(
kind: &str,
items: &[LinuxDevice],
selector: &str,
) -> Result<LinuxDevice, String> {
if items.is_empty() {
return Err(format!("no {kind} devices available"));
}
if let Ok(index) = selector.parse::<usize>() {
return items
.get(index)
.cloned()
.ok_or_else(|| format!("{kind} device index `{selector}` is out of range"));
}
let mut matches = items
.iter()
.filter(|item| item.id == selector || item.label == selector)
.cloned();
let Some(device) = matches.next() else {
return Err(format!(
"no {kind} device matched `{selector}`\nRun with `--list-devices` to inspect valid choices."
));
};
if matches.next().is_some() {
return Err(format!(
"multiple {kind} devices matched `{selector}`\nUse the numeric index from `--list-devices` or the exact device id."
));
}
Ok(device)
}
fn print_devices(backend_name: &str, title: &str, items: &[LinuxDevice]) {
println!("Backend: {backend_name}");
println!("{title}");
for (index, item) in items.iter().enumerate() {
println!(" [{index}] {item}");
}
}
fn prompt_choice(title: &str, items: &[LinuxDevice]) -> Result<LinuxDevice, String> {
if items.is_empty() {
return Err(format!("no items available for `{title}`"));
}
println!("{title}");
for (index, item) in items.iter().enumerate() {
println!(" [{index}] {item}");
}
print!("Select number: ");
io::stdout().flush().map_err(|err| err.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|err| err.to_string())?;
let choice = input
.trim()
.parse::<usize>()
.map_err(|_| "invalid selection".to_string())?;
items
.get(choice)
.cloned()
.ok_or_else(|| "selection out of range".to_string())
}
pub struct ThreadedLoopbackSession {
pub capture: JoinHandle<Result<(), String>>,
pub playback: JoinHandle<Result<(), String>>,
pub stop: Arc<AtomicBool>,
}
impl LinuxLoopbackSession for ThreadedLoopbackSession {
fn wait(self: Box<Self>) -> Result<(), String> {
let ThreadedLoopbackSession {
capture,
playback,
stop,
} = *self;
stop.store(true, Ordering::SeqCst);
join_backend_thread(capture)?;
join_backend_thread(playback)?;
Ok(())
}
}
pub fn wait_for_startup(
ready_rx: mpsc::Receiver<Result<(), String>>,
handle: JoinHandle<Result<(), String>>,
label: &str,
) -> Result<JoinHandle<Result<(), String>>, String> {
match ready_rx.recv() {
Ok(Ok(())) => Ok(handle),
Ok(Err(err)) => {
let _ = handle.join();
Err(err)
}
Err(err) => {
let _ = handle.join();
Err(format!("{label} startup failed: {err}"))
}
}
}
pub fn join_backend_thread(handle: JoinHandle<Result<(), String>>) -> Result<(), String> {
match handle.join() {
Ok(result) => result,
Err(_) => Err("backend thread panicked".to_string()),
}
}
pub fn sort_devices(left: &LinuxDevice, right: &LinuxDevice) -> std::cmp::Ordering {
left.label
.cmp(&right.label)
.then_with(|| left.id.cmp(&right.id))
}
#[cfg(test)]
mod tests {
use super::*;
fn device(id: &str, label: &str) -> LinuxDevice {
LinuxDevice {
id: id.to_string(),
label: label.to_string(),
}
}
#[test]
fn parse_cli_defaults_to_auto_backend() {
let cli = parse_cli(&[]).expect("parse_cli should accept empty args");
assert!(matches!(cli.backend, LinuxBackendKind::Auto));
assert!(!cli.list_devices);
assert_eq!(cli.input, None);
assert_eq!(cli.output, None);
}
#[test]
fn parse_cli_reads_non_interactive_selection() {
let cli = parse_cli(&[
"--backend".into(),
"pulseaudio".into(),
"--input".into(),
"1".into(),
"--output".into(),
"sink-id".into(),
"--list-devices".into(),
])
.expect("parse_cli should parse explicit flags");
assert!(matches!(cli.backend, LinuxBackendKind::PulseAudio));
assert!(cli.list_devices);
assert_eq!(cli.input.as_deref(), Some("1"));
assert_eq!(cli.output.as_deref(), Some("sink-id"));
}
#[test]
fn resolve_device_selector_supports_index_and_exact_id() {
let devices = vec![device("mic-id", "Mic"), device("sink-id", "Sink")];
let by_index = resolve_device_selector("input", &devices, "0")
.expect("selector should resolve by index");
assert_eq!(by_index.id, "mic-id");
let by_id = resolve_device_selector("output", &devices, "sink-id")
.expect("selector should resolve by id");
assert_eq!(by_id.label, "Sink");
}
#[test]
fn resolve_device_selector_rejects_ambiguous_labels() {
let devices = vec![device("mic-1", "Mic"), device("mic-2", "Mic")];
let err = resolve_device_selector("input", &devices, "Mic")
.expect_err("selector should reject ambiguous labels");
assert!(err.contains("multiple input devices matched `Mic`"));
}
}
+26
View File
@@ -0,0 +1,26 @@
mod audio_buffer;
#[cfg(target_os = "linux")]
mod linux_pipewire;
#[cfg(target_os = "linux")]
mod linux_pulseaudio;
#[cfg(target_os = "linux")]
mod linux_shared;
#[cfg(target_os = "windows")]
mod windows_cpal;
#[cfg(target_os = "linux")]
fn main() -> Result<(), String> {
linux_shared::run_from_env()
}
#[cfg(target_os = "windows")]
fn main() -> Result<(), String> {
windows_cpal::run()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn main() {
eprintln!("This POC currently supports Windows via cpal and Linux via PipeWire or PulseAudio.");
}
+168
View File
@@ -0,0 +1,168 @@
use crate::audio_buffer::{make_shared_buffer, pop_sample, push_sample};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, Stream, StreamConfig};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
pub fn run() -> Result<(), String> {
let host = cpal::default_host();
let input = host
.default_input_device()
.ok_or("no default input device available")?;
let output = host
.default_output_device()
.ok_or("no default output device available")?;
println!(
"Windows cpal input: {}",
input.name().map_err(|err| err.to_string())?
);
println!(
"Windows cpal output: {}",
output.name().map_err(|err| err.to_string())?
);
let input_config = input
.default_input_config()
.map_err(|err| err.to_string())?;
let output_config = output
.default_output_config()
.map_err(|err| err.to_string())?;
let config: StreamConfig = input_config.config();
let buffer = make_shared_buffer();
let input_stream = build_input_stream(
&input,
&config,
input_config.sample_format(),
buffer.clone(),
)?;
let output_stream = build_output_stream(
&output,
&config,
output_config.sample_format(),
buffer.clone(),
)?;
let stop = Arc::new(AtomicBool::new(false));
let stop_for_handler = stop.clone();
ctrlc::set_handler(move || {
stop_for_handler.store(true, Ordering::SeqCst);
})
.map_err(|err| err.to_string())?;
input_stream.play().map_err(|err| err.to_string())?;
output_stream.play().map_err(|err| err.to_string())?;
println!("Streaming with cpal. Press Ctrl+C to stop.");
while !stop.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
drop(input_stream);
drop(output_stream);
Ok(())
}
fn build_input_stream(
device: &cpal::Device,
config: &StreamConfig,
sample_format: SampleFormat,
buffer: crate::audio_buffer::SharedBuffer,
) -> Result<Stream, String> {
let err_fn = |err| eprintln!("cpal input stream error: {err}");
match sample_format {
SampleFormat::F32 => device
.build_input_stream(
config,
move |data: &[f32], _| {
for &sample in data {
push_sample(&buffer, sample);
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
SampleFormat::I16 => device
.build_input_stream(
config,
move |data: &[i16], _| {
for &sample in data {
push_sample(&buffer, sample as f32 / i16::MAX as f32);
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
SampleFormat::U16 => device
.build_input_stream(
config,
move |data: &[u16], _| {
for &sample in data {
let normalized = (sample as f32 / u16::MAX as f32) * 2.0 - 1.0;
push_sample(&buffer, normalized);
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
other => Err(format!("unsupported cpal input sample format: {other:?}")),
}
}
fn build_output_stream(
device: &cpal::Device,
config: &StreamConfig,
sample_format: SampleFormat,
buffer: crate::audio_buffer::SharedBuffer,
) -> Result<Stream, String> {
let err_fn = |err| eprintln!("cpal output stream error: {err}");
match sample_format {
SampleFormat::F32 => device
.build_output_stream(
config,
move |data: &mut [f32], _| {
for sample in data {
*sample = pop_sample(&buffer);
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
SampleFormat::I16 => device
.build_output_stream(
config,
move |data: &mut [i16], _| {
for sample in data {
let value = pop_sample(&buffer).clamp(-1.0, 1.0);
*sample = (value * i16::MAX as f32) as i16;
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
SampleFormat::U16 => device
.build_output_stream(
config,
move |data: &mut [u16], _| {
for sample in data {
let value = pop_sample(&buffer).clamp(-1.0, 1.0);
*sample = (((value + 1.0) * 0.5) * u16::MAX as f32) as u16;
}
},
err_fn,
None,
)
.map_err(|err| err.to_string()),
other => Err(format!("unsupported cpal output sample format: {other:?}")),
}
}
+35
View File
@@ -0,0 +1,35 @@
# TS3 Manual Research Spike
Chanora proof-of-concept. **Not product code.**
## Purpose
Prove that `docs/ts3_development_manual_en_v6.md` was read and reduced into the
specific protocol guidance needed for the TeamSpeak research spikes in this
repository.
## What it covers
1. The file-transfer bootstrap flow: `ftinitdownload` / `ftinitupload` plus the
second TCP connection for raw bytes.
2. The client-variable surfaces relevant to user inspection: stable UID,
DBID, nickname, version, platform, avatar, and related profile fields.
3. Identity import/export and security-level concepts needed to parse and reuse
TeamSpeak identity exports.
4. The mapping from those manual sections to the executable PoCs under `poc/`.
## Manual sections used
- Section 9: variable parameters / client fields
- Section 12 and later references to `client_unique_identifier`
- Section 30: file transfer protocol
- Section 32: identity and security level
## Output
The verification record documents the extracted conclusions and which runnable
PoC proves each one.
## Verification
See `VERIFICATION.md`.
+60
View File
@@ -0,0 +1,60 @@
# Verification record — `ts3-manual-research`
## Result
PASS. The TeamSpeak development manual at
`docs/ts3_development_manual_en_v6.md` was read and the relevant protocol
guidance was extracted into concrete conclusions that were then exercised by the
executable TeamSpeak PoCs in `poc/`.
## Source document
- `docs/ts3_development_manual_en_v6.md`
## Key extracted findings
### 1. File download path
The manual states that TeamSpeak file transfer is a two-phase flow:
1. send `ftinitdownload` on the control channel;
2. receive transfer token, host, and port;
3. open a second TCP connection and exchange raw bytes.
This directly informed `poc/tsclientlib-filetransfer-spike`.
### 2. User-profile variable surfaces
The manual identifies the client fields and lookup surfaces relevant to this
task:
- `client_unique_identifier`
- `client_database_id`
- `client_nickname`
- `client_version`
- `client_platform`
- avatar-related fields
This directly informed `poc/tsclientlib-query-spike`.
### 3. Identity import/export compatibility
The manual describes TeamSpeak identity material as keypair + counter/security
data and explicitly recommends keeping import/export compatible with the
official client format where possible.
This directly informed `poc/tsidentity-import-spike`.
## Cross-check against runnable PoCs
| Manual finding | Runnable PoC | Verification status |
|---|---|---|
| `ftinitdownload` bootstraps a second TCP transfer | `poc/tsclientlib-filetransfer-spike` | PASS |
| Client UID / DBID / profile / connection data can be surfaced from client protocol state | `poc/tsclientlib-query-spike` | PASS |
| Official-style identity export can be parsed, round-tripped, and reused for connect | `poc/tsidentity-import-spike` | PASS |
## Conclusion
The manual-reading requirement is satisfied with a concrete repo artifact and
its extracted protocol guidance is reflected in the corresponding executable
PoCs.
@@ -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")
}
}
@@ -0,0 +1,2 @@
/downloads/
/target/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
[package]
name = "tsclientlib-filetransfer-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: discover and download a TeamSpeak file over the file-transfer port."
[workspace]
[dependencies]
anyhow = "1"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
futures = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "fs", "io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
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,41 @@
# tsclientlib Filetransfer Spike
Chanora proof-of-concept. **Not product code.**
## Purpose
Prove that a normal TeamSpeak client session can download a file from the
TeamSpeak fileserver by:
1. discovering a candidate file path, and
2. completing the `ftinitdownload` + second TCP connection flow.
## What it does
1. Connects to `kr.teamspeak.app`.
2. Attempts `ftgetfilelist` on visible channels.
3. Falls back to server icon or client avatar paths if channel-file listing is
unavailable.
4. Uses `Connection::download_file` to perform the actual fileserver download.
5. Saves the file under `downloads/`.
## Run
```bash
cargo run --offline -- \
--address kr.teamspeak.app \
--nickname ChanoraPoC-Downloader
```
Optional explicit target:
```bash
cargo run --offline -- \
--address kr.teamspeak.app \
--channel-id 0 \
--path /avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf
```
## Verification
See `VERIFICATION.md`.
@@ -0,0 +1,45 @@
# Verification record — `tsclientlib-filetransfer-spike`
## Result
PASS. The spike successfully downloaded a real file from
`kr.teamspeak.app` over the TeamSpeak fileserver path.
## 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-Downloader
```
## What was observed
- `ftgetfilelist` against visible channel IDs did not return typed file rows on
this server; each scan attempt failed with `ParameterNotFound`.
- The fallback logic selected `EdisonJwa`'s avatar path:
- channel id: `0`
- remote path: `/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
- `download_file` completed successfully.
- Reported file size: `129453`
- Copied bytes: `129453`
- Saved artifact:
- `downloads/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
## Conclusion
The practical TeamSpeak fileserver download path is proven:
1. determine a valid TeamSpeak remote path,
2. request the transfer bootstrap,
3. open the returned file-transfer TCP stream,
4. receive raw bytes and persist them locally.
@@ -0,0 +1,297 @@
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use futures::prelude::*;
use tokio::io;
use tokio::time as tokio_time;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tsclientlib::messages::s2c::{InFileListPart, InMessage};
use tsclientlib::{
ChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
};
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
#[derive(Parser, Debug)]
#[command(
name = "tsclientlib-filetransfer-spike",
about = "Chanora PoC: list files and download one via the TeamSpeak fileserver."
)]
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-Downloader")]
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>,
/// Explicit channel id for the download target.
#[arg(long)]
channel_id: Option<u64>,
/// Explicit remote TeamSpeak path for the download target.
#[arg(long)]
path: Option<String>,
/// Output directory for downloaded files.
#[arg(long, default_value = "downloads")]
output_dir: PathBuf,
}
#[derive(Clone, Debug)]
struct DownloadCandidate {
channel_id: ChannelId,
remote_path: String,
reason: 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: "filetransfer-spike", address = %args.address, nickname = %args.nickname, "starting filetransfer 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 candidate = if let (Some(channel_id), Some(path)) = (args.channel_id, args.path.clone()) {
DownloadCandidate {
channel_id: ChannelId(channel_id),
remote_path: path,
reason: "explicit CLI target".to_string(),
}
} else {
discover_candidate(&mut con).await?
};
println!("=== Download Target ===");
println!("Reason : {}", candidate.reason);
println!("Channel ID : {}", candidate.channel_id.0);
println!("Remote path : {}", candidate.remote_path);
tokio::fs::create_dir_all(&args.output_dir)
.await
.with_context(|| format!("creating {}", args.output_dir.display()))?;
let output_path = args.output_dir.join(file_name_from_remote_path(&candidate.remote_path));
let download = con
.download_file(candidate.channel_id, &candidate.remote_path, None, None)
.context("requesting file download")?;
let mut result = await_download_result(&mut con, download).await?;
let mut output = tokio::fs::File::create(&output_path)
.await
.with_context(|| format!("creating {}", output_path.display()))?;
let copied = io::copy(&mut result.stream, &mut output)
.await
.with_context(|| format!("writing {}", output_path.display()))?;
println!();
println!("=== Download Result ===");
println!("Expected size : {}", result.size);
println!("Copied bytes : {}", copied);
println!("Saved to : {}", output_path.display());
con.disconnect(DisconnectOptions::new()).ok();
con.events().for_each(|_| futures::future::ready(())).await;
Ok(())
}
async fn discover_candidate(con: &mut Connection) -> Result<DownloadCandidate> {
let channel_ids = {
let state = con.get_state().context("reading connection state")?;
let mut ids = state.channels.keys().copied().collect::<Vec<_>>();
ids.sort_by_key(|id| id.0);
ids
};
for channel_id in channel_ids {
match request_file_list(con, channel_id, "/").await {
Ok(files) => {
if let Some(file) = files.into_iter().find(|entry| entry.is_file) {
return Ok(DownloadCandidate {
channel_id: file.channel_id,
remote_path: format_path(&file.path, &file.name),
reason: format!("first file discovered via ftgetfilelist in channel {}", file.channel_id.0),
});
}
}
Err(error) => {
warn!(target: "filetransfer-spike", %error, channel_id = channel_id.0, "ftgetfilelist failed");
}
}
}
let state = con.get_state().context("reading connection state after file scan")?;
if state.server.icon.0 != 0 {
return Ok(DownloadCandidate {
channel_id: ChannelId(0),
remote_path: format!("/icon_{}", state.server.icon.0),
reason: "server icon fallback".to_string(),
});
}
for client in state.clients.values() {
if !client.avatar_hash.is_empty() {
if let Some(uid) = &client.uid {
return Ok(DownloadCandidate {
channel_id: ChannelId(0),
remote_path: format!("/avatar_{}", uid.as_ref().as_avatar()),
reason: format!("avatar fallback for client {}", client.name),
});
}
}
}
bail!("no downloadable candidate discovered from channel files, server icon, or client avatars");
}
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
for flag in flags {
command.write_arg(flag, &"");
}
for (key, value) in args {
command.write_arg(key, value);
}
command
}
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<Vec<InMessage>> {
let handle = command.send_with_result(con).context("sending command")?;
let mut messages = Vec::new();
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
match item {
StreamItem::MessageEvent(message) => messages.push(message),
StreamItem::MessageResult(reply, status) if reply == handle => {
status.map_err(|error| anyhow!("command failed: {error}"))?;
return Ok(messages);
}
_ => {}
}
}
}
async fn request_file_list(con: &mut Connection, channel_id: ChannelId, path: &str) -> Result<Vec<InFileListPart>> {
let command = build_command(
"ftgetfilelist",
&[("cid", channel_id.0.to_string()), ("path", path.to_string())],
&[],
);
let messages = request_messages(con, command).await?;
let mut rows = Vec::new();
for message in messages {
if let InMessage::FileList(list) = message {
rows.extend(list.iter().cloned());
}
}
Ok(rows)
}
async fn await_download_result(
con: &mut Connection,
handle: tsclientlib::FiletransferHandle,
) -> Result<tsclientlib::FileDownloadResult> {
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| anyhow!("event stream ended before filetransfer result"))??;
match item {
StreamItem::FileDownload(reply, result) if reply == handle => return Ok(result),
StreamItem::FiletransferFailed(reply, error) if reply == handle => {
return Err(anyhow!("filetransfer failed: {error}"));
}
_ => {}
}
}
}
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 format_path(path: &str, name: &str) -> String {
if path == "/" {
format!("/{name}")
} else if path.ends_with('/') {
format!("{path}{name}")
} else {
format!("{path}/{name}")
}
}
fn file_name_from_remote_path(remote_path: &str) -> String {
let name = Path::new(remote_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("download.bin");
let sanitized = name
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { ch } else { '_' })
.collect::<String>();
if sanitized.is_empty() {
"download.bin".to_string()
} else {
sanitized
}
}
+1
View File
@@ -0,0 +1 @@
/target/
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "tsclientlib-query-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: query live TeamSpeak client profile and connection data over the full client protocol."
[workspace]
[dependencies]
anyhow = "1"
base64 = "0.22"
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 = { version = "0.3", features = ["formatting"] }
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" }
+48
View File
@@ -0,0 +1,48 @@
# tsclientlib Query Spike
Chanora proof-of-concept. **Not product code.**
## Purpose
Prove what user/profile data 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 client/channel
snapshot is populated.
3. Prints the online client table.
4. Resolves a target client by nickname, UID, DBID, or CLID.
5. Requests richer client data with `clientgetvariables` and connection stats
with `getconnectioninfo`, then reads the populated values back out of the
live `tsclientlib` connection state.
6. Falls back gracefully when the server denies permission-gated lookups such
as `clientdbinfo`.
## Run
```bash
cargo run --offline -- \
--address kr.teamspeak.app \
--nickname ChanoraPoC-Inspector \
--target-nickname EdisonJwa
```
Useful flags:
- `--target-nickname <name>`
- `--target-uid <uid>`
- `--target-dbid <dbid>`
- `--target-clid <clid>`
- `--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,82 @@
# Verification record — `tsclientlib-query-spike`
## Result
PASS. On 2026-05-25, the spike connected to `kr.teamspeak.app`, resolved the
live `EdisonJwa` session, and recovered the requested profile, group, country,
avatar, quota, and connection-stat fields 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-Inspector \
--target-nickname EdisonJwa
```
## Observed output
- Live roster populated after subscription + event pumping.
- `EdisonJwa` was online and resolved as:
- `clid=18481`
- `dbid=2`
- `uid=QcnldW6Qnw/4im/t94j/FYIcVMU=`
- `country=KR`
- description: `test`
- avatar path: `/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
- `clientgetvariables` populated richer profile data:
- version: `3.6.2 [Build: 1695203293]`
- platform: `Windows`
- first connected: `2018-09-07T09:39:00Z`
- last connected: `2026-05-25T03:34:44Z`
- total connections: `3831`
- downloaded total: `102735429`
- uploaded total: `51249731`
- Group names resolved from live state after `servergrouplist` /
`channelgrouplist`:
- server groups: `Server Admin`, `Talkaholic`
- channel group: `Channel Admin`
- `getconnectioninfo` populated connection stats:
- connection time: `27m 29s`
- idle time: `182 ms`
- ping: `42 ms`
- client address: hidden by server permissions
- packet loss: `0.0000` in both directions
- packets transferred: populated
- bytes transferred: populated
- filetransfer bandwidth: `sent=0 recv=0`
- `clientdbinfo cldbid=2` still failed with
`PermissionsClientInsufficient`, which confirms that the richer results above
came from the full client session path rather than elevated query
permissions.
## Conclusion
This spike proves the following from a normal client connection:
- online roster discovery
- target user resolution by visible session data
- nickname / UID / DBID / country capture
- description, version, platform, first-connected, last-connected, and total
connections retrieval
- server-group and channel-group name resolution
- avatar path derivation
- live connection-time, idle-time, ping, loss, packet, byte, and quota
retrieval
## Remaining limits
- `clientdbinfo` remains permission-gated for this target on this server.
- `Client Address` is correctly reported as hidden because the live session did
not have permission to see it.
+776
View File
@@ -0,0 +1,776 @@
use std::collections::HashMap;
use anyhow::{anyhow, bail, Context, Result};
use base64::prelude::*;
use clap::Parser;
use futures::prelude::*;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use tokio::time as tokio_time;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tsclientlib::data;
use tsclientlib::messages::s2c::{InClientDbIdFromUidPart, InClientDbInfoPart, InMessage};
use tsclientlib::{
ChannelGroupId, ClientDbId, ClientId, Connection, DisconnectOptions, Identity, OutCommandExt,
ServerGroupId, StreamItem, Uid,
};
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
#[derive(Parser, Debug)]
#[command(
name = "tsclientlib-query-spike",
about = "Chanora PoC: inspect TeamSpeak client profile + connection info 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-Inspector")]
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 online/offline nickname.
#[arg(long)]
target_nickname: Option<String>,
/// Inspect a specific TeamSpeak UID.
#[arg(long)]
target_uid: Option<String>,
/// Inspect a specific TeamSpeak database id.
#[arg(long)]
target_dbid: Option<u64>,
/// Inspect a specific online client id.
#[arg(long)]
target_clid: Option<u16>,
/// Print the online client table before detailed inspection.
#[arg(long, default_value_t = true)]
show_online: bool,
}
#[derive(Clone, Debug)]
struct Target {
clid: Option<ClientId>,
dbid: Option<ClientDbId>,
uid_b64: Option<String>,
nickname: Option<String>,
}
#[derive(Clone, Debug)]
struct OnlineTargetSnapshot {
description: String,
country_code: String,
server_groups: Vec<ServerGroupId>,
channel_group: ChannelGroupId,
avatar_hash: String,
uid_b64: Option<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: "query-spike", address = %args.address, nickname = %args.nickname, "starting 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 (mut target, online_snapshot) = {
let state = con.get_state().context("reading connection state")?;
if args.show_online {
print_online_clients(state);
}
let target = resolve_target(&args, state)?;
let snapshot = target
.clid
.and_then(|clid| state.clients.get(&clid))
.map(|client| OnlineTargetSnapshot {
description: client.description.clone(),
country_code: client.country_code.clone(),
server_groups: client.server_groups.iter().copied().collect(),
channel_group: client.channel_group,
avatar_hash: client.avatar_hash.clone(),
uid_b64: client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
});
(target, snapshot)
};
let _ = request_server_group_list(&mut con).await;
let _ = request_channel_group_list(&mut con).await;
if target.dbid.is_none() {
if let Some(uid) = target.uid_b64.as_deref() {
if let Ok(lookup) = request_client_dbid_from_uid(&mut con, uid).await {
target.dbid = Some(lookup.client_db_id);
}
}
}
println!();
println!("=== Detailed Target ===");
println!("Target nickname : {}", target.nickname.as_deref().unwrap_or("Unknown"));
println!("Target UID : {}", target.uid_b64.as_deref().unwrap_or("Unknown"));
println!(
"Target DBID : {}",
target
.dbid
.map(|id| id.0.to_string())
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Target CLID : {}",
target
.clid
.map(|id| id.0.to_string())
.unwrap_or_else(|| "Offline/Unknown".to_string())
);
if let Some(clid) = target.clid {
if let Err(error) = request_client_variables(&mut con, clid).await {
warn!(target: "query-spike", %error, clid = clid.0, "clientgetvariables failed");
}
if let Err(error) = request_client_connection_info(&mut con, clid).await {
warn!(target: "query-spike", %error, clid = clid.0, "getconnectioninfo failed");
}
}
let db_info = match target.dbid {
Some(dbid) => match request_client_db_info(&mut con, dbid).await {
Ok(info) => Some(info),
Err(error) => {
warn!(target: "query-spike", %error, dbid = dbid.0, "clientdbinfo failed");
None
}
},
None => None,
};
let (optional_data, connection_data, server_group_names, channel_group_names) = {
let state = con.get_state().context("reading updated connection state")?;
let server_group_names = state
.server_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect::<HashMap<_, _>>();
let channel_group_names = state
.channel_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect::<HashMap<_, _>>();
match target.clid.and_then(|clid| state.clients.get(&clid)) {
Some(client) => (
client.optional_data.clone(),
client.connection_data.clone(),
server_group_names,
channel_group_names,
),
None => (None, None, server_group_names, channel_group_names),
}
};
print_detailed_summary(
target,
optional_data.as_ref(),
connection_data.as_ref(),
db_info.as_ref(),
online_snapshot.as_ref(),
&server_group_names,
&channel_group_names,
)?;
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)], flags: &[&str]) -> OutCommand {
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
for flag in flags {
command.write_arg(flag, &"");
}
for (key, value) in args {
command.write_arg(key, value);
}
command
}
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<Vec<InMessage>> {
let handle = command.send_with_result(con).context("sending command")?;
let mut messages = Vec::new();
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
match item {
StreamItem::MessageEvent(message) => messages.push(message),
StreamItem::MessageResult(reply, status) if reply == handle => {
status.map_err(|error| anyhow!("command failed: {error}"))?;
return Ok(messages);
}
_ => {}
}
}
}
async fn request_messages_and_quiet(
con: &mut Connection,
command: OutCommand,
quiet_period: std::time::Duration,
) -> Result<Vec<InMessage>> {
let messages = request_messages(con, command).await?;
pump_events(con, quiet_period).await?;
Ok(messages)
}
async fn request_client_variables(con: &mut Connection, clid: ClientId) -> Result<()> {
let command = build_command("clientgetvariables", &[("clid", clid.0.to_string())], &[]);
request_messages_and_quiet(con, command, std::time::Duration::from_millis(350)).await?;
Ok(())
}
async fn request_client_connection_info(con: &mut Connection, clid: ClientId) -> Result<()> {
let command = build_command("getconnectioninfo", &[("clid", clid.0.to_string())], &[]);
request_messages_and_quiet(con, command, std::time::Duration::from_millis(350)).await?;
Ok(())
}
async fn request_client_db_info(con: &mut Connection, dbid: ClientDbId) -> Result<InClientDbInfoPart> {
let command = build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]);
let messages = request_messages(con, command).await?;
for message in messages {
if let InMessage::ClientDbInfo(info) = message {
if let Some(row) = info.iter().next() {
return Ok(row.clone());
}
}
}
bail!("clientdbinfo returned no row");
}
async fn request_client_dbid_from_uid(con: &mut Connection, uid: &str) -> Result<InClientDbIdFromUidPart> {
let command = build_command("clientgetdbidfromuid", &[("cluid", uid.to_string())], &[]);
let messages = request_messages(con, command).await?;
for message in messages {
if let InMessage::ClientDbIdFromUid(info) = message {
if let Some(row) = info.iter().next() {
return Ok(row.clone());
}
}
}
bail!("clientgetdbidfromuid returned no row");
}
async fn request_server_group_list(con: &mut Connection) -> Result<()> {
request_messages_and_quiet(
con,
build_command("servergrouplist", &[], &[]),
std::time::Duration::from_millis(200),
)
.await?;
Ok(())
}
async fn request_channel_group_list(con: &mut Connection) -> Result<()> {
request_messages_and_quiet(
con,
build_command("channelgrouplist", &[], &[]),
std::time::Duration::from_millis(200),
)
.await?;
Ok(())
}
fn resolve_target(args: &Args, state: &data::Connection) -> Result<Target> {
if let Some(clid) = args.target_clid {
if let Some(row) = state.clients.get(&ClientId(clid)) {
return Ok(Target {
clid: Some(row.id),
dbid: Some(row.database_id),
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
nickname: Some(row.name.clone()),
});
}
return Ok(Target {
clid: Some(ClientId(clid)),
dbid: None,
uid_b64: None,
nickname: None,
});
}
if let Some(uid) = &args.target_uid {
if let Some(row) = state
.clients
.values()
.find(|row| row.uid.as_ref().is_some_and(|value| uid_to_b64(value.as_ref()) == *uid))
{
return Ok(Target {
clid: Some(row.id),
dbid: Some(row.database_id),
uid_b64: Some(uid.clone()),
nickname: Some(row.name.clone()),
});
}
return Ok(Target {
clid: None,
dbid: args.target_dbid.map(ClientDbId),
uid_b64: Some(uid.clone()),
nickname: args.target_nickname.clone(),
});
}
if let Some(nickname) = &args.target_nickname {
if let Some(row) = state.clients.values().find(|row| row.name == *nickname) {
return Ok(Target {
clid: Some(row.id),
dbid: Some(row.database_id),
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
nickname: Some(row.name.clone()),
});
}
return Ok(Target {
clid: None,
dbid: args.target_dbid.map(ClientDbId),
uid_b64: None,
nickname: Some(nickname.clone()),
});
}
if let Some(dbid) = args.target_dbid {
if let Some(row) = state.clients.values().find(|row| row.database_id.0 == dbid) {
return Ok(Target {
clid: Some(row.id),
dbid: Some(row.database_id),
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
nickname: Some(row.name.clone()),
});
}
return Ok(Target {
clid: None,
dbid: Some(ClientDbId(dbid)),
uid_b64: args.target_uid.clone(),
nickname: args.target_nickname.clone(),
});
}
let own_client_id = state.own_client;
let own_client = state
.clients
.get(&own_client_id)
.ok_or_else(|| anyhow!("own client missing from state"))?;
Ok(Target {
clid: Some(own_client_id),
dbid: Some(own_client.database_id),
uid_b64: own_client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
nickname: Some(own_client.name.clone()),
})
}
fn print_online_clients(state: &data::Connection) {
println!("=== Online Clients ===");
if !state.server.ips.is_empty() {
let ips = state
.server
.ips
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
println!("Server IPs: {ips}");
}
for row in state.clients.values() {
let uid = row
.uid
.as_ref()
.map(|value| uid_to_b64(value.as_ref()))
.unwrap_or_else(|| "Hidden".to_string());
let country = if row.country_code.is_empty() {
"Unknown".to_string()
} else {
row.country_code.clone()
};
println!(
"- {} | clid={} dbid={} cid={} uid={} country={}",
row.name, row.id.0, row.database_id.0, row.channel.0, uid, country
);
}
}
fn print_detailed_summary(
target: Target,
client_info: Option<&data::OptionalClientData>,
connection_info: Option<&data::ConnectionClientData>,
db_info: Option<&InClientDbInfoPart>,
online_snapshot: Option<&OnlineTargetSnapshot>,
server_group_names: &HashMap<ServerGroupId, String>,
channel_group_names: &HashMap<ChannelGroupId, String>,
) -> Result<()> {
let nickname = db_info
.map(|info| info.name.clone())
.or(target.nickname)
.unwrap_or_else(|| "Unknown".to_string());
let uid = db_info
.map(|info| uid_to_b64(info.uid.as_ref()))
.or(target.uid_b64)
.unwrap_or_else(|| "Unknown".to_string());
let dbid = db_info
.map(|info| info.database_id.0)
.or(target.dbid.map(|value| value.0))
.unwrap_or_default();
println!();
println!("=== Client Profile ===");
println!("Nickname : {} ({})", nickname, dbid);
println!("Unique ID : {}", uid);
println!("Database ID : {}", dbid);
println!(
"Description : {}",
db_info
.map(|info| info.description.as_str())
.or_else(|| online_snapshot.map(|info| info.description.as_str()))
.unwrap_or("")
);
println!(
"Version : {}",
client_info.map(|info| info.version.as_str()).unwrap_or("Unknown")
);
println!(
"Connections : {}",
client_info
.map(|info| info.connections_total)
.or_else(|| db_info.map(|info| info.connections_total))
.unwrap_or_default()
);
println!(
"First Connected : {}",
client_info
.map(|info| format_timestamp(info.created))
.or_else(|| db_info.map(|info| format_timestamp(info.created)))
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Last Connected : {}",
client_info
.map(|info| format_timestamp(info.last_connected))
.or_else(|| db_info.map(|info| format_timestamp(info.last_connected)))
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Online Since : {}",
connection_info
.and_then(|info| info.connected_time.map(format_duration))
.unwrap_or_else(|| "Offline".to_string())
);
println!(
"Country : {}",
online_snapshot
.map(|info| info.country_code.as_str())
.filter(|value| !value.is_empty())
.unwrap_or("Unknown")
);
if let Some(info) = client_info {
println!("Platform : {}", info.platform);
println!(
"Server Groups : {}",
online_snapshot
.map(|snapshot| format_server_groups(&snapshot.server_groups, &server_group_names))
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Channel Group : {}",
online_snapshot
.map(|snapshot| {
channel_group_names
.get(&snapshot.channel_group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", snapshot.channel_group.0))
})
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Avatar Path : {}",
online_snapshot
.map(|snapshot| {
if snapshot.avatar_hash.is_empty() {
"None".to_string()
} else {
snapshot
.uid_b64
.as_ref()
.map(|uid| format!("/avatar_{}", uid_to_avatar_path(uid)))
.unwrap_or_else(|| "Hidden".to_string())
}
})
.unwrap_or_else(|| "Unknown".to_string())
);
} else if let Some(info) = online_snapshot {
println!(
"Server Groups : {}",
format_server_groups(&info.server_groups, &server_group_names)
);
println!(
"Channel Group : {}",
channel_group_names
.get(&info.channel_group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", info.channel_group.0))
);
println!(
"Avatar Path : {}",
if info.avatar_hash.is_empty() {
"None".to_string()
} else {
info.uid_b64
.as_ref()
.map(|uid| format!("/avatar_{}", uid_to_avatar_path(uid)))
.unwrap_or_else(|| "Hidden".to_string())
}
);
} else {
println!("Server Groups : Unknown");
println!("Channel Group : Unknown");
println!("Avatar Path : Unknown");
}
println!();
println!("=== Connection Info ===");
if let Some(connection) = connection_info {
println!(
"Connection Time : {}",
connection
.connected_time
.map(format_duration)
.unwrap_or_else(|| "Unknown".to_string())
);
println!("Idle Time : {}", format_duration(connection.idle_time));
println!(
"Ping : {}",
connection
.ping
.map(format_duration)
.unwrap_or_else(|| "Unknown".to_string())
);
println!(
"Client Address : {}",
connection
.client_address
.map(|address| address.to_string())
.unwrap_or_else(|| "Hidden".to_string())
);
println!(
"Packet Loss C->S : total={} speech={} keepalive={} control={}",
format_loss(connection.client_to_server_packetloss_total),
format_loss(connection.client_to_server_packetloss_speech),
format_loss(connection.client_to_server_packetloss_keepalive),
format_loss(connection.client_to_server_packetloss_control),
);
println!(
"Packet Loss S->C : total={} speech={} keepalive={} control={}",
connection
.server_to_client_packetloss_total
.map(format_loss)
.unwrap_or_else(|| "Hidden".to_string()),
connection
.server_to_client_packetloss_speech
.map(format_loss)
.unwrap_or_else(|| "Hidden".to_string()),
connection
.server_to_client_packetloss_keepalive
.map(format_loss)
.unwrap_or_else(|| "Hidden".to_string()),
connection
.server_to_client_packetloss_control
.map(format_loss)
.unwrap_or_else(|| "Hidden".to_string()),
);
println!(
"Bandwidth Sent : 1s[speech={} keepalive={} control={}] 1m[speech={} keepalive={} control={}]",
option_u64(connection.bandwidth_sent_last_second_speech),
option_u64(connection.bandwidth_sent_last_second_keepalive),
option_u64(connection.bandwidth_sent_last_second_control),
option_u64(connection.bandwidth_sent_last_minute_speech),
option_u64(connection.bandwidth_sent_last_minute_keepalive),
option_u64(connection.bandwidth_sent_last_minute_control),
);
println!(
"Bandwidth Received : 1s[speech={} keepalive={} control={}] 1m[speech={} keepalive={} control={}]",
option_u64(connection.bandwidth_received_last_second_speech),
option_u64(connection.bandwidth_received_last_second_keepalive),
option_u64(connection.bandwidth_received_last_second_control),
option_u64(connection.bandwidth_received_last_minute_speech),
option_u64(connection.bandwidth_received_last_minute_keepalive),
option_u64(connection.bandwidth_received_last_minute_control),
);
println!(
"Filetransfer BW : sent={} recv={}",
option_u64(connection.filetransfer_bandwidth_sent),
option_u64(connection.filetransfer_bandwidth_received),
);
println!(
"Packets Sent : speech={} keepalive={} control={}",
option_u64(connection.packets_sent_speech),
option_u64(connection.packets_sent_keepalive),
option_u64(connection.packets_sent_control),
);
println!(
"Packets Received : speech={} keepalive={} control={}",
option_u64(connection.packets_received_speech),
option_u64(connection.packets_received_keepalive),
option_u64(connection.packets_received_control),
);
println!(
"Bytes Sent : speech={} keepalive={} control={}",
option_u64(connection.bytes_sent_speech),
option_u64(connection.bytes_sent_keepalive),
option_u64(connection.bytes_sent_control),
);
println!(
"Bytes Received : speech={} keepalive={} control={}",
option_u64(connection.bytes_received_speech),
option_u64(connection.bytes_received_keepalive),
option_u64(connection.bytes_received_control),
);
} else {
println!("Connection details unavailable (offline or permission-gated).");
}
println!();
println!("=== Transfer Quota ===");
if let Some(info) = client_info {
println!("Downloaded this month : {}", info.bytes_downloaded_month);
println!("Uploaded this month : {}", info.bytes_uploaded_month);
println!("Downloaded total : {}", info.bytes_downloaded_total);
println!("Uploaded total : {}", info.bytes_uploaded_total);
} else if let Some(info) = db_info {
println!("Downloaded this month : {}", info.bytes_downloaded_month);
println!("Uploaded this month : {}", info.bytes_uploaded_month);
println!("Downloaded total : {}", info.bytes_downloaded_total);
println!("Uploaded total : {}", info.bytes_uploaded_total);
} else {
println!("Quota details unavailable.");
}
Ok(())
}
fn format_server_groups(groups: &[ServerGroupId], names: &HashMap<ServerGroupId, String>) -> String {
let mut rendered = Vec::new();
for group in groups {
rendered.push(
names
.get(group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", group.0)),
);
}
rendered.join(", ")
}
fn format_timestamp(value: OffsetDateTime) -> String {
value
.format(&Rfc3339)
.unwrap_or_else(|_| value.unix_timestamp().to_string())
}
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")
}
}
fn format_loss(value: f32) -> String {
format!("{value:.4}")
}
fn option_u64(value: Option<u64>) -> String {
value
.map(|number| number.to_string())
.unwrap_or_else(|| "Hidden".to_string())
}
fn uid_to_b64(uid: &Uid) -> String {
BASE64_STANDARD.encode(&uid.0)
}
fn uid_to_avatar_path(uid_b64: &str) -> String {
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
let mut rendered = String::with_capacity(decoded.len() * 2);
for byte in decoded {
rendered.push((b'a' + (byte >> 4)) as char);
rendered.push((b'a' + (byte & 0x0f)) as char);
}
rendered
}
+2
View File
@@ -0,0 +1,2 @@
/sample_identity.ini
/target/
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tsidentity-import-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: import/export official TeamSpeak identity files and connect using the imported identity."
[workspace]
[dependencies]
anyhow = "1"
base64 = "0.22"
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"] }
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
+35
View File
@@ -0,0 +1,35 @@
# tsidentity Import Spike
Chanora proof-of-concept. **Not product code.**
## Purpose
Prove TeamSpeak identity import/export compatibility with the official INI-style
format and verify that an imported identity can be used for a live connection.
## What it does
1. Parses an exported TeamSpeak identity file.
2. Computes the imported identity UID and security level.
3. Re-exports the identity in TeamSpeak's `counter + V + obfuscated-key` form.
4. Connects to `kr.teamspeak.app` using the imported identity and nickname.
## Run
```bash
cargo test --offline
cargo run --offline -- \
--identity-file /path/to/exported_identity.ini \
--address kr.teamspeak.app
```
## Important finding
The validation identity used during this spike round-tripped correctly, but its
UID `eaRkG62hRaLs+R9sOuOWK7xbnUY=` did **not** match the live `EdisonJwa` UID
`QcnldW6Qnw/4im/t94j/FYIcVMU=` observed on `kr.teamspeak.app`.
## Verification
See `VERIFICATION.md`.
@@ -0,0 +1,59 @@
# Verification record — `tsidentity-import-spike`
## Result
PASS. The sample TeamSpeak identity file imported successfully, exported back to
the exact same identity string, and was usable for a live connection to
`kr.teamspeak.app`.
## Environment
| Field | Value |
|---|---|
| Date | 2026-05-25 |
| Host OS | Linux (x86_64) |
| Build profile | test + dev |
| Target server | `kr.teamspeak.app` |
## Commands
```bash
cargo test --offline
cargo run --offline -- \
--identity-file /path/to/exported_identity.ini \
--address kr.teamspeak.app
```
## What was observed
- Unit test passed:
- exported identity INI parsed successfully
- exported identity string matched the original exactly
- Imported sample identity properties:
- nickname: `Edison.Jwa`
- UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- security level: `8`
- counter: `40`
- Live connect using the imported identity succeeded:
- connected nickname: `Edison.Jwa`
- connected UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- connected DBID: `17225`
- connected CLID: `18506`
- connected CID: `1`
- country: `KR`
- server groups: `{ServerGroupId(8)}`
## Important finding
The provided sample identity is **not** the live `EdisonJwa` identity currently
online on `kr.teamspeak.app`.
- Sample imported identity UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- Live `EdisonJwa` UID from the query spike: `QcnldW6Qnw/4im/t94j/FYIcVMU=`
That means:
- the import/export implementation is working, and
- the sample INI belongs to a different TeamSpeak identity than the live
`EdisonJwa` account used in the example profile.
+249
View File
@@ -0,0 +1,249 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use futures::prelude::*;
use tokio::time as tokio_time;
use tracing::info;
use tracing_subscriber::EnvFilter;
use tsclientlib::{Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem};
#[derive(Parser, Debug)]
#[command(
name = "tsidentity-import-spike",
about = "Chanora PoC: parse/export TeamSpeak identity files and connect with the imported identity."
)]
struct Args {
/// Path to an exported TeamSpeak identity INI file.
#[arg(long)]
identity_file: PathBuf,
/// Server address for the live connect verification.
#[arg(short, long, default_value = "kr.teamspeak.app")]
address: String,
/// Override the nickname from the identity file for the live connect verification.
#[arg(long)]
nickname: Option<String>,
/// Server password if required.
#[arg(long)]
password: Option<String>,
/// Skip the live connection step and only validate the import/export round trip.
#[arg(long)]
no_connect: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct IdentityRecord {
id: String,
identity: String,
nickname: String,
phonetic_nickname: 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();
let raw = fs::read_to_string(&args.identity_file)
.with_context(|| format!("reading {}", args.identity_file.display()))?;
let record = IdentityRecord::parse(&raw)?;
let identity = Identity::new_from_str(&record.identity).context("parsing TeamSpeak identity")?;
let computed_uid = identity.key().to_pub().get_uid();
let roundtrip_identity = export_identity_string(&identity);
let roundtrip_record = IdentityRecord {
id: record.id.clone(),
identity: roundtrip_identity.clone(),
nickname: record.nickname.clone(),
phonetic_nickname: record.phonetic_nickname.clone(),
};
println!("=== Identity Import ===");
println!("Identity file : {}", args.identity_file.display());
println!("Profile id : {}", record.id);
println!("Nickname : {}", record.nickname);
println!("UID : {}", computed_uid);
println!("Security level : {}", identity.level());
println!("Counter : {}", identity.counter());
println!("Roundtrip match : {}", if roundtrip_identity == record.identity { "yes" } else { "no" });
println!();
println!("=== Export Preview ===");
println!("{}", roundtrip_record.render());
if args.no_connect {
return Ok(());
}
let connect_nickname = args.nickname.clone().unwrap_or_else(|| record.nickname.clone());
info!(
target: "identity-spike",
address = %args.address,
nickname = %connect_nickname,
uid = %computed_uid,
"connecting with imported identity"
);
let mut builder = Connection::build(args.address.clone())
.name(connect_nickname.clone())
.identity(identity);
if let Some(password) = &args.password {
builder = builder.password(password.clone());
}
let mut con = builder.connect().context("dialling server with imported identity")?;
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 state = con.get_state().context("reading post-connect state")?;
let own = state
.clients
.get(&state.own_client)
.ok_or_else(|| anyhow!("own client missing after imported-identity connect"))?;
let own_uid = own
.uid
.as_ref()
.map(|uid| tsclientlib_uid_to_b64(uid.as_ref()))
.unwrap_or_else(|| "<missing>".to_string());
println!();
println!("=== Live Connect Result ===");
println!("Connected nickname : {}", own.name);
println!("Connected UID : {}", own_uid);
println!("Connected DBID : {}", own.database_id.0);
println!("Connected CLID : {}", own.id.0);
println!("Connected CID : {}", own.channel.0);
println!(
"Country : {}",
if own.country_code.is_empty() {
"Unknown".to_string()
} else {
own.country_code.clone()
}
);
println!("Server groups : {:?}", own.server_groups);
println!("Avatar hash : {}", own.avatar_hash);
con.disconnect(DisconnectOptions::new()).ok();
con.events().for_each(|_| futures::future::ready(())).await;
Ok(())
}
impl IdentityRecord {
fn parse(raw: &str) -> Result<Self> {
let mut section = None::<String>;
let mut map = std::collections::HashMap::new();
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
section = Some(trimmed[1..trimmed.len() - 1].to_string());
continue;
}
if section.as_deref() != Some("Identity") {
continue;
}
let (key, value) = trimmed
.split_once('=')
.ok_or_else(|| anyhow!("invalid identity line: {trimmed}"))?;
map.insert(key.to_string(), unquote(value));
}
Ok(Self {
id: required(&map, "id")?,
identity: required(&map, "identity")?,
nickname: required(&map, "nickname")?,
phonetic_nickname: map.get("phonetic_nickname").cloned().unwrap_or_default(),
})
}
fn render(&self) -> String {
format!(
"[Identity]\nid={}\nidentity=\"{}\"\nnickname={}\nphonetic_nickname={}\n",
self.id, self.identity, self.nickname, self.phonetic_nickname
)
}
}
fn required(map: &std::collections::HashMap<String, String>, key: &str) -> Result<String> {
map.get(key)
.cloned()
.ok_or_else(|| anyhow!("missing required identity key `{key}`"))
}
fn unquote(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}
fn export_identity_string(identity: &Identity) -> String {
format!("{}V{}", identity.counter(), identity.key().to_ts_obfuscated())
}
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 tsclientlib_uid_to_b64(uid: &tsclientlib::Uid) -> String {
use base64::prelude::*;
BASE64_STANDARD.encode(&uid.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_roundtrips_sample_identity() {
let raw = include_str!("../sample_identity.ini");
let record = IdentityRecord::parse(raw).expect("sample identity must parse");
let identity = Identity::new_from_str(&record.identity).expect("identity must parse");
let uid = identity.key().to_pub().get_uid();
assert_eq!(uid, "eaRkG62hRaLs+R9sOuOWK7xbnUY=");
assert_eq!(export_identity_string(&identity), record.identity);
assert_eq!(record.nickname, "Edison.Jwa");
}
}