feat: promote linux native audio path
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/downloads/
|
||||
/target/
|
||||
+3453
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user