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
129
130
131
132
133
134
135
136
|
// 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 };
}
|