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/server.js | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
Diffstat (limited to 'gateway/src/server.js')
| -rw-r--r-- | gateway/src/server.js | 128 |
1 files changed, 128 insertions, 0 deletions
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 }; +} |
