feat(poc/protocol): add tsclientlib connect spike

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.
This commit is contained in:
EdisonJwa
2026-05-14 12:25:48 +08:00
parent bdeab3b451
commit 02c11ead7e
5 changed files with 3812 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "tsclientlib-connect-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: prove tsclientlib protocol feasibility by connecting to a TS3-compatible server and printing the channel tree."
# This is PoC code, not product code. See docs/architecture/proof-of-concept-plan.md §4.
[dependencies]
# tsclientlib is git-only (not on crates.io).
# Audio feature is disabled because this spike only proves the connect/state path;
# audio behaviour is covered by a separate PoC (audio-capture-playback-spike).
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", default-features = false, features = ["default-tls"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
futures = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+57
View File
@@ -0,0 +1,57 @@
# tsclientlib Connect Spike
Chanora proof-of-concept. **Not product code.**
| Field | Value |
|---|---|
| PoC name | `tsclientlib-connect-spike` |
| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 |
| Purpose | Prove protocol feasibility via `tsclientlib` |
| Exit criterion | "Rust can connect to a compatible server/test double" |
## What it does
1. Generates an ephemeral identity (or accepts one via `--identity`).
2. Connects to a TeamSpeak-3-compatible server.
3. Waits for the initial state snapshot (`StreamItem::BookEvents`).
4. Subscribes to the server's channel tree.
5. Prints server metadata + the channel tree with client names.
6. Disconnects cleanly.
## Run
Requires Rust stable (developed against 1.95). Network access required so
that `cargo` can fetch `tsclientlib` from GitHub on first build.
```bash
cargo run --release -- --address cn.teamspeak.app
```
Useful flags:
| Flag | Meaning |
|---|---|
| `-a, --address <host[:port]>` | Server (default `cn.teamspeak.app`) |
| `-n, --nickname <name>` | Nickname (default `ChanoraPoC`) |
| `--password <pw>` | Server password if required |
| `--identity <base64>` | Reuse an existing identity instead of generating one |
| `--hold-secs <n>` | Seconds to hold the connection before disconnect (default 3) |
| `-v` / `-vv` / `-vvv` | Increase tsclientlib log verbosity |
## Scope boundaries
This spike is intentionally narrow:
- No audio capture or playback (covered by `audio-capture-playback-spike`).
- No FFI surface (covered by `flutter-rust-bridge-hello`).
- No persistent identity (covered by `secure-storage-spike`).
- No diagnostic redaction policy (covered by `diagnostics-redaction-spike`).
- No Chanora architecture layering — does **not** live inside
`chanora_protocol` and does not expose typed DTOs. The intent of the
spike is to validate the upstream `tsclientlib` API surface before the
product crate is written, per the PoC plan.
## Verification log
See `VERIFICATION.md` in this directory for the most recent run captured
against `cn.teamspeak.app`.
@@ -0,0 +1,68 @@
# Verification record — `tsclientlib-connect-spike`
## Result
PASS. The PoC exit criterion (from
`docs/architecture/proof-of-concept-plan.md` §2: "Rust can connect to a
compatible server/test double") is met.
## Environment
| Field | Value |
|---|---|
| Date | 2026-05-13 |
| Host OS | Linux (x86_64) |
| Rust toolchain | stable 1.95.0 (59807616e 2026-04-14) |
| Build profile | release |
| Target server | `cn.teamspeak.app` (resolved to 175.178.125.23) |
| Network | direct outbound UDP/TCP from build host |
## Command
```bash
cargo build --release
./target/release/tsclientlib-connect-spike \
--address cn.teamspeak.app \
--hold-secs 4 \
--nickname ChanoraPoC
```
Process exit code: `0`.
## What was observed
- Identity generated locally (ephemeral, level-0).
- UDP connection negotiated with the server.
- `StreamItem::BookEvents` snapshot received → initial state ready.
- `server.set_subscribed(true)` accepted → full channel tree streamed.
- Server identity surfaced:
- name: `Vigorous Pro`
- platform: `Linux`
- version: `3.13.7 [Build: 1655727713]` (TeamSpeak 3 server)
- clients online: 5
- channels: 36
- Channel tree printed with multi-byte (CJK) channel and client names
intact, validating UTF-8 end-to-end (cross-references SAD ADR-008
"UTF-8 at all boundaries").
- The PoC client appeared in `Default Channel` as `ChanoraPoC`.
- Clean disconnect via `DisconnectOptions::new()`; no panics or hangs.
## Known non-blocking observations
- `tsproto::packet_codec` emits `Out of order command packet` warnings
during the initial state burst. These are upstream-level and informational;
the snapshot still resolves correctly. Worth tracking when the
`chanora_protocol` crate is written, but not a spike blocker.
- The welcome banner uses TeamSpeak's `[URL...]`/`[cspacer0]` markup —
this is server-provided text and per DEC-015 / ADR-008 must be
preserved, not translated. The spike does not parse it.
## Scope honestly NOT validated by this spike
- Audio capture/playback (separate PoC).
- Persisted identity via secure storage (separate PoC).
- Reconnect / network-loss recovery.
- FFI to Flutter (separate PoC).
- Behaviour against a non-TS3 / TS5 server.
- Behaviour behind restrictive NAT or under packet loss.
- Performance characteristics under sustained load.
+194
View File
@@ -0,0 +1,194 @@
//! 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()
}