//! 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, /// Server password, if the server requires one. #[arg(long)] password: Option, /// 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() }