feat: promote linux native audio path
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/sample_identity.ini
|
||||
/target/
|
||||
Generated
+3452
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user