Merge fix/sicherheit-und-stabilitaet in main

This commit is contained in:
mregosar committed 2026-11-01 20:25:45 +01:00
commit 48241f0784
13 files changed
+406 -46

No files matched your search

+1 -1
View File
@@ -394,7 +394,7 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden. - **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht. - **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen. - **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. - **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt.
### Beamer-Diashow ### Beamer-Diashow
+2
View File
@@ -66,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
} }
const token = randomToken(); const token = randomToken();
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000; const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
// Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst
for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t);
sessions.set(token, expiresAt); sessions.set(token, expiresAt);
return res.json({ token, expiresAt }); return res.json({ token, expiresAt });
}); });
+4 -4
View File
@@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key= // Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => { router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) { // Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden'); const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : '');
} if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
return service.streamOverview(); return service.streamOverview({ matchId: access.matchId });
})); }));
router.post('/matches', limit('create'), wrap((req) => { router.post('/matches', limit('create'), wrap((req) => {
+18 -7
View File
@@ -9,16 +9,26 @@
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min // Game Over: competitive mg_active de_mirage score 13:9 after 35 min
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen) // Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
const RE = { const RE = {
matchStart: /World triggered "Match_Start" on "([^"]+)"/, matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
warmupStart: /World triggered "Warmup_Start"/, warmupStart: /^World triggered "Warmup_Start"$/,
worldRoundEnd: /World triggered "Round_End"/, worldRoundEnd: /^World triggered "Round_End"$/,
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/, teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/, roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/,
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer. // Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/, gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
}; };
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
export function logPayload(line) {
return String(line).trim().replace(PREFIX, '');
}
// Gründe für ein Rundenende aus den SFUI-Hinweisen // Gründe für ein Rundenende aus den SFUI-Hinweisen
const REASONS = { const REASONS = {
Target_Bombed: 'bomb', Target_Bombed: 'bomb',
@@ -85,7 +95,8 @@ export function applyLogLines(state, lines, ctx) {
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null }); Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
}; };
for (const line of lines) { for (const raw of lines) {
const line = logPayload(raw);
let m; let m;
if ((m = RE.matchStart.exec(line))) { if ((m = RE.matchStart.exec(line))) {
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu. // Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
+30 -3
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import crypto from 'node:crypto';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js'; import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js'; import { trimLiveState } from './rounds.js';
@@ -696,6 +697,10 @@ export class MatchService extends EventEmitter {
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000); const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited'); if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]); this.chatRate.set(who, [...recent, now]);
// Gelegentlich alte Einträge anderer Spieler entfernen
if (this.chatRate.size > 500) {
for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k);
}
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null; const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(` this.db.prepare(`
@@ -823,8 +828,30 @@ export class MatchService extends EventEmitter {
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen – * Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten. * keine Codes, keine Verbindungsdaten.
*/ */
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) { /** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); matchStreamKey(matchId) {
const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22);
return `${matchId}.${mac}`;
}
/**
* Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang.
* Ohne STREAM_KEY ist die Übersicht offen.
*/
streamAccess(key) {
if (!this.config.streamKey) return { matchId: null };
const value = String(key ?? '');
if (safeEqual(value, this.config.streamKey)) return { matchId: null };
const dot = value.lastIndexOf('.');
const matchId = dot > 0 ? value.slice(0, dot) : '';
if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId };
return null;
}
streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) {
const rows = matchId
? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId)
: this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const now = this.now(); const now = this.now();
const active = []; const active = [];
const upcoming = []; const upcoming = [];
@@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter {
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`, stream: `${base}/stream/${formatCode(match.code_spectator)}`,
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt) // Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`, overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`,
}, },
}; };
} }
+46 -10
View File
@@ -1,5 +1,8 @@
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import { safeEqual } from './security.js'; // Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde
const SOCKET_BURST = 20;
const SOCKET_REFILL = 2;
const SOCKET_REFILL_MS = 1000;
/** /**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die * Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
@@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) {
let streamTimer = null; let streamTimer = null;
const broadcastStream = () => { const broadcastStream = () => {
clearTimeout(streamTimer); clearTimeout(streamTimer);
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200); streamTimer = setTimeout(async () => {
io.to('stream-overview').emit('stream:overview', service.streamOverview());
// Zugänge mit Match-Schlüssel bekommen nur ihr Match
for (const s of await io.in('stream-match').fetchSockets()) {
s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch }));
}
}, 200);
}; };
service.on('changed', (matchId) => { service.on('changed', (matchId) => {
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('overview:subscribe', () => { // Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden,
// und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand).
let budget = SOCKET_BURST;
const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS);
const on = (event, handler) => socket.on(event, (payload) => {
if (budget <= 0) {
socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' });
return;
}
budget -= 1;
try {
handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {});
} catch (err) {
console.error(`[socket] ${event}: ${err.message}`);
}
});
on('overview:subscribe', () => {
socket.join('overview'); socket.join('overview');
socket.emit('overview', { matches: service.listMatches() }); socket.emit('overview', { matches: service.listMatches() });
}); });
socket.on('stream:subscribe', ({ key } = {}) => { on('stream:subscribe', ({ key }) => {
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) { const access = service.streamAccess(typeof key === 'string' ? key : '');
if (!access) {
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' }); socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return; return;
} }
socket.join('stream-overview'); socket.leave('stream-overview');
socket.emit('stream:overview', service.streamOverview()); socket.leave('stream-match');
// Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches
socket.data.streamMatch = access.matchId ?? null;
socket.join(access.matchId ? 'stream-match' : 'stream-overview');
socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId }));
}); });
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => { on('match:subscribe', ({ matchId, token, playerToken }) => {
if (typeof matchId !== 'string') return; if (typeof matchId !== 'string' || matchId.length > 64) return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6)); for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
const str = (v) => (typeof v === 'string' ? v : null); const str = (v) => (typeof v === 'string' ? v : null);
socket.data = { matchId, token: str(token), playerToken: str(playerToken) }; socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
try { try {
service.getMatch(matchId); service.getMatch(matchId);
} catch (err) { } catch (err) {
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
}; };
socket.on('disconnect', () => { socket.on('disconnect', () => {
clearInterval(refill);
try {
if (socket.data?.matchId) leave(socket.data.matchId); if (socket.data?.matchId) leave(socket.data.matchId);
} catch (err) {
console.error(`[socket] disconnect: ${err.message}`);
}
}); });
}); });
+28 -6
View File
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb); return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
} }
/**
* Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile
* in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die
* Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt,
* ebenso alle in `secrets` genannten Werte.
*/
export function safeErrorMessage(err, secrets = []) {
const stderr = String(err?.stderr ?? '').trim();
let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler');
text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen';
text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***');
for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***');
return text.slice(0, 500);
}
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */ /** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
export function createRateLimiter({ windowMs, max }) { export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
const hits = new Map(); const hits = new Map();
return function check(key) { let lastSweep = Date.now();
function check(key) {
const now = Date.now(); const now = Date.now();
// Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist
if (now - lastSweep > windowMs) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
lastSweep = now;
}
const entry = hits.get(key); const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) { if (!entry || now - entry.start > windowMs) {
// Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge)
if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value);
hits.set(key, { start: now, count: 1 }); hits.set(key, { start: now, count: 1 });
return true; return true;
} }
entry.count += 1; entry.count += 1;
if (hits.size > 10_000) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
}
return entry.count <= max; return entry.count <= max;
}; }
check.size = () => hits.size;
return check;
} }
+9
View File
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js'; import { sanitizeConsoleText } from './cs2Config.js';
import { safeErrorMessage } from '../security.js';
const exec = promisify(execFile); const exec = promisify(execFile);
@@ -149,8 +150,16 @@ export class DockerServerAdapter {
async docker(args, timeout = 60_000) { async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args, this.options.name); if (this.runDocker) return this.runDocker(args, this.options.name);
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env; const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
try {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env }); const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim(); return stdout.trim();
} catch (err) {
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
const clean = new Error(safeErrorMessage(err));
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
clean.code = err.code;
throw clean;
}
} }
allocatePorts(blocked = new Set()) { allocatePorts(blocked = new Set()) {
+9 -3
View File
@@ -3,7 +3,7 @@
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu. // leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
import { HttpError } from '../matchService.js'; import { HttpError } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword, safeErrorMessage } from '../security.js';
const SLOW_POLL_MS = 10_000; const SLOW_POLL_MS = 10_000;
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet // Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
@@ -208,6 +208,11 @@ export class KnifeServers {
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) }, secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`, serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
}); });
// Während des Starts entfernt: Container gleich wieder abbauen
if (!this.row(id)) {
await this.adapter.stop(result.handle).catch(() => {});
return;
}
this.update(id, { this.update(id, {
status: 'starting', status: 'starting',
message: 'Container läuft, Spielserver lädt', message: 'Container läuft, Spielserver lädt',
@@ -219,8 +224,9 @@ export class KnifeServers {
started_at: Date.now(), started_at: Date.now(),
}); });
} catch (err) { } catch (err) {
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` }); const message = safeErrorMessage(err, [row.rcon_password]);
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed'); if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` });
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed');
} finally { } finally {
this.busy.delete(id); this.busy.delete(id);
} }
+16 -5
View File
@@ -75,9 +75,17 @@ export class RconClient {
onData(chunk) { onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]); this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer); let decoded;
this.buffer = rest; try {
for (const packet of packets) for (const listener of this.listeners) listener(packet); decoded = decodePackets(this.buffer);
} catch (err) {
// Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden
this.buffer = Buffer.alloc(0);
this.socket?.destroy(new RconError(err.message));
return;
}
this.buffer = decoded.rest;
for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet);
} }
authenticate() { authenticate() {
@@ -105,15 +113,18 @@ export class RconClient {
const id = this.nextId++; const id = this.nextId++;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let body = ''; let body = '';
let answered = false;
let quietTimer = null; let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs); // Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg
const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs);
const listener = (packet) => { const listener = (packet) => {
if (packet.id !== id) return; if (packet.id !== id) return;
answered = true;
body += packet.body; body += packet.body;
clearTimeout(quietTimer); clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs); quietTimer = setTimeout(finish, quietMs);
}; };
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen')); const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => { const finish = (err) => {
clearTimeout(timer); clearTimeout(timer);
clearTimeout(quietTimer); clearTimeout(quietTimer);
+3 -2
View File
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
try { try {
await runRcon(target, plan.beforeMapChange); await runRcon(target, plan.beforeMapChange);
} catch (err) { } catch (err) {
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler. // Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`); // beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten.
if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
} }
const deadline = Date.now() + loadTimeoutMs; const deadline = Date.now() + loadTimeoutMs;
+16 -5
View File
@@ -3,7 +3,7 @@ import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js'; import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js'; import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword, safeErrorMessage } from '../security.js';
export const SERVER_STATUS = { export const SERVER_STATUS = {
starting: 'Server startet', starting: 'Server startet',
@@ -343,6 +343,7 @@ export class ServerManager {
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet'); this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
const attempt = (previous?.attempts ?? 0) + 1;
try { try {
const result = await this.adapter.start({ const result = await this.adapter.start({
matchId, matchId,
@@ -350,6 +351,14 @@ export class ServerManager {
secrets, secrets,
teamNames: { A: match.team_a_name, B: match.team_b_name }, teamNames: { A: match.team_a_name, B: match.team_b_name },
}); });
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
const current = this.row(matchId);
if (!current || current.status !== 'starting' || current.attempts !== attempt) {
this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`);
await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {});
return;
}
this.update(matchId, { this.update(matchId, {
handle: JSON.stringify(result.handle), handle: JSON.stringify(result.handle),
host: result.host, host: result.host,
@@ -358,8 +367,9 @@ export class ServerManager {
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch', message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
}); });
} catch (err) { } catch (err) {
this.update(matchId, { status: 'start_failed', message: err.message }); const message = safeErrorMessage(err, Object.values(secrets));
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`); if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message });
if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`);
} }
} finally { } finally {
this.busy.delete(matchId); this.busy.delete(matchId);
@@ -536,8 +546,9 @@ export class ServerManager {
try { try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) { } catch (err) {
this.update(matchId, { status: 'config_failed', message: err.message }); const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`); this.update(matchId, { status: 'config_failed', message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
return; return;
} }
this.update(matchId, { this.update(matchId, {
+224
View File
@@ -0,0 +1,224 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { io as connect } from 'socket.io-client';
import { loadConfig } from '../src/config.js';
import { createApp } from '../src/app.js';
import { DemoServerAdapter } from '../src/servers/demoAdapter.js';
import { applyLogLines, initialLiveState } from '../src/liveScore.js';
import { RconClient } from '../src/servers/rcon.js';
import { createRateLimiter, safeErrorMessage } from '../src/security.js';
// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war.
const quiet = { log() {}, warn() {}, error() {} };
async function start(overrides = {}, adapter) {
const config = loadConfig({
databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '',
demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides,
});
const app = createApp(config, { logger: quiet, adapter });
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const port = app.server.address().port;
const call = async (method, path, { token, body } = {}) => {
const res = await fetch(`http://127.0.0.1:${port}/api${path}`, {
method,
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
};
return { app, port, call, close: () => app.close() };
}
const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } });
test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => {
const { port, call, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) {
socket.emit('stream:subscribe', payload);
socket.emit('match:subscribe', payload);
socket.emit('overview:subscribe', payload);
}
await new Promise((r) => setTimeout(r, 200));
assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch');
} finally {
socket.close();
await close();
}
});
test('Zu viele Socket-Abfragen werden gebremst', async () => {
const { port, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve) => socket.on('connect', resolve));
let limited = 0;
socket.on('rate_limited', () => { limited += 1; });
for (let i = 0; i < 60; i++) socket.emit('overview:subscribe');
await new Promise((r) => setTimeout(r, 300));
assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen');
} finally {
socket.close();
await close();
}
});
test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => {
class Failing extends DemoServerAdapter {
async start({ secrets }) {
const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`);
err.stderr = '';
throw err;
}
}
const { app, call, close } = await start({}, new Failing({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await app.manager.idle();
const row = app.manager.row(id);
assert.equal(row.status, 'start_failed');
const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body);
for (const secret of [row.rcon_password, row.server_password]) {
assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht');
assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung');
}
assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password));
} finally {
await close();
}
});
test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => {
const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' });
assert.equal(msg, 'Error: port is already allocated');
assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***');
assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch');
});
test('Chat-Nachrichten können keine Map beenden oder starten', () => {
const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
const ts = '11/01/2026 - 20:00:00.000 - ';
const lines = [
`${ts}"Troll<3><[U:1:1]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`,
`${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]><T>" say "hi"`,
];
const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.deepEqual(events, []);
assert.notEqual(after.status, 'finished');
assert.deepEqual(after.score, { A: 0, B: 0 });
// Die echte Systemzeile wirkt weiterhin
const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.equal(real.events[0]?.type, 'map_end');
});
test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => {
let release;
const stopped = [];
class Slow extends DemoServerAdapter {
async start(args) {
await new Promise((r) => { release = r; });
return super.start(args);
}
async stop(handle) {
stopped.push(handle.matchId);
return super.stop(handle);
}
}
const { app, call, close } = await start({}, new Slow({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await new Promise((r) => setTimeout(r, 50));
await app.manager.stop(id, 'Test');
release();
await app.manager.idle();
assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt');
assert.equal(app.manager.row(id).status, 'stopped');
assert.equal(app.manager.row(id).handle, null);
// Löschen während des Starts
const second = (await newMatch(call)).body.id;
await new Promise((r) => setTimeout(r, 50));
const deleting = app.manager.deleteMatch(second, 'Test');
await deleting;
release();
await app.manager.idle();
assert.ok(stopped.includes(second));
} finally {
await close();
}
});
/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */
async function fakeRcon(onCommand) {
const server = net.createServer((socket) => {
socket.on('data', (buf) => {
const id = buf.readInt32LE(4);
const type = buf.readInt32LE(8);
if (type === 3) {
const ok = Buffer.alloc(14);
ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8);
socket.write(ok);
} else onCommand(socket, id);
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { port: server.address().port, close: () => server.close() };
}
test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => {
const bad = await fakeRcon((socket) => {
const junk = Buffer.alloc(8);
junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum
socket.write(junk);
});
const silent = await fakeRcon(() => {});
try {
const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect();
await assert.rejects(a.command('status'), /geschlossen|keine Antwort/);
a.close();
const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect();
await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/);
b.close();
} finally {
bad.close();
silent.close();
}
});
test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => {
const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' });
try {
const one = (await newMatch(call)).body;
const two = (await newMatch(call)).body;
const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body;
const link = view.organizer.links.overview;
assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link');
const key = new URL(link).searchParams.get('key');
const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body;
const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id);
assert.deepEqual(ids, [one.id], 'nur das eigene Match');
assert.ok(!ids.includes(two.id));
// Gefälschter Schlüssel für ein anderes Match geht nicht
assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403);
// Der globale Schlüssel zeigt weiter alles
const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body;
assert.equal(all.upcoming.length, 2);
} finally {
await close();
}
});
test('Rate-Limiter hält seinen Speicher begrenzt', () => {
const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 });
for (let i = 0; i < 1000; i++) check(`ip-${i}`);
assert.ok(check.size() <= 100);
for (let i = 0; i < 5; i++) assert.equal(check('x'), true);
assert.equal(check('x'), false);
});