Proof-of-concept proving the protocol-feasibility exit criterion from docs/architecture/proof-of-concept-plan.md §2: "Rust can connect to a compatible server/test double." The spike opens a tsclientlib connection, waits for the BookEvents state snapshot, subscribes to the server channel tree, prints server metadata and the channel tree with client names, and disconnects cleanly. Audio feature is disabled because audio is covered by a separate PoC. Verified on 2026-05-13 against cn.teamspeak.app (TeamSpeak 3 server 3.13.7); 36 channels and 5 online clients retrieved with full UTF-8 (CJK) preservation. See poc/tsclientlib-connect-spike/VERIFICATION.md for the captured run. Authority: PoC plan §2, SysRS-005, SysDes-011, SysDes-029. Not product code; not promoted into chanora_protocol.
195 lines
6.7 KiB
Rust
195 lines
6.7 KiB
Rust
//! Chanora PoC: tsclientlib connect spike.
|
|
//!
|
|
//! Purpose (per `docs/architecture/proof-of-concept-plan.md` §2):
|
|
//! Prove that a Rust process can connect to a TeamSpeak-3-compatible
|
|
//! server through `tsclientlib` and observe the server state.
|
|
//!
|
|
//! Exit criterion: "Rust can connect to a compatible server/test double."
|
|
//!
|
|
//! This is PoC code. It deliberately:
|
|
//! * does not capture or play audio;
|
|
//! * does not expose any FFI surface to Flutter;
|
|
//! * does not use the Chanora architecture layers (chanora_protocol etc.);
|
|
//! * generates a fresh identity per run unless one is supplied.
|
|
//!
|
|
//! It is not promoted to product code unless explicitly migrated per
|
|
//! `docs/architecture/proof-of-concept-plan.md` §4.
|
|
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
use futures::prelude::*;
|
|
use tokio::time;
|
|
use tracing::{info, warn};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use tsclientlib::data::{self, Channel, Client};
|
|
use tsclientlib::{ChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem};
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(
|
|
name = "tsclientlib-connect-spike",
|
|
about = "Chanora PoC: connect to a TS3-compatible server and dump channel tree."
|
|
)]
|
|
struct Args {
|
|
/// Server address (hostname[:port] or TSDNS name).
|
|
#[arg(short, long, default_value = "cn.teamspeak.app")]
|
|
address: String,
|
|
|
|
/// Nickname used on the server.
|
|
#[arg(short, long, default_value = "ChanoraPoC")]
|
|
nickname: String,
|
|
|
|
/// Seconds to remain connected before disconnecting cleanly.
|
|
#[arg(long, default_value_t = 3)]
|
|
hold_secs: u64,
|
|
|
|
/// Optional base64 identity string. If omitted, a fresh identity is generated.
|
|
#[arg(long)]
|
|
identity: Option<String>,
|
|
|
|
/// Server password, if the server requires one.
|
|
#[arg(long)]
|
|
password: Option<String>,
|
|
|
|
/// Increase log verbosity (-v, -vv, -vvv).
|
|
#[arg(short, long, action = clap::ArgAction::Count)]
|
|
verbose: u8,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let args = Args::parse();
|
|
|
|
// Default to info-level logs; respect RUST_LOG if set.
|
|
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: "spike", address = %args.address, nickname = %args.nickname, "starting connect spike");
|
|
|
|
// Build connection config.
|
|
let mut builder = Connection::build(args.address.clone())
|
|
.name(args.nickname.clone())
|
|
.log_commands(args.verbose >= 1)
|
|
.log_packets(args.verbose >= 2)
|
|
.log_udp_packets(args.verbose >= 3);
|
|
|
|
// Identity: provided or freshly generated.
|
|
let identity = match &args.identity {
|
|
Some(s) => Identity::new_from_str(s).context("parsing --identity")?,
|
|
None => {
|
|
// Generate a level-0 identity. For a real product we would persist this
|
|
// via SecureStorage (see docs/security/secure-storage-audit-report.md).
|
|
// PoC: ephemeral per run.
|
|
Identity::create()
|
|
}
|
|
};
|
|
builder = builder.identity(identity);
|
|
|
|
if let Some(pw) = &args.password {
|
|
builder = builder.password(pw.clone());
|
|
}
|
|
|
|
// Connect.
|
|
let mut con = builder.connect().context("dialling server")?;
|
|
|
|
// Wait for the initial book/state snapshot (BookEvents == state ready).
|
|
let first = con
|
|
.events()
|
|
.try_filter(|e| future::ready(matches!(e, StreamItem::BookEvents(_))))
|
|
.next()
|
|
.await;
|
|
match first {
|
|
Some(Ok(_)) => info!(target: "spike", "received initial state snapshot"),
|
|
Some(Err(e)) => {
|
|
anyhow::bail!("connection failed before state ready: {e}");
|
|
}
|
|
None => {
|
|
anyhow::bail!("event stream ended before state ready");
|
|
}
|
|
}
|
|
|
|
// Subscribe to the server so we get the full channel tree, not just our own.
|
|
if let Ok(state) = con.get_state() {
|
|
if let Err(e) = state.server.set_subscribed(true).send(&mut con) {
|
|
warn!(target: "spike", error = %e, "could not subscribe to server tree");
|
|
}
|
|
}
|
|
|
|
// Give the server a moment to ship the tree, but bail out early if the
|
|
// connection disconnects in the meantime.
|
|
let mut bg = con.events().try_filter(|_| future::ready(false));
|
|
tokio::select! {
|
|
_ = time::sleep(Duration::from_secs(args.hold_secs.min(60))) => {}
|
|
ev = bg.next() => {
|
|
if let Some(Err(e)) = ev {
|
|
anyhow::bail!("disconnected during hold window: {e}");
|
|
}
|
|
}
|
|
}
|
|
drop(bg);
|
|
|
|
// Print snapshot.
|
|
print_snapshot(&*con.get_state().context("reading server state")?);
|
|
|
|
// Clean disconnect.
|
|
con.disconnect(DisconnectOptions::new()).ok();
|
|
con.events().for_each(|_| future::ready(())).await;
|
|
|
|
info!(target: "spike", "spike completed successfully");
|
|
Ok(())
|
|
}
|
|
|
|
fn print_snapshot(con: &data::Connection) {
|
|
println!();
|
|
println!("=== Chanora tsclientlib connect spike — server snapshot ===");
|
|
println!("Server name : {}", sanitize(&con.server.name));
|
|
println!("Welcome msg : {}", sanitize(&con.server.welcome_message));
|
|
println!("Platform : {}", sanitize(&con.server.platform));
|
|
println!("Version : {}", sanitize(&con.server.version));
|
|
println!("Clients online : {}", con.clients.len());
|
|
println!("Channels : {}", con.channels.len());
|
|
println!();
|
|
|
|
let mut channels: Vec<&Channel> = con.channels.values().collect();
|
|
channels.sort_by_key(|c| c.order.0);
|
|
let clients: Vec<&Client> = con.clients.values().collect();
|
|
|
|
println!("Channel tree:");
|
|
print_children(&clients, &channels, ChannelId(0), 0);
|
|
println!();
|
|
}
|
|
|
|
fn print_children(clients: &[&Client], channels: &[&Channel], parent: ChannelId, depth: usize) {
|
|
let indent = " ".repeat(depth);
|
|
for ch in channels {
|
|
if ch.parent == parent {
|
|
println!("{}- [{}] {}", indent, ch.id.0, sanitize(&ch.name));
|
|
for client in clients {
|
|
if client.channel == ch.id {
|
|
println!("{} * {}", indent, sanitize(&client.name));
|
|
}
|
|
}
|
|
print_children(clients, channels, ch.id, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restrict to a safe printable set; mirrors the upstream `simple.rs` example.
|
|
/// This is *not* the Chanora redaction policy — see
|
|
/// `docs/security/diagnostic-redaction-audit-report.md`.
|
|
fn sanitize(s: &str) -> String {
|
|
s.chars()
|
|
.filter(|c| {
|
|
c.is_alphanumeric()
|
|
|| [
|
|
' ', '\t', '.', ':', '-', '_', '"', '\'', '/', '(', ')', '[', ']', '{', '}',
|
|
'!', '?', ',', '#', '+', '*',
|
|
]
|
|
.contains(c)
|
|
})
|
|
.collect()
|
|
}
|