// 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 HEALTH_PATH = "/healthz"; 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) => { // Docker HEALTHCHECK target: answered by the gateway itself so it does not // depend on (or 404 from) the Flask dashboard behind it. if (req.url === HEALTH_PATH) { res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end("ok"); return; } 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 }; }