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:
Generated
+3472
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user