aboutsummaryrefslogtreecommitdiffstats
path: root/gateway/src/server.js
blob: b4e716ee8ee6c7434ddd1e03c7f36e88f364600b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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