No description
  • TypeScript 75.7%
  • Svelte 24.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julian Imhof 6eae71db13 feat(badges): the catalog and its icons survive a restart (#2)
Closes #1.

The badge catalog (91 entries) and every fetched icon lived in module memory
only, so every boot refetched them and every client rendered nameless until
the fetch landed. Both now go through `ctx.storage`: `badge-catalog` holds the
parsed entries, `badge-icon:<guid>:<variant>` the bytes as base64.

Every stored row is validated rather than cast — a row this version cannot use
reads as "nothing stored", the state the code already handled. The row does not
carry a content type (derived from the variant, so a CDN error page cannot be
replayed as `text/html` forever) and does carry the URL it fetched, so art the
catalog moves is refetched rather than served stale for the life of the process.

`restoreBadges()` covers storage cleared under a live window, which `firstRun`
cannot: nothing hydrated plus a schedule row whose `lastRunMs` predates this
boot means one run started by the module itself.

Version 0.4.10 → 0.5.0.
2026-08-21 16:54:23 +02:00
assets feat: flag emoji font, right-aligned flags, periodic refresh 2026-07-18 22:56:48 +02:00
backend fix(badges): the icon map drops art the catalog moved away from 2026-08-21 16:53:44 +02:00
frontend feat: rework the dashboard overview card 2026-07-27 15:05:11 +02:00
CLAUDE.md Initial commit 2026-07-18 18:48:39 +02:00
opsdeck.module.json fix: page the instance log backwards to find the license block 2026-08-17 16:48:01 +02:00
README.md fix(badges): key stored icons by their URL and derive the content type 2026-08-21 01:13:03 +02:00

OpsDeck module: teamspeak

Monitors one or more TeamSpeak servers over the SSH ServerQuery interface (TeamSpeak server >= 3.3 with query_protocols=ssh, default port 10022) and shows the live server tree — channels in their real order, clients sorted like the TS client (talk power, then nickname), mute/away state, country flag — plus moving clients between channels.

The SSH transport is implemented in-module (backend/ssh.ts, WebCrypto + Deno.connect), so the module has zero dependencies beyond the OpsDeck SDK types. SSH authentication is the ServerQuery login.

Configuration

Environment, prefix OPSDECK_MOD_TEAMSPEAK_:

Variable Meaning
SERVERS JSON array of servers (see below).
HOST, PORT, USERNAME, PASSWORD, SID, LABEL single-server shorthand, used only when SERVERS is unset
POLL_SEC poll interval in seconds, default 5 (min 2)
OPSDECK_MOD_TEAMSPEAK_SERVERS='[
  {"label":"Main","host":"ts.example.com","password":"secret"},
  {"label":"Second","host":"ts2.example.com","port":10022,
   "username":"serveradmin","sid":2,"id":"second"}
]'

Per entry: host and password are required; port defaults to 10022, username to serveradmin, sid (virtual server id) to 1, label to the host, id (used in URLs) to a slug of the label.

With exactly one configured server the UI skips server selection entirely — the module root page is that server's tree.

TeamSpeak side: start the server with query_protocols=raw,ssh (or just ssh) and add the OpsDeck host's IP to query_ip_allowlist.txt so the poller isn't flood-throttled.

What you get

  • Server tree — channels in linked-list order (channel_order), spacer channels rendered as separators (all alignments incl. [*spacer] repeat and the special line spacers [spacer]___ / --- / ... / -.- / -..), default-channel marker, password and client-limit indicators.
  • Clients — sorted by talk power then nickname; away (+ message), input/output muted, no-hardware, recording, channel commander, priority speaker; country flag emoji; myTeamSpeak badges (see below); server group, channel group, client and channel icons (see below).
  • Move clients — drag & drop onto a channel, or click a client and pick the target channel (admin role; enforced server-side).
  • Users-online chart per server (users.online metric, tagged by server id) with history + live SSE ticks.
  • Status card — online pill, version/platform chips, and a tile grid: slot usage with a capacity meter (amber ≥ 80 %, red ≥ 95 %), channels with the number in use, uptime with its start time, and license expiry (date + days left, amber ≤ 30 days, red once expired). Web only — schema clients get the same numbers as plain fields.
  • License expiry read from the instance log (see below).
  • Alerts — a deduped warning when a server becomes unreachable, an info when it comes back; both click through to the affected server.
  • Mobile / schema clients — the full tree (read-only) via the portable UI schema; moving clients needs an input widget the schema doesn't have, so that stays web-only.

Endpoints

GET  /api/mod/teamspeak/servers            summaries of all servers
GET  /api/mod/teamspeak/server/:id         info + nested tree + flat channels
GET  /api/mod/teamspeak/badge/:guid        myTeamSpeak badge icon (SVG)
GET  /api/mod/teamspeak/server/:id/icon/:iconId   group/client/channel icon
POST /api/mod/teamspeak/server/:id/move    {clid, cid}   (admin)

License expiry

No query command reports the license — serverinfo, hostinfo and instanceinfo carry nothing and there is no licenseinfo (checked against TS6 6.0.0-beta12). The server only ever logs it, so the module reads the instance log (logview instance=1) and parses the Accounting block:

|INFO |Accounting |   |Licensing Information
|INFO |Accounting |   |licensed to       : TeamSpeak Systems GmbH
|INFO |Accounting |   |type              : No License
|INFO |Accounting |   |ending date       : Thu Oct  1 00:00:00 2026

The block is written once at startup, so it sits at the head of the log — but logview reads from the tail: begin_pos is the byte offset a page ends at (0 = end of file) and last_pos on the first row is the offset of the oldest line returned, i.e. the cursor to page further back. The module walks pages of 100 lines backwards from the tail until the block turns up, last_pos reaches 0 or it has read 1000 lines, accumulating the pages so a block split across a boundary still parses. begin_pos=0 alone returns the newest 100 lines and never sees the block — that is what made the tile show a dash on a server up for more than ~100 log lines.

Result is cached for 6 h. A query account without b_serverinstance_log_view gets no license fields (logged once, then not retried); /server/:id reports licenseEndsText: "—" and the tile shows a dash.

Group / client / channel icons

Custom icons (server groups, channel groups, per-client and per-channel icons) are files named /icon_<crc32> on the TeamSpeak server, downloaded via the server's file transfer interface and cached in memory (icon ids are content hashes — a cached icon never goes stale). Two transports, picked per download from the ftinitdownload response:

  • classic (TS3, current TS6 builds): raw TCP to the filetransfer port (default 30033, or the ip the server hands back), send the ftkey, read the file. The filetransfer port must be reachable from the OpsDeck host.
  • S3 presigned URL (TS6 with s3ft storage): newer TS6 builds answer with a url field instead — a plain HTTPS GET. (TS6 beta11 still proxies query-initiated transfers over the classic port even with S3 storage enabled; both paths are supported.)

Icon ids below 1000 are built into the TS client and have no server file — they are ignored. Group name/icon lookups come from servergrouplist / channelgrouplist; on permission-restricted query accounts the module degrades to bare group ids instead of failing the poll.

myTeamSpeak badges

Clients' badges (verified users, event badges, …) are read via clientlist -badges and shown next to the nickname. The badge catalog (GUID → name + icon URL, a small protobuf blob) is fetched from the official https://badges-content.teamspeak.com/list endpoint twice a day; icons are fetched from the same host on first use, then served same-origin at /api/mod/teamspeak/badge/:guid (the shell's CSP is img-src 'self') with a one-week browser cache. Servers older than 3.12 reject the -badges flag — the module detects that once and polls without badges. No outbound requests are made for servers without badge users beyond the catalog refresh.

Both survive a restart: the catalog and each fetched icon are written to ctx.storage (the host's module-scoped persistent store), so a boot restores 91 entries from disk instead of refetching them, and badges have names within a poll or two of boot rather than at the next refresh window. Icons are stored per GUID and variant with the URL they were fetched from, base64-encoded because that store holds JSON; a row whose URL the catalog has since moved on from is refetched, the existing 512 KB cap still applies, and a fetch that fails is neither cached nor stored, so the next request retries it. What a stored icon is served as comes from the variant, not from the response header — the header is never persisted.

Nothing expires these rows and there is no UI that can drop them: core gives module_kv no HTTP surface, and the storage block on /system is the metrics database, not this. The catalog is a full replacement on every refresh, so the Run button in Activity rebuilds it; an icon row is only replaced (its URL changed) or orphaned (its badge left the catalog), and an orphan is permanent — ctx.storage has no enumeration, so the module cannot find it either. Dropping one means SQL against module_kv in /data/opsdeck.duckdb. A boot that finds no catalog under a schedule window that has already run refreshes immediately rather than waiting for the window.

Country flags

Windows browsers render flag emoji as bare letter pairs, so the module ships assets/TwemojiCountryFlags.woff2 (the country-flag-emoji-polyfill subset of Twemoji, flags © Twitter/X under CC-BY 4.0) and serves it same-origin at /api/mod/teamspeak/flag-font — the shell's CSP is font-src 'self', so a CDN font would be blocked. The tree's flag column uses it via @font-face with unicode-range: U+1F1E6-1F1FF, everything else keeps the normal UI font.

Notes on the SSH client

backend/ssh.ts speaks SSH-2 with kex curve25519/ecdh-nistp256/DH-group14, host keys ssh-rsa/rsa-sha2-*/ssh-ed25519, aes-ctr ciphers and hmac-sha2/sha1 MACs. Host key signatures are verified by the algorithm named inside the signature blob — TeamSpeak servers negotiate rsa-sha2-* but then sign with ssh-rsa/SHA-1. Rekeying is intentionally unsupported: the poller retries with a fresh connection, which also covers query timeouts and server restarts. The host key fingerprint is logged on connect.

Dev loop

Same as any external module — build inside a core checkout:

cp -r . <core>/packages/modules/teamspeak
cd <core>
deno task build
OPSDECK_AUTH=disabled OPSDECK_MODULES=teamspeak \
OPSDECK_MOD_TEAMSPEAK_HOST=ts.example.com \
OPSDECK_MOD_TEAMSPEAK_PASSWORD=secret \
deno task start
# http://localhost:8080/m/teamspeak

Production: OPSDECK_EXTERNAL_MODULES=<git-url>#<sha> on the host.