aboutsummaryrefslogtreecommitdiffstats
path: root/gateway/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
committerPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
commit96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch)
treeb2164a76dc3aa5b69e160d1135d3385bcbe6108d /gateway/src
parent150858067059ba615c548f826bb343589993b4bb (diff)
ts web slopify code
Diffstat (limited to 'gateway/src')
-rw-r--r--gateway/src/auth.js83
-rw-r--r--gateway/src/config.js97
-rw-r--r--gateway/src/index.js39
-rw-r--r--gateway/src/opus.js59
-rw-r--r--gateway/src/server.js128
-rw-r--r--gateway/src/voice-session.js500
6 files changed, 906 insertions, 0 deletions
diff --git a/gateway/src/auth.js b/gateway/src/auth.js
new file mode 100644
index 0000000..288de90
--- /dev/null
+++ b/gateway/src/auth.js
@@ -0,0 +1,83 @@
+// Access control for browser voice: server-password check and brute-force limits.
+import { createHash, timingSafeEqual } from "node:crypto";
+
+const digest = (value) => createHash("sha256").update(String(value), "utf8").digest();
+
+/**
+ * Constant-time comparison of the typed password against the configured one.
+ * An unset/empty configured password never matches: voice requires a password.
+ */
+export function passwordMatches(input, expected) {
+ if (typeof input !== "string" || typeof expected !== "string" || expected.length === 0) return false;
+ if (input.length > 512) return false;
+ return timingSafeEqual(digest(input), digest(expected));
+}
+
+/** Per-IP failed-login tracking with a temporary lockout. */
+export class LoginLimiter {
+ constructor({ maxFailures = 5, lockoutMs = 10 * 60_000, maxTracked = 4096 } = {}, clock = Date.now) {
+ this.maxFailures = maxFailures;
+ this.lockoutMs = lockoutMs;
+ this.maxTracked = maxTracked;
+ this.clock = clock;
+ this.peers = new Map(); // ip -> { failures, firstAt, lockedUntil }
+ }
+
+ /** Milliseconds the peer is still locked out for (0 = allowed). */
+ lockedFor(ip) {
+ const entry = this.peers.get(ip);
+ if (!entry) return 0;
+ const now = this.clock();
+ if (entry.lockedUntil > now) return entry.lockedUntil - now;
+ if (now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
+ return 0;
+ }
+
+ recordFailure(ip) {
+ const now = this.clock();
+ let entry = this.peers.get(ip);
+ if (!entry || now - entry.firstAt > this.lockoutMs) {
+ this.prune(now);
+ entry = { failures: 0, firstAt: now, lockedUntil: 0 };
+ this.peers.set(ip, entry);
+ }
+ entry.failures += 1;
+ if (entry.failures >= this.maxFailures) entry.lockedUntil = now + this.lockoutMs;
+ }
+
+ recordSuccess(ip) {
+ this.peers.delete(ip);
+ }
+
+ prune(now) {
+ for (const [ip, entry] of this.peers) {
+ if (entry.lockedUntil <= now && now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
+ }
+ while (this.peers.size >= this.maxTracked) {
+ const oldest = this.peers.keys().next().value;
+ if (oldest === undefined) break;
+ this.peers.delete(oldest);
+ }
+ }
+}
+
+/** Same-host Origin check (plus configured extra origins) for the voice WebSocket. */
+export function originAllowed(origin, host, allowedOrigins = []) {
+ if (!origin) return false; // browsers always send Origin on WebSocket upgrades
+ const normalized = origin.replace(/\/+$/, "").toLowerCase();
+ if (allowedOrigins.includes("*") || allowedOrigins.includes(normalized)) return true;
+ try {
+ return new URL(normalized).host === String(host ?? "").toLowerCase();
+ } catch {
+ return false;
+ }
+}
+
+/** Trim, drop control characters and enforce TeamSpeak's 3..30 character nickname rule. */
+export function sanitizeNickname(input, prefix = "") {
+ if (typeof input !== "string") return null;
+ const cleaned = input.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim();
+ if (!cleaned) return null;
+ const full = `${prefix}${cleaned}`.slice(0, 30).trim();
+ return [...full].length >= 3 ? full : null;
+}
diff --git a/gateway/src/config.js b/gateway/src/config.js
new file mode 100644
index 0000000..33cad84
--- /dev/null
+++ b/gateway/src/config.js
@@ -0,0 +1,97 @@
+// Loads the same config.toml + .env the Flask dashboard uses (see config.py).
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parse as parseToml } from "smol-toml";
+
+export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+// [section, key, ENV_VAR, type]. Keep in sync with ENV_OVERRIDES in config.py.
+const ENV_OVERRIDES = [
+ ["web", "host", "WEB_HOST", "string"],
+ ["web", "port", "WEB_PORT", "int"],
+ ["web", "dashboard_port", "DASHBOARD_PORT", "int"],
+ ["server", "host", "TS_HOST", "string"],
+ ["server", "webquery_port", "TS_WEBQUERY_PORT", "int"],
+ ["server", "server_id", "TS_SERVER_ID", "string"],
+ ["server", "voice_port", "TS_VOICE_PORT", "int"],
+ ["server", "voice_port", "TS_PORT", "int"], // legacy name from the old .env.template
+ ["server", "ts_domain", "TS_DOMAIN", "string"],
+ ["voice", "enabled", "VOICE_ENABLED", "bool"],
+ ["voice", "trust_proxy", "VOICE_TRUST_PROXY", "bool"],
+];
+
+function envValue(raw, kind) {
+ const value = raw.trim();
+ if (kind === "bool") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
+ if (kind === "int") {
+ if (!/^\d+$/.test(value)) throw new Error("not an integer");
+ return Number(value);
+ }
+ return value;
+}
+
+function toInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
+ const n = Number(value);
+ return Number.isInteger(n) && n >= min && n <= max ? n : fallback;
+}
+
+export function loadConfig(env = process.env) {
+ const envFile = resolve(ROOT_DIR, ".env");
+ // Like python-dotenv's load_dotenv(): existing variables are not overridden.
+ if (env === process.env && existsSync(envFile)) process.loadEnvFile(envFile);
+
+ const configFile = env.TS6_CONFIG_FILE || resolve(ROOT_DIR, "config.toml");
+ let raw = {};
+ if (existsSync(configFile)) raw = parseToml(readFileSync(configFile, "utf8"));
+ else console.warn(`[config] ${configFile} not found, using defaults`);
+
+ const section = (name) => ({ ...(raw[name] ?? {}) });
+ const web = section("web");
+ const server = section("server");
+ const voice = section("voice");
+
+ const sections = { web, server, voice };
+ const applied = new Set();
+ for (const [name, key, envName, kind] of ENV_OVERRIDES) {
+ const value = env[envName];
+ const id = `${name}.${key}`;
+ if (value === undefined || value.trim() === "" || applied.has(id)) continue;
+ try {
+ sections[name][key] = envValue(value, kind);
+ applied.add(id);
+ } catch {
+ console.warn(`[config] ignoring invalid ${envName}=${JSON.stringify(value)}`);
+ }
+ }
+
+ const allowedOrigins = Array.isArray(voice.allowed_origins)
+ ? voice.allowed_origins.filter((o) => typeof o === "string" && o).map((o) => o.replace(/\/+$/, "").toLowerCase())
+ : [];
+
+ return {
+ web: {
+ host: String(web.host ?? "0.0.0.0"),
+ port: toInt(web.port, 5000, 1, 65535),
+ dashboardPort: toInt(web.dashboard_port, 5001, 1, 65535),
+ },
+ server: {
+ host: String(server.host ?? "").trim(),
+ voicePort: toInt(server.voice_port, 9987, 1, 65535),
+ // Secret: only ever read from the environment / .env.
+ password: env.TS_SERVER_PASSWORD ?? "",
+ },
+ voice: {
+ enabled: voice.enabled !== false,
+ maxSessions: toInt(voice.max_sessions, 8, 1, 1000),
+ maxSessionsPerIp: toInt(voice.max_sessions_per_ip, 2, 1, 1000),
+ maxFailedLogins: toInt(voice.max_failed_logins, 5, 1, 1000),
+ lockoutMs: toInt(voice.lockout_minutes, 10, 1, 24 * 60) * 60_000,
+ nicknamePrefix: String(voice.nickname_prefix ?? ""),
+ defaultChannel: String(voice.default_channel ?? ""),
+ identitySecurityLevel: toInt(voice.identity_security_level, 8, 0, 40),
+ trustProxy: voice.trust_proxy === true,
+ allowedOrigins,
+ },
+ };
+}
diff --git a/gateway/src/index.js b/gateway/src/index.js
new file mode 100644
index 0000000..28beec5
--- /dev/null
+++ b/gateway/src/index.js
@@ -0,0 +1,39 @@
+import { loadConfig } from "./config.js";
+import { createGatewayServer } from "./server.js";
+import { opusBackendName } from "./opus.js";
+
+const config = loadConfig();
+const log = console;
+
+const { server, shutdown, voiceAvailable } = createGatewayServer(config, log);
+
+server.listen(config.web.port, config.web.host, () => {
+ log.info(`[gateway] listening on http://${config.web.host}:${config.web.port} -> dashboard 127.0.0.1:${config.web.dashboardPort}`);
+ if (voiceAvailable()) {
+ log.info(`[gateway] web voice enabled for ${config.server.host}:${config.server.voicePort} (opus: ${opusBackendName()})`);
+ } else if (!config.server.password) {
+ log.warn("[gateway] web voice disabled: set TS_SERVER_PASSWORD in .env (visitors must know it to join)");
+ } else if (!config.server.host) {
+ log.warn("[gateway] web voice disabled: set TS_HOST");
+ } else {
+ log.warn("[gateway] web voice disabled in config.toml ([voice] enabled = false)");
+ }
+});
+
+server.on("error", (error) => {
+ log.error(`[gateway] ${error.message}`);
+ process.exit(1);
+});
+
+let stopping = false;
+async function stop(signal) {
+ if (stopping) return;
+ stopping = true;
+ log.info(`[gateway] ${signal} received, disconnecting voice sessions`);
+ const force = setTimeout(() => process.exit(0), 3000);
+ force.unref();
+ await shutdown();
+ process.exit(0);
+}
+process.on("SIGINT", () => void stop("SIGINT"));
+process.on("SIGTERM", () => void stop("SIGTERM"));
diff --git a/gateway/src/opus.js b/gateway/src/opus.js
new file mode 100644
index 0000000..b1805d6
--- /dev/null
+++ b/gateway/src/opus.js
@@ -0,0 +1,59 @@
+// Opus codec wrapper. Uses the native @discordjs/opus addon when it happens to
+// be installed (faster), otherwise opusscript (WASM): no compiler needed, so it
+// installs cleanly on arm64 SBCs.
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+export const SAMPLE_RATE = 48_000;
+export const FRAME_SAMPLES = 960; // 20 ms @ 48 kHz
+export const FRAME_BYTES = FRAME_SAMPLES * 2; // mono Int16
+
+let backend = null;
+
+function loadBackend() {
+ if (backend) return backend;
+ try {
+ const { OpusEncoder } = require("@discordjs/opus");
+ backend = {
+ name: "@discordjs/opus (native)",
+ create(channels) {
+ const codec = new OpusEncoder(SAMPLE_RATE, channels);
+ return {
+ encode: (pcm) => codec.encode(pcm),
+ decode: (data) => codec.decode(data),
+ setBitrate: (bps) => codec.setBitrate(bps),
+ dispose() {},
+ };
+ },
+ };
+ } catch {
+ const OpusScript = require("opusscript");
+ backend = {
+ name: "opusscript (wasm)",
+ create(channels) {
+ const codec = new OpusScript(SAMPLE_RATE, channels, OpusScript.Application.VOIP);
+ return {
+ encode: (pcm) => Buffer.from(codec.encode(pcm, pcm.length / (2 * channels))),
+ decode: (data) => Buffer.from(codec.decode(data)),
+ setBitrate: (bps) => codec.setBitrate(bps),
+ dispose: () => codec.delete(),
+ };
+ },
+ };
+ }
+ return backend;
+}
+
+export function opusBackendName() {
+ return loadBackend().name;
+}
+
+/** Mono 48 kHz Opus codec (encoder + decoder state). */
+export function createOpusCodec({ bitrate } = {}) {
+ const codec = loadBackend().create(1);
+ if (bitrate) {
+ try { codec.setBitrate(bitrate); } catch { /* keep the library default */ }
+ }
+ return codec;
+}
diff --git a/gateway/src/server.js b/gateway/src/server.js
new file mode 100644
index 0000000..b4e716e
--- /dev/null
+++ b/gateway/src/server.js
@@ -0,0 +1,128 @@
+// Public HTTP front: serves /ws/voice itself and reverse-proxies everything
+// else to the Flask dashboard, so the whole app is one origin on one port.
+import http from "node:http";
+import { WebSocketServer } from "ws";
+import { LoginLimiter, originAllowed } from "./auth.js";
+import { VoiceSession } from "./voice-session.js";
+
+const VOICE_PATH = "/ws/voice";
+const STATUS_PATH = "/voice-gateway/status";
+const HEARTBEAT_MS = 30_000;
+const HOP_BY_HOP = ["connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade"];
+
+function clientIp(req, trustProxy) {
+ if (trustProxy) {
+ const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
+ if (forwarded) return forwarded;
+ }
+ return (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
+}
+
+function rejectUpgrade(socket, status, text) {
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
+}
+
+export function createGatewayServer(config, log = console) {
+ const limiter = new LoginLimiter({ maxFailures: config.voice.maxFailedLogins, lockoutMs: config.voice.lockoutMs });
+ const sessions = new Set();
+ const perIp = new Map();
+ const upstreamAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
+ const voiceAvailable = () => config.voice.enabled && Boolean(config.server.password) && Boolean(config.server.host);
+
+ function proxyToDashboard(req, res) {
+ const headers = { ...req.headers };
+ for (const name of HOP_BY_HOP) delete headers[name];
+ const ip = clientIp(req, false);
+ headers["x-forwarded-for"] = config.voice.trustProxy && req.headers["x-forwarded-for"]
+ ? `${req.headers["x-forwarded-for"]}, ${ip}` : ip;
+ headers["x-forwarded-host"] ??= req.headers.host ?? "";
+ headers["x-forwarded-proto"] = config.voice.trustProxy && req.headers["x-forwarded-proto"]
+ ? req.headers["x-forwarded-proto"] : "http";
+
+ const upstream = http.request({
+ host: "127.0.0.1",
+ port: config.web.dashboardPort,
+ method: req.method,
+ path: req.url,
+ headers,
+ agent: upstreamAgent,
+ }, (upstreamRes) => {
+ const responseHeaders = { ...upstreamRes.headers };
+ for (const name of HOP_BY_HOP) delete responseHeaders[name];
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.statusMessage, responseHeaders);
+ upstreamRes.pipe(res);
+ });
+ upstream.setTimeout(30_000, () => upstream.destroy(new Error("upstream timeout")));
+ upstream.on("error", () => {
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
+ res.end("Dashboard is unavailable (is it still starting?)");
+ });
+ req.pipe(upstream);
+ }
+
+ const server = http.createServer((req, res) => {
+ if (req.url === STATUS_PATH) {
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
+ res.end(JSON.stringify({ available: voiceAvailable(), sessions: sessions.size, maxSessions: config.voice.maxSessions }));
+ return;
+ }
+ proxyToDashboard(req, res);
+ });
+
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024, perMessageDeflate: false });
+
+ server.on("upgrade", (req, socket, head) => {
+ const pathname = (req.url ?? "").split("?")[0];
+ if (pathname !== VOICE_PATH) return rejectUpgrade(socket, 404, "Not Found");
+ if (!originAllowed(req.headers.origin, req.headers["x-forwarded-host"] && config.voice.trustProxy
+ ? req.headers["x-forwarded-host"] : req.headers.host, config.voice.allowedOrigins)) {
+ log.warn(`[voice] rejected origin ${req.headers.origin ?? "(none)"}`);
+ return rejectUpgrade(socket, 403, "Forbidden");
+ }
+ if (!voiceAvailable()) return rejectUpgrade(socket, 503, "Service Unavailable");
+ const ip = clientIp(req, config.voice.trustProxy);
+ if (sessions.size >= config.voice.maxSessions) return rejectUpgrade(socket, 503, "Service Unavailable");
+ if ((perIp.get(ip) ?? 0) >= config.voice.maxSessionsPerIp) return rejectUpgrade(socket, 429, "Too Many Requests");
+
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
+ ws.isAlive = true;
+ ws.on("pong", () => { ws.isAlive = true; });
+ let released = false;
+ const session = new VoiceSession(ws, {
+ ip,
+ config,
+ limiter,
+ log,
+ onClose() {
+ if (released) return;
+ released = true;
+ sessions.delete(session);
+ const left = (perIp.get(ip) ?? 1) - 1;
+ if (left > 0) perIp.set(ip, left); else perIp.delete(ip);
+ },
+ });
+ sessions.add(session);
+ });
+ });
+
+ const heartbeat = setInterval(() => {
+ for (const ws of wss.clients) {
+ if (!ws.isAlive) { ws.terminate(); continue; }
+ ws.isAlive = false;
+ try { ws.ping(); } catch { /* closing */ }
+ }
+ }, HEARTBEAT_MS);
+ heartbeat.unref();
+
+ async function shutdown() {
+ clearInterval(heartbeat);
+ for (const session of [...sessions]) session.close();
+ await new Promise((resolve) => setTimeout(resolve, 300)); // let TeamSpeak goodbyes go out
+ wss.close();
+ server.close();
+ upstreamAgent.destroy();
+ }
+
+ return { server, sessions, shutdown, voiceAvailable };
+}
diff --git a/gateway/src/voice-session.js b/gateway/src/voice-session.js
new file mode 100644
index 0000000..039df7b
--- /dev/null
+++ b/gateway/src/voice-session.js
@@ -0,0 +1,500 @@
+// One browser <-> TeamSpeak voice session.
+//
+// Wire protocol on /ws/voice (no WebRTC; everything rides on this WebSocket):
+// browser -> gateway JSON {type:"join", nickname, password, channelId?, channelPassword?, identity?, serverDecode?}
+// JSON {type:"move", channelId, password?} | {type:"mute", muted} | {type:"deafen", deafened}
+// {type:"chat", scope?:"channel"|"server"|"private", message, targetId?}
+// {type:"whisper", targetIds:number[], active:boolean}
+// {type:"leave"}
+// binary 1920 bytes = 20 ms of 48 kHz mono Int16LE PCM from the microphone
+// gateway -> browser JSON {type:"joined"|"state"|"chatMessage"|"whisperTargets"|"error"|"disconnected", ...}
+// binary [kind u8][clientId u16BE][payload]
+// kind 4/5 = TeamSpeak Opus (voice/music) frame, decoded by WebCodecs in the browser
+// kind 0xF0 = 48 kHz mono Int16LE PCM (gateway-side decode fallback)
+// an empty payload marks the end of that speaker's transmission
+import { WebSocket } from "ws";
+import {
+ asChannelID,
+ asClientID,
+ moveClient,
+ sendChannelMessage,
+ sendPrivateMessage,
+ sendServerMessage,
+ updateClient,
+} from "@honeybbq/teamspeak";
+import { Client, generateIdentity, parseIdentity, subscribeAllChannels } from "@honeybbq/teamspeak/client";
+import { createOpusCodec, FRAME_BYTES } from "./opus.js";
+import { passwordMatches, sanitizeNickname } from "./auth.js";
+
+const CONNECT_TIMEOUT_MS = 15_000;
+const STATE_THROTTLE_MS = 150;
+const END_OF_SPEECH_MS = 100;
+const OPUS_BITRATE = 40_000;
+const MAX_BUFFERED_OPUS_BYTES = 8 * 1024;
+const MAX_BUFFERED_PCM_BYTES = 32 * 1024;
+const MAX_SPEAKER_DECODERS = 16;
+const MAX_CHAT_LENGTH = 1024;
+const MAX_WHISPER_TARGETS = 32;
+const KIND_PCM = 0xf0;
+// Token bucket for microphone frames: 50 frames/s nominal, small burst allowance.
+const FRAME_RATE = 55;
+const FRAME_BURST = 25;
+
+const ERROR_TEXT = {
+ 1028: ["BAD_PASSWORD", "The TeamSpeak server rejected the password."],
+ 781: ["CHANNEL_PASSWORD", "Wrong channel password."],
+ 513: ["NICKNAME_IN_USE", "That nickname is already in use."],
+ 514: ["NICKNAME_INVALID", "That nickname is not allowed."],
+ 521: ["TOO_MANY_CLONES", "Too many connections from this gateway."],
+ 524: ["FLOODING", "Slow down, the server thinks you are flooding."],
+ 777: ["CHANNEL_FULL", "That channel is full."],
+ 770: ["ALREADY_IN_CHANNEL", "You are already in that channel."],
+ 2568: ["NO_PERMISSION", "You do not have permission to do that."],
+ 3329: ["BANNED", "You are banned from this server."],
+};
+
+function describeError(error) {
+ for (let e = error, depth = 0; e && depth < 5; e = e.cause, depth++) {
+ if (typeof e.code === "number" && ERROR_TEXT[e.code]) {
+ const [code, message] = ERROR_TEXT[e.code];
+ return { code, message };
+ }
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ if (/abort|timeout|timed out/i.test(message)) return { code: "TIMEOUT", message: "Timed out reaching the TeamSpeak server." };
+ return { code: "TEAMSPEAK_ERROR", message: message.slice(0, 200) || "TeamSpeak error" };
+}
+
+function snapshot(client) {
+ const view = client.view;
+ return {
+ serverName: view.serverName ?? "",
+ self: { id: Number(client.clientID), channelId: String(client.channelID) },
+ channels: [...view.channels.values()].map((c) => ({
+ id: String(c.id),
+ parentId: String(c.parentID),
+ order: String(c.order),
+ name: c.name,
+ hasPassword: Boolean(c.hasPassword),
+ })),
+ clients: [...view.clients.values()].map((e) => ({
+ id: Number(e.id),
+ nickname: e.nickname,
+ channelId: String(e.channelID),
+ type: e.type,
+ away: Boolean(e.isAway),
+ awayMessage: e.awayMessage ?? "",
+ inputMuted: Boolean(e.isInputMuted),
+ outputMuted: Boolean(e.isOutputMuted),
+ serverGroups: (e.serverGroups ?? []).map(String),
+ })),
+ };
+}
+
+const CHANNEL_ID = /^\d{1,20}$/;
+
+export class VoiceSession {
+ /**
+ * @param {WebSocket} ws
+ * @param {{ip: string, config: ReturnType<import("./config.js").loadConfig>, limiter: import("./auth.js").LoginLimiter, log: Console, onClose: () => void}} ctx
+ */
+ constructor(ws, ctx) {
+ this.ws = ws;
+ this.ctx = ctx;
+ this.client = null;
+ this.state = "awaiting-join"; // -> connecting -> ready -> closed
+ this.muted = false;
+ this.deafened = false;
+ this.serverDecode = false;
+ this.encoder = null;
+ this.decoders = new Map();
+ this.whisperTargetIds = new Set();
+ this.whisperActive = false;
+ this.stateTimer = null;
+ this.eosTimer = null;
+ this.tokens = FRAME_BURST;
+ this.tokensAt = Date.now();
+ this.badFrames = 0;
+ this.kickReason = "";
+ this.unsubscribers = [];
+ this.closing = false;
+
+ this.joinTimer = setTimeout(() => this.fail("JOIN_TIMEOUT", "No join request received.", 4408), 20_000);
+ ws.on("message", (data, isBinary) => {
+ if (isBinary) this.onPcm(data);
+ else this.onJson(data);
+ });
+ ws.on("close", () => this.close());
+ ws.on("error", () => this.close());
+ }
+
+ send(message) {
+ if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
+ }
+
+ fail(code, message, closeCode = 4400) {
+ this.send({ type: "error", code, message, fatal: true });
+ try { this.ws.close(closeCode, code.slice(0, 60)); } catch { /* already closed */ }
+ this.close();
+ }
+
+ error(code, message) {
+ this.send({ type: "error", code, message, fatal: false });
+ }
+
+ onJson(data) {
+ let msg;
+ try {
+ msg = JSON.parse(String(data));
+ } catch {
+ return this.fail("BAD_MESSAGE", "Malformed message.");
+ }
+ if (!msg || typeof msg !== "object") return this.fail("BAD_MESSAGE", "Malformed message.");
+
+ if (msg.type === "join") return void this.join(msg);
+ if (this.state !== "ready") return this.error("NOT_READY", "Not connected yet.");
+ switch (msg.type) {
+ case "move": return void this.move(msg);
+ case "mute": return void this.setMuted(Boolean(msg.muted));
+ case "deafen": return void this.setDeafened(Boolean(msg.deafened));
+ case "chat": return void this.sendChat(msg);
+ case "whisper": return void this.setWhisper(msg);
+ case "leave": return void this.close();
+ default: return this.error("UNKNOWN_MESSAGE", "Unknown message type.");
+ }
+ }
+
+ async join(msg) {
+ if (this.state !== "awaiting-join") return this.error("ALREADY_JOINED", "Already joined.");
+ clearTimeout(this.joinTimer);
+ const { config, limiter, ip, log } = this.ctx;
+
+ if (!config.server.password) return this.fail("VOICE_DISABLED", "Web voice is disabled: no server password is configured.", 4403);
+ if (!config.server.host) return this.fail("NOT_CONFIGURED", "The TeamSpeak host is not configured.", 4503);
+
+ const lockedMs = limiter.lockedFor(ip);
+ if (lockedMs > 0) {
+ return this.fail("LOCKED_OUT", `Too many wrong passwords. Try again in ${Math.ceil(lockedMs / 60_000)} min.`, 4429);
+ }
+ // The gateway itself verifies the server password before spending any
+ // resources on a TeamSpeak connection; the TeamSpeak server checks it again.
+ if (!passwordMatches(msg.password, config.server.password)) {
+ limiter.recordFailure(ip);
+ log.warn(`[voice] wrong password from ${ip}`);
+ return this.fail("BAD_PASSWORD", "Wrong server password.", 4401);
+ }
+ limiter.recordSuccess(ip);
+
+ const nickname = sanitizeNickname(msg.nickname, config.voice.nicknamePrefix);
+ if (!nickname) return this.fail("NICKNAME_INVALID", "Nickname must be 3-30 characters.");
+
+ let targetChannel = null;
+ if (msg.channelId !== undefined && msg.channelId !== null && msg.channelId !== "") {
+ if (!CHANNEL_ID.test(String(msg.channelId))) return this.fail("BAD_CHANNEL", "Invalid channel.");
+ targetChannel = String(msg.channelId);
+ }
+ this.serverDecode = msg.serverDecode === true;
+ this.state = "connecting";
+
+ let identity = null;
+ if (typeof msg.identity === "string" && msg.identity.length > 0 && msg.identity.length < 512) {
+ try { identity = parseIdentity(msg.identity); } catch { identity = null; }
+ }
+ try {
+ identity ??= await generateIdentity(config.voice.identitySecurityLevel, { signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ } catch {
+ return this.fail("IDENTITY_FAILED", "Could not create a TeamSpeak identity.", 4500);
+ }
+ if (this.closing) return;
+
+ const client = new Client(identity, `${config.server.host}:${config.server.voicePort}`, nickname, {
+ serverPassword: config.server.password,
+ defaultChannel: config.voice.defaultChannel || undefined,
+ logger: {
+ debug() {},
+ info() {},
+ warn: (m) => log.warn(`[ts ${nickname}] ${m}`),
+ error: (m) => log.error(`[ts ${nickname}] ${m}`),
+ },
+ });
+ this.client = client;
+ this.attachClientEvents(client);
+
+ try {
+ await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ await client.view.whenReady({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ } catch (error) {
+ if (this.closing) return;
+ const { code, message } = describeError(error);
+ log.warn(`[voice] ${ip} could not connect as "${nickname}": ${code} ${error?.message ?? ""}`);
+ return this.fail(code, message, 4502);
+ }
+ if (this.closing) return;
+
+ try {
+ // Without this the server only reports clients of our own channel.
+ await subscribeAllChannels(client, { timeoutMs: 5000 });
+ } catch (error) {
+ log.warn(`[voice] could not subscribe to all channels: ${error?.message ?? error}`);
+ }
+
+ this.encoder = createOpusCodec({ bitrate: OPUS_BITRATE });
+ this.state = "ready";
+ log.info(`[voice] ${ip} joined as "${nickname}" (clid ${client.clientID})`);
+ this.send({ type: "joined", identity: identity.export(), nickname, ...snapshot(client) });
+
+ if (targetChannel && targetChannel !== String(client.channelID)) {
+ await this.move({ channelId: targetChannel, password: msg.channelPassword });
+ }
+ }
+
+ attachClientEvents(client) {
+ const on = (event, fn) => this.unsubscribers.push(client.on(event, fn));
+ on("viewChanged", () => this.scheduleState());
+ on("clientMoved", () => this.scheduleState());
+ on("clientLeave", (event) => {
+ const id = Number(event?.clientID);
+ const decoder = this.decoders.get(id);
+ if (decoder) { decoder.dispose(); this.decoders.delete(id); }
+ if (this.whisperTargetIds.delete(id)) {
+ if (!this.whisperTargetIds.size) this.whisperActive = false;
+ this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive });
+ }
+ });
+ on("textMessage", (event) => {
+ const scope = event.targetMode === 1 ? "private" : event.targetMode === 2 ? "channel" : "server";
+ if (scope === "private" && Number(event.invokerID) === Number(client.clientID)) return;
+ const targetId = scope === "channel"
+ ? String(client.channelID)
+ : event.targetID ? String(event.targetID) : undefined;
+ this.send({
+ type: "chatMessage",
+ scope,
+ ...(targetId ? { targetId } : {}),
+ invokerId: Number(event.invokerID),
+ invokerName: event.invokerName,
+ senderUid: String(event.invokerUID ?? ""),
+ message: event.message,
+ timestamp: Date.now(),
+ });
+ });
+ on("kicked", (event) => { this.kickReason = event?.reasonMessage ?? ""; });
+ on("voiceData", (event) => this.onTeamSpeakVoice(event));
+ on("disconnected", (event) => {
+ if (this.closing || this.state !== "ready") return;
+ const reason = this.kickReason
+ ? `Kicked: ${this.kickReason}`
+ : event?.error ? describeError(event.error).message : "Disconnected from TeamSpeak.";
+ this.send({ type: "disconnected", reason });
+ try { this.ws.close(1000, "disconnected"); } catch { /* ignore */ }
+ this.close();
+ });
+ }
+
+ scheduleState() {
+ if (this.stateTimer || this.state !== "ready") return;
+ this.stateTimer = setTimeout(() => {
+ this.stateTimer = null;
+ if (this.state === "ready" && this.client) this.send({ type: "state", ...snapshot(this.client) });
+ }, STATE_THROTTLE_MS);
+ }
+
+ async move(msg) {
+ const id = String(msg.channelId ?? "");
+ if (!CHANNEL_ID.test(id)) return this.error("BAD_CHANNEL", "Invalid channel.");
+ const password = typeof msg.password === "string" && msg.password ? msg.password.slice(0, 512) : undefined;
+ try {
+ await moveClient(this.client, this.client.clientID, asChannelID(BigInt(id)), { password, timeoutMs: 5000 });
+ this.scheduleState();
+ } catch (error) {
+ const { code, message } = describeError(error);
+ this.send({ type: "error", code, message, fatal: false, channelId: id });
+ }
+ }
+
+ async sendChat(msg) {
+ const text = typeof msg.message === "string" ? msg.message.trim().slice(0, MAX_CHAT_LENGTH) : "";
+ if (!text) return;
+ const scope = msg.scope === "private" ? "private" : msg.scope === "server" ? "server" : "channel";
+ try {
+ if (scope === "private") {
+ const targetId = Number(msg.targetId);
+ if (!Number.isInteger(targetId) || targetId <= 0 || targetId > 65535) {
+ return this.error("BAD_TARGET", "Invalid private message recipient.");
+ }
+ await sendPrivateMessage(this.client, asClientID(targetId), text, { timeoutMs: 5000 });
+ const selfId = Number(this.client.clientID);
+ const selfEntry = this.client.view?.clients?.get?.(this.client.clientID);
+ this.send({
+ type: "chatMessage",
+ scope: "private",
+ targetId: String(targetId),
+ invokerId: selfId,
+ invokerName: selfEntry?.nickname || "you",
+ senderUid: "",
+ message: text,
+ timestamp: Date.now(),
+ });
+ } else if (scope === "server") {
+ await sendServerMessage(this.client, text, { timeoutMs: 5000 });
+ } else {
+ await sendChannelMessage(this.client, text, { timeoutMs: 5000 });
+ }
+ } catch (error) {
+ const { code, message } = describeError(error);
+ this.error(code, message);
+ }
+ }
+
+ setWhisper(msg) {
+ const selfId = Number(this.client?.clientID ?? 0);
+ if (Array.isArray(msg.targetIds)) {
+ const clean = [];
+ for (const raw of msg.targetIds) {
+ const id = Number(raw);
+ if (Number.isInteger(id) && id > 0 && id <= 65535 && id !== selfId && !clean.includes(id)) {
+ clean.push(id);
+ if (clean.length >= MAX_WHISPER_TARGETS) break;
+ }
+ }
+ this.whisperTargetIds = new Set(clean);
+ }
+ if (typeof msg.active === "boolean") {
+ this.whisperActive = msg.active && this.whisperTargetIds.size > 0;
+ } else if (!this.whisperTargetIds.size) {
+ this.whisperActive = false;
+ }
+ this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive });
+ }
+
+ async setMuted(muted) {
+ this.muted = muted;
+ if (muted) this.endOfSpeech();
+ try { await updateClient(this.client, { isInputMuted: muted }, { timeoutMs: 5000 }); }
+ catch (error) { this.error(describeError(error).code, "Could not update mute state."); }
+ }
+
+ async setDeafened(deafened) {
+ this.deafened = deafened;
+ try { await updateClient(this.client, { isOutputMuted: deafened }, { timeoutMs: 5000 }); }
+ catch (error) { this.error(describeError(error).code, "Could not update deafen state."); }
+ }
+
+ // ---- audio ----------------------------------------------------------------
+
+ takeToken() {
+ const now = Date.now();
+ this.tokens = Math.min(FRAME_BURST, this.tokens + ((now - this.tokensAt) / 1000) * FRAME_RATE);
+ this.tokensAt = now;
+ if (this.tokens < 1) return false;
+ this.tokens -= 1;
+ return true;
+ }
+
+ sendVoiceOrWhisper(payload) {
+ if (this.whisperActive && this.whisperTargetIds.size > 0) {
+ this.client.sendWhisper(payload, { clients: [...this.whisperTargetIds].map(asClientID) }, 4 /* Codec.OpusVoice */);
+ } else {
+ this.client.sendVoice(payload, 4 /* Codec.OpusVoice */);
+ }
+ }
+
+ onPcm(frame) {
+ if (this.state !== "ready" || this.muted || !this.encoder) return;
+ if (!Buffer.isBuffer(frame) || frame.length !== FRAME_BYTES) {
+ if (++this.badFrames > 100) this.fail("BAD_AUDIO", "Malformed audio frames.");
+ return;
+ }
+ if (!this.takeToken()) return;
+ try {
+ const encoded = this.encoder.encode(frame);
+ this.sendVoiceOrWhisper(encoded);
+ } catch {
+ return;
+ }
+ clearTimeout(this.eosTimer);
+ this.eosTimer = setTimeout(() => this.endOfSpeech(), END_OF_SPEECH_MS);
+ }
+
+ /** An empty voice packet tells TeamSpeak clients the transmission ended. */
+ endOfSpeech() {
+ clearTimeout(this.eosTimer);
+ this.eosTimer = null;
+ if (this.state !== "ready") return;
+ try { this.sendVoiceOrWhisper(new Uint8Array(0)); } catch { /* not connected */ }
+ }
+
+ speakerDecoder(clientId) {
+ let decoder = this.decoders.get(clientId);
+ if (decoder) {
+ this.decoders.delete(clientId); // refresh LRU position
+ } else {
+ decoder = createOpusCodec();
+ while (this.decoders.size >= MAX_SPEAKER_DECODERS) {
+ const [oldestId, oldest] = this.decoders.entries().next().value;
+ oldest.dispose();
+ this.decoders.delete(oldestId);
+ }
+ }
+ this.decoders.set(clientId, decoder);
+ return decoder;
+ }
+
+ onTeamSpeakVoice(event) {
+ if (this.state !== "ready" || this.deafened || this.ws.readyState !== WebSocket.OPEN) return;
+ const clientId = Number(event.clientID);
+ if (clientId === Number(this.client.clientID) || clientId < 1 || clientId > 65535) return;
+ if (event.codec !== 4 && event.codec !== 5) return; // legacy Speex/CELT are not supported
+ const data = event.data instanceof Uint8Array ? event.data : new Uint8Array(0);
+
+ let kind = event.codec;
+ let payload = data;
+ if (this.serverDecode) {
+ if (this.ws.bufferedAmount > MAX_BUFFERED_PCM_BYTES) return;
+ kind = KIND_PCM;
+ if (data.length > 0) {
+ try { payload = this.speakerDecoder(clientId).decode(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); }
+ catch { return; }
+ }
+ } else if (this.ws.bufferedAmount > MAX_BUFFERED_OPUS_BYTES) {
+ return;
+ }
+
+ const packet = Buffer.allocUnsafe(3 + payload.length);
+ packet[0] = kind;
+ packet.writeUInt16BE(clientId, 1);
+ packet.set(payload, 3);
+ this.ws.send(packet);
+ }
+
+ // ---- teardown -------------------------------------------------------------
+
+ close() {
+ if (this.closing) return;
+ this.closing = true;
+ const wasReady = this.state === "ready";
+ this.state = "closed";
+ clearTimeout(this.joinTimer);
+ clearTimeout(this.stateTimer);
+ clearTimeout(this.eosTimer);
+ for (const off of this.unsubscribers) { try { off(); } catch { /* ignore */ } }
+ this.unsubscribers = [];
+ try { this.encoder?.dispose(); } catch { /* ignore */ }
+ this.encoder = null;
+ for (const decoder of this.decoders.values()) { try { decoder.dispose(); } catch { /* ignore */ } }
+ this.decoders.clear();
+ this.whisperTargetIds.clear();
+ this.whisperActive = false;
+ const client = this.client;
+ this.client = null;
+ if (client) {
+ client.disconnect({ reasonMessage: "Left from the web", signal: AbortSignal.timeout(2000) })
+ .catch(() => undefined);
+ }
+ if (wasReady) this.ctx.log.info(`[voice] ${this.ctx.ip} left`);
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
+ try { this.ws.close(1000, "bye"); } catch { /* ignore */ }
+ }
+ this.ctx.onClose();
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage