diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
| commit | 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch) | |
| tree | b2164a76dc3aa5b69e160d1135d3385bcbe6108d /gateway/src | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
Diffstat (limited to 'gateway/src')
| -rw-r--r-- | gateway/src/auth.js | 83 | ||||
| -rw-r--r-- | gateway/src/config.js | 97 | ||||
| -rw-r--r-- | gateway/src/index.js | 39 | ||||
| -rw-r--r-- | gateway/src/opus.js | 59 | ||||
| -rw-r--r-- | gateway/src/server.js | 128 | ||||
| -rw-r--r-- | gateway/src/voice-session.js | 500 |
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(); + } +} |
