aboutsummaryrefslogtreecommitdiffstats
path: root/gateway/src/server.js
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/server.js
parent150858067059ba615c548f826bb343589993b4bb (diff)
ts web slopify code
Diffstat (limited to 'gateway/src/server.js')
-rw-r--r--gateway/src/server.js128
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 };
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage