feat: promote linux native audio path

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