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 };
}
|